Paint the desktop with Flutter too, beside libcosmic
Two desktop GUIs now, and both first-class: `just run` is libcosmic under
jolt, `just flutter-desktop` is Flutter's Linux target over frq.hiccup. The
same screens out of common/frq/screens/, two renderers.
Almost all of it is the APK's work. One `clojure -M:cljd compile` over the
same flutter/src and common/ feeds both targets, and path_provider and
url_launcher each have a Linux implementation, so the seam needed no new
call. What is new is the toolchain and the runner:
* devShells.flutter-desktop — the APK shell with the Android half swapped
out. The same pinned clojure and flutter, with cmake, ninja, pkg-config
and gtk3 where the JDK and the SDK are. Separate rather than merged,
because a desktop build has no use for a few hundred megabytes of
Android SDK — the same argument that keeps Flutter out of the default
shell.
* just flutter-desktop [build|run] — the re-entry shape every recipe here
has, and nixGL off NixOS for the reason `run` needs it and `tui` does
not. Still impure for the network half of what makes `apk` impure, but
with no writable-ANDROID_HOME dance, since nothing writes to the store.
* flutter/linux/ — the template runner renamed. frq rather than
cljd_flutter, uk.nandi.frq rather than com.example.cljd_flutter, so the
binary, the window title and the GTK application id agree with the APK's
applicationId.
And two bugs the second target exposed, both of them places where "the
phone" was assumed rather than asked:
* frq.io.dart/write-private-file! was a plain write, on the grounds that
Android storage is already private to the app. On a Linux desktop it is
not — the file lands under the XDG data directory with the process
umask, world-readable on most systems, and what it holds is a broker
token. It branches on Platform.isAndroid now and otherwise does what
frq.io.jolt does, in the same order: create, chmod, then write, so the
token is never on disk readable even for an instant. Dart has no chmod,
which is why that is a process and not a call.
* frq.oauth.dart handed the capture page frq://auth unconditionally, to
raise the app from behind Chrome. Nothing on a desktop claims that
scheme, so it was a button that does nothing. nil there instead — which
core/capture-html already documented as the desktop case and already
handled.
The stale UNVERIFIED note on frq.io.dart goes with them: two targets compile
that file now.
Verified: builds, and the window opens on COSMIC under Impeller/GL with the
connect screen painted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>fc5f63c parent: 709af9f modified
CLAUDE.md +25 -8 | @@ -39,11 +39,28 @@ Adding a host call means adding it to the seam in `common/frq/io.cljc` and to | ||
| 39 | 39 | both implementations. Name it for the result rather than the mechanism — the |
| 40 | 40 | seam has `write-private-file!` and not a chmod, because Dart has no chmod. |
| 41 | 41 | |
| 42 | -`flutter/` builds with `just apk`, out of the flake's own `.#flutter` shell | |
| 43 | -(clojure, jdk17, flutter) and its `.#android-sdk` package. Impure on purpose: | |
| 44 | -Gradle fetches its own dependencies and writes into `ANDROID_HOME`, so the | |
| 45 | -recipe copies the store SDK to `flutter/.home` and lets it finish there. See | |
| 46 | -flutter/README.md. It is the only APK there is — the jolt APK, | |
| 47 | -`nix/android.nix`, `android/` and the `.#apk` outputs are gone, because every | |
| 48 | -backend that APK could paint with is retired. jvui and Vidya were experiments; | |
| 49 | -libcosmic is the desktop window and does not cross to a phone. | |
| 42 | +`flutter/` builds two things, from one `clojure -M:cljd compile`: | |
| 43 | + | |
| 44 | +`just apk`, out of the flake's own `.#flutter` shell (clojure, jdk17, flutter) | |
| 45 | +and its `.#android-sdk` package. Impure on purpose: Gradle fetches its own | |
| 46 | +dependencies and writes into `ANDROID_HOME`, so the recipe copies the store SDK | |
| 47 | +to `flutter/.home` and lets it finish there. It is the only APK there is — the | |
| 48 | +jolt APK, `nix/android.nix`, `android/` and the `.#apk` outputs are gone, | |
| 49 | +because every backend that APK could paint with is retired. | |
| 50 | + | |
| 51 | +`just flutter-desktop`, out of `.#flutter-desktop` (the same clojure and | |
| 52 | +flutter, with cmake, ninja, pkg-config and gtk3 where the JDK and the SDK are). | |
| 53 | +Impure for the network half of the same reasons and no writable-SDK dance, since | |
| 54 | +nothing writes into the store. nixGL off NixOS, like `just run`. | |
| 55 | + | |
| 56 | +So there are two desktop GUIs and they are both first-class: `just run` is | |
| 57 | +libcosmic under jolt, `just flutter-desktop` is Flutter's Linux target over | |
| 58 | +`frq.hiccup`. Same screens out of `common/frq/screens/`, two renderers. jvui and | |
| 59 | +Vidya were experiments and are gone; libcosmic is a desktop window and does not | |
| 60 | +cross to a phone, which is what the Flutter half is for. | |
| 61 | + | |
| 62 | +The consequence for `common/` is that "the phone" is no longer a synonym for | |
| 63 | +"the ClojureDart side" — two targets compile it. An implementation that branches | |
| 64 | +on the platform has to ask (`Platform.isAndroid`) rather than assume; see | |
| 65 | +`frq.io.dart/write-private-file!`, where assuming cost a token its file mode. | |
| 66 | +See flutter/README.md. | |
| @@ -39,11 +39,28 @@ Adding a host call means adding it to the seam in `common/frq/io.cljc` and to | |||
| 39 | both implementations. Name it for the result rather than the mechanism — the | 39 | both implementations. Name it for the result rather than the mechanism — the |
| 40 | seam has `write-private-file!` and not a chmod, because Dart has no chmod. | 40 | seam has `write-private-file!` and not a chmod, because Dart has no chmod. |
| 41 | 41 | ||
| 42 | -`flutter/` builds with `just apk`, out of the flake's own `.#flutter` shell | 42 | +`flutter/` builds two things, from one `clojure -M:cljd compile`: |
| 43 | -(clojure, jdk17, flutter) and its `.#android-sdk` package. Impure on purpose: | 43 | + |
| 44 | -Gradle fetches its own dependencies and writes into `ANDROID_HOME`, so the | 44 | +`just apk`, out of the flake's own `.#flutter` shell (clojure, jdk17, flutter) |
| 45 | -recipe copies the store SDK to `flutter/.home` and lets it finish there. See | 45 | +and its `.#android-sdk` package. Impure on purpose: Gradle fetches its own |
| 46 | -flutter/README.md. It is the only APK there is — the jolt APK, | 46 | +dependencies and writes into `ANDROID_HOME`, so the recipe copies the store SDK |
| 47 | -`nix/android.nix`, `android/` and the `.#apk` outputs are gone, because every | 47 | +to `flutter/.home` and lets it finish there. It is the only APK there is — the |
| 48 | -backend that APK could paint with is retired. jvui and Vidya were experiments; | 48 | +jolt APK, `nix/android.nix`, `android/` and the `.#apk` outputs are gone, |
| 49 | -libcosmic is the desktop window and does not cross to a phone. | 49 | +because every backend that APK could paint with is retired. |
| 50 | + | ||
| 51 | +`just flutter-desktop`, out of `.#flutter-desktop` (the same clojure and | ||
| 52 | +flutter, with cmake, ninja, pkg-config and gtk3 where the JDK and the SDK are). | ||
| 53 | +Impure for the network half of the same reasons and no writable-SDK dance, since | ||
| 54 | +nothing writes into the store. nixGL off NixOS, like `just run`. | ||
| 55 | + | ||
| 56 | +So there are two desktop GUIs and they are both first-class: `just run` is | ||
| 57 | +libcosmic under jolt, `just flutter-desktop` is Flutter's Linux target over | ||
| 58 | +`frq.hiccup`. Same screens out of `common/frq/screens/`, two renderers. jvui and | ||
| 59 | +Vidya were experiments and are gone; libcosmic is a desktop window and does not | ||
| 60 | +cross to a phone, which is what the Flutter half is for. | ||
| 61 | + | ||
| 62 | +The consequence for `common/` is that "the phone" is no longer a synonym for | ||
| 63 | +"the ClojureDart side" — two targets compile it. An implementation that branches | ||
| 64 | +on the platform has to ask (`Platform.isAndroid`) rather than assume; see | ||
| 65 | +`frq.io.dart/write-private-file!`, where assuming cost a token its file mode. | ||
| 66 | +See flutter/README.md. | ||
modified
flake.nix +53 -0 | @@ -607,6 +607,59 @@ | ||
| 607 | 607 | FRQ_ANDROID_SDK = |
| 608 | 608 | "${androidSdkFor pkgs.stdenv.hostPlatform.system}/libexec/android-sdk"; |
| 609 | 609 | }; |
| 610 | + | |
| 611 | + # The other desktop GUI. Same ClojureDart half as the APK — one | |
| 612 | + # `clojure -M:cljd compile`, one flutter/src — over Flutter's Linux | |
| 613 | + # target instead of its Android one, so `flutter/linux/` is the | |
| 614 | + # runner and CMake and Ninja are the build rather than Gradle. | |
| 615 | + # | |
| 616 | + # A separate shell from `flutter` rather than one that carries both, | |
| 617 | + # because the halves are disjoint: this wants GTK and a C++ toolchain | |
| 618 | + # and no JDK, and the APK wants a JDK and an SDK and no GTK. Sharing | |
| 619 | + # them would mean every desktop build paying for a few hundred | |
| 620 | + # megabytes of Android SDK it never opens, which is the same argument | |
| 621 | + # that keeps Flutter out of the default shell. | |
| 622 | + # | |
| 623 | + # mkShell and not mkShellNoCC, unlike every other shell here: this is | |
| 624 | + # the one that actually compiles C++. stdenv brings the compiler, and | |
| 625 | + # gtk3 in buildInputs is what puts its .pc file where the runner's | |
| 626 | + # `pkg_check_modules(GTK gtk+-3.0)` can find it. | |
| 627 | + flutter-desktop = pkgs.mkShell { | |
| 628 | + name = "frq-flutter-desktop"; | |
| 629 | + | |
| 630 | + # clojure and flutter are the APK shell's, and deliberately the | |
| 631 | + # same two: the Dart that runs here is generated by the same | |
| 632 | + # compiler from the same source, and a second Flutter version | |
| 633 | + # under it would be a second set of engine artifacts and a second | |
| 634 | + # answer to "does the phone build match the desktop one". | |
| 635 | + nativeBuildInputs = [ | |
| 636 | + pkgs.clojure | |
| 637 | + pkgs.flutter | |
| 638 | + pkgs.just | |
| 639 | + pkgs.cmake | |
| 640 | + pkgs.ninja | |
| 641 | + pkgs.pkg-config | |
| 642 | + ]; | |
| 643 | + | |
| 644 | + # gtk3 is the runner's own dependency; the rest are url_launcher's | |
| 645 | + # Linux implementation, which is a GTK plugin compiled into the | |
| 646 | + # bundle. path_provider needs nothing here — its Linux half is | |
| 647 | + # pure Dart over the XDG directories. | |
| 648 | + buildInputs = [ pkgs.gtk3 pkgs.glib ]; | |
| 649 | + | |
| 650 | + # Same reason `just run` reaches for it: Flutter paints through | |
| 651 | + # GL, and off NixOS the driver that can do that is the host's, not | |
| 652 | + # the store's. The recipe reads this exactly as `run` does. | |
| 653 | + NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel"; | |
| 654 | + | |
| 655 | + # The recipe's re-entry test, the way JOLT_NATIVE_LIB is the | |
| 656 | + # default shell's. Nothing else sets it, so `just flutter-desktop` | |
| 657 | + # outside the shell re-enters and lands back on the same recipe — | |
| 658 | + # no flag to forget, and no second code path for someone who runs | |
| 659 | + # `nix develop .#flutter-desktop --command just flutter-desktop` | |
| 660 | + # by hand. | |
| 661 | + FRQ_FLUTTER_DESKTOP = "1"; | |
| 662 | + }; | |
| 610 | 663 | }); |
| 611 | 664 | |
| 612 | 665 | apps = forEachSystem (pkgs: { |
| @@ -607,6 +607,59 @@ | |||
| 607 | FRQ_ANDROID_SDK = | 607 | FRQ_ANDROID_SDK = |
| 608 | "${androidSdkFor pkgs.stdenv.hostPlatform.system}/libexec/android-sdk"; | 608 | "${androidSdkFor pkgs.stdenv.hostPlatform.system}/libexec/android-sdk"; |
| 609 | }; | 609 | }; |
| 610 | + | ||
| 611 | + # The other desktop GUI. Same ClojureDart half as the APK — one | ||
| 612 | + # `clojure -M:cljd compile`, one flutter/src — over Flutter's Linux | ||
| 613 | + # target instead of its Android one, so `flutter/linux/` is the | ||
| 614 | + # runner and CMake and Ninja are the build rather than Gradle. | ||
| 615 | + # | ||
| 616 | + # A separate shell from `flutter` rather than one that carries both, | ||
| 617 | + # because the halves are disjoint: this wants GTK and a C++ toolchain | ||
| 618 | + # and no JDK, and the APK wants a JDK and an SDK and no GTK. Sharing | ||
| 619 | + # them would mean every desktop build paying for a few hundred | ||
| 620 | + # megabytes of Android SDK it never opens, which is the same argument | ||
| 621 | + # that keeps Flutter out of the default shell. | ||
| 622 | + # | ||
| 623 | + # mkShell and not mkShellNoCC, unlike every other shell here: this is | ||
| 624 | + # the one that actually compiles C++. stdenv brings the compiler, and | ||
| 625 | + # gtk3 in buildInputs is what puts its .pc file where the runner's | ||
| 626 | + # `pkg_check_modules(GTK gtk+-3.0)` can find it. | ||
| 627 | + flutter-desktop = pkgs.mkShell { | ||
| 628 | + name = "frq-flutter-desktop"; | ||
| 629 | + | ||
| 630 | + # clojure and flutter are the APK shell's, and deliberately the | ||
| 631 | + # same two: the Dart that runs here is generated by the same | ||
| 632 | + # compiler from the same source, and a second Flutter version | ||
| 633 | + # under it would be a second set of engine artifacts and a second | ||
| 634 | + # answer to "does the phone build match the desktop one". | ||
| 635 | + nativeBuildInputs = [ | ||
| 636 | + pkgs.clojure | ||
| 637 | + pkgs.flutter | ||
| 638 | + pkgs.just | ||
| 639 | + pkgs.cmake | ||
| 640 | + pkgs.ninja | ||
| 641 | + pkgs.pkg-config | ||
| 642 | + ]; | ||
| 643 | + | ||
| 644 | + # gtk3 is the runner's own dependency; the rest are url_launcher's | ||
| 645 | + # Linux implementation, which is a GTK plugin compiled into the | ||
| 646 | + # bundle. path_provider needs nothing here — its Linux half is | ||
| 647 | + # pure Dart over the XDG directories. | ||
| 648 | + buildInputs = [ pkgs.gtk3 pkgs.glib ]; | ||
| 649 | + | ||
| 650 | + # Same reason `just run` reaches for it: Flutter paints through | ||
| 651 | + # GL, and off NixOS the driver that can do that is the host's, not | ||
| 652 | + # the store's. The recipe reads this exactly as `run` does. | ||
| 653 | + NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel"; | ||
| 654 | + | ||
| 655 | + # The recipe's re-entry test, the way JOLT_NATIVE_LIB is the | ||
| 656 | + # default shell's. Nothing else sets it, so `just flutter-desktop` | ||
| 657 | + # outside the shell re-enters and lands back on the same recipe — | ||
| 658 | + # no flag to forget, and no second code path for someone who runs | ||
| 659 | + # `nix develop .#flutter-desktop --command just flutter-desktop` | ||
| 660 | + # by hand. | ||
| 661 | + FRQ_FLUTTER_DESKTOP = "1"; | ||
| 662 | + }; | ||
| 610 | }); | 663 | }); |
| 611 | 664 | ||
| 612 | apps = forEachSystem (pkgs: { | 665 | apps = forEachSystem (pkgs: { |
modified
flutter/README.md +56 -4 | @@ -76,13 +76,65 @@ plugins and is its own project. | ||
| 76 | 76 | |
| 77 | 77 | ## Building it |
| 78 | 78 | |
| 79 | +Two targets out of one tree. The ClojureDart compile is the same command for | |
| 80 | +both — `clojure -M:cljd compile` over `src/` and `../common` — and what differs | |
| 81 | +is only what Flutter is asked to wrap it in. | |
| 82 | + | |
| 79 | 83 | ```bash |
| 80 | -just apk # the debug APK | |
| 81 | -just apk install # and onto a connected device | |
| 82 | -just apk run # and launched | |
| 83 | -just apk log # logcat | |
| 84 | +just apk # the debug APK | |
| 85 | +just apk install # and onto a connected device | |
| 86 | +just apk run # and launched | |
| 87 | +just apk log # logcat | |
| 88 | + | |
| 89 | +just flutter-desktop # the debug Linux bundle | |
| 90 | +just flutter-desktop run # and the window | |
| 84 | 91 | ``` |
| 85 | 92 | |
| 93 | +### The desktop one | |
| 94 | + | |
| 95 | +There are two desktop GUIs now, and they are not a fallback for each other: | |
| 96 | +`just run` is libcosmic under jolt, and `just flutter-desktop` is this tree | |
| 97 | +under Flutter's Linux target. Same screens out of `common/frq/screens/`, two | |
| 98 | +renderers — `glimmer-cosmic` walks the hiccup on one side and `frq.hiccup` | |
| 99 | +emits Flutter widgets on the other. | |
| 100 | + | |
| 101 | +Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the | |
| 102 | +Android half swapped out: clojure and Flutter are the same two packages at the | |
| 103 | +same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and | |
| 104 | +the SDK do. Kept separate rather than merged into one shell because the halves | |
| 105 | +are disjoint — a desktop build has no use for a few hundred megabytes of | |
| 106 | +Android SDK, which is the same argument that keeps Flutter out of the default | |
| 107 | +shell. | |
| 108 | + | |
| 109 | +Still impure, for one of the two reasons the APK is: pub.dev resolution and | |
| 110 | +Flutter's engine artifacts are network. What it does *not* need is the | |
| 111 | +writable-`ANDROID_HOME` dance, since nothing here writes into the store — so | |
| 112 | +there is no `flutter/.home` on this path. | |
| 113 | + | |
| 114 | +nixGL off NixOS, for the reason `just run` needs it and `just tui` does not: | |
| 115 | +Flutter paints through GL and the driver that can do that is the host's. | |
| 116 | + | |
| 117 | +`linux/` is the Flutter template's GTK runner, renamed — `frq` rather than | |
| 118 | +`cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so | |
| 119 | +the binary, the window title and the GTK application id agree with the APK's | |
| 120 | +`applicationId`. | |
| 121 | + | |
| 122 | +Two things the desktop target changed in the Dart, both of them cases where | |
| 123 | +"the phone" had been assumed rather than asked: | |
| 124 | + | |
| 125 | +* `frq.io.dart/write-private-file!` was a plain write, on the grounds that | |
| 126 | + Android storage is already private to the app. On a Linux desktop it is not: | |
| 127 | + the file lands under the XDG data directory with the process umask, and it | |
| 128 | + holds a broker token. The desktop branch now does what `frq.io.jolt` does — | |
| 129 | + create, chmod, then write — and Dart having no chmod is why that is a | |
| 130 | + process. | |
| 131 | +* `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to | |
| 132 | + raise the app from behind Chrome. Nothing on a desktop claims that scheme, so | |
| 133 | + it is now nil there — which `core/capture-html` already documented as the | |
| 134 | + desktop case and already handled. | |
| 135 | + | |
| 136 | +### The APK | |
| 137 | + | |
| 86 | 138 | Impure on purpose. Gradle resolves its own dependencies over the network and |
| 87 | 139 | installs build-tools and a platform into `ANDROID_HOME` as it goes, so it can |
| 88 | 140 | neither run in a sandbox nor write to the store. What nix gives is the |
| @@ -76,13 +76,65 @@ plugins and is its own project. | |||
| 76 | 76 | ||
| 77 | ## Building it | 77 | ## Building it |
| 78 | 78 | ||
| 79 | +Two targets out of one tree. The ClojureDart compile is the same command for | ||
| 80 | +both — `clojure -M:cljd compile` over `src/` and `../common` — and what differs | ||
| 81 | +is only what Flutter is asked to wrap it in. | ||
| 82 | + | ||
| 79 | ```bash | 83 | ```bash |
| 80 | -just apk # the debug APK | 84 | +just apk # the debug APK |
| 81 | -just apk install # and onto a connected device | 85 | +just apk install # and onto a connected device |
| 82 | -just apk run # and launched | 86 | +just apk run # and launched |
| 83 | -just apk log # logcat | 87 | +just apk log # logcat |
| 88 | + | ||
| 89 | +just flutter-desktop # the debug Linux bundle | ||
| 90 | +just flutter-desktop run # and the window | ||
| 84 | ``` | 91 | ``` |
| 85 | 92 | ||
| 93 | +### The desktop one | ||
| 94 | + | ||
| 95 | +There are two desktop GUIs now, and they are not a fallback for each other: | ||
| 96 | +`just run` is libcosmic under jolt, and `just flutter-desktop` is this tree | ||
| 97 | +under Flutter's Linux target. Same screens out of `common/frq/screens/`, two | ||
| 98 | +renderers — `glimmer-cosmic` walks the hiccup on one side and `frq.hiccup` | ||
| 99 | +emits Flutter widgets on the other. | ||
| 100 | + | ||
| 101 | +Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the | ||
| 102 | +Android half swapped out: clojure and Flutter are the same two packages at the | ||
| 103 | +same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and | ||
| 104 | +the SDK do. Kept separate rather than merged into one shell because the halves | ||
| 105 | +are disjoint — a desktop build has no use for a few hundred megabytes of | ||
| 106 | +Android SDK, which is the same argument that keeps Flutter out of the default | ||
| 107 | +shell. | ||
| 108 | + | ||
| 109 | +Still impure, for one of the two reasons the APK is: pub.dev resolution and | ||
| 110 | +Flutter's engine artifacts are network. What it does *not* need is the | ||
| 111 | +writable-`ANDROID_HOME` dance, since nothing here writes into the store — so | ||
| 112 | +there is no `flutter/.home` on this path. | ||
| 113 | + | ||
| 114 | +nixGL off NixOS, for the reason `just run` needs it and `just tui` does not: | ||
| 115 | +Flutter paints through GL and the driver that can do that is the host's. | ||
| 116 | + | ||
| 117 | +`linux/` is the Flutter template's GTK runner, renamed — `frq` rather than | ||
| 118 | +`cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so | ||
| 119 | +the binary, the window title and the GTK application id agree with the APK's | ||
| 120 | +`applicationId`. | ||
| 121 | + | ||
| 122 | +Two things the desktop target changed in the Dart, both of them cases where | ||
| 123 | +"the phone" had been assumed rather than asked: | ||
| 124 | + | ||
| 125 | +* `frq.io.dart/write-private-file!` was a plain write, on the grounds that | ||
| 126 | + Android storage is already private to the app. On a Linux desktop it is not: | ||
| 127 | + the file lands under the XDG data directory with the process umask, and it | ||
| 128 | + holds a broker token. The desktop branch now does what `frq.io.jolt` does — | ||
| 129 | + create, chmod, then write — and Dart having no chmod is why that is a | ||
| 130 | + process. | ||
| 131 | +* `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to | ||
| 132 | + raise the app from behind Chrome. Nothing on a desktop claims that scheme, so | ||
| 133 | + it is now nil there — which `core/capture-html` already documented as the | ||
| 134 | + desktop case and already handled. | ||
| 135 | + | ||
| 136 | +### The APK | ||
| 137 | + | ||
| 86 | Impure on purpose. Gradle resolves its own dependencies over the network and | 138 | Impure on purpose. Gradle resolves its own dependencies over the network and |
| 87 | installs build-tools and a platform into `ANDROID_HOME` as it goes, so it can | 139 | installs build-tools and a platform into `ANDROID_HOME` as it goes, so it can |
| 88 | neither run in a sandbox nor write to the store. What nix gives is the | 140 | neither run in a sandbox nor write to the store. What nix gives is the |
modified
flutter/linux/CMakeLists.txt +2 -2 | @@ -4,10 +4,10 @@ project(runner LANGUAGES CXX) | ||
| 4 | 4 | |
| 5 | 5 | # The name of the executable created for the application. Change this to change |
| 6 | 6 | # the on-disk name of your application. |
| 7 | -set(BINARY_NAME "cljd_flutter") | |
| 7 | +set(BINARY_NAME "frq") | |
| 8 | 8 | # The unique GTK application identifier for this application. See: |
| 9 | 9 | # https://wiki.gnome.org/HowDoI/ChooseApplicationID |
| 10 | -set(APPLICATION_ID "com.example.cljd_flutter") | |
| 10 | +set(APPLICATION_ID "uk.nandi.frq") | |
| 11 | 11 | |
| 12 | 12 | # Explicitly opt in to modern CMake behaviors to avoid warnings with recent |
| 13 | 13 | # versions of CMake. |
| @@ -4,10 +4,10 @@ project(runner LANGUAGES CXX) | |||
| 4 | 4 | ||
| 5 | # The name of the executable created for the application. Change this to change | 5 | # The name of the executable created for the application. Change this to change |
| 6 | # the on-disk name of your application. | 6 | # the on-disk name of your application. |
| 7 | -set(BINARY_NAME "cljd_flutter") | 7 | +set(BINARY_NAME "frq") |
| 8 | # The unique GTK application identifier for this application. See: | 8 | # The unique GTK application identifier for this application. See: |
| 9 | # https://wiki.gnome.org/HowDoI/ChooseApplicationID | 9 | # https://wiki.gnome.org/HowDoI/ChooseApplicationID |
| 10 | -set(APPLICATION_ID "com.example.cljd_flutter") | 10 | +set(APPLICATION_ID "uk.nandi.frq") |
| 11 | 11 | ||
| 12 | # Explicitly opt in to modern CMake behaviors to avoid warnings with recent | 12 | # Explicitly opt in to modern CMake behaviors to avoid warnings with recent |
| 13 | # versions of CMake. | 13 | # versions of CMake. |
modified
flutter/linux/runner/my_application.cc +2 -2 | @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { | ||
| 45 | 45 | if (use_header_bar) { |
| 46 | 46 | GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); |
| 47 | 47 | gtk_widget_show(GTK_WIDGET(header_bar)); |
| 48 | - gtk_header_bar_set_title(header_bar, "cljd_flutter"); | |
| 48 | + gtk_header_bar_set_title(header_bar, "frq"); | |
| 49 | 49 | gtk_header_bar_set_show_close_button(header_bar, TRUE); |
| 50 | 50 | gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); |
| 51 | 51 | } else { |
| 52 | - gtk_window_set_title(window, "cljd_flutter"); | |
| 52 | + gtk_window_set_title(window, "frq"); | |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | 55 | gtk_window_set_default_size(window, 1280, 720); |
| @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { | |||
| 45 | if (use_header_bar) { | 45 | if (use_header_bar) { |
| 46 | GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); | 46 | GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); |
| 47 | gtk_widget_show(GTK_WIDGET(header_bar)); | 47 | gtk_widget_show(GTK_WIDGET(header_bar)); |
| 48 | - gtk_header_bar_set_title(header_bar, "cljd_flutter"); | 48 | + gtk_header_bar_set_title(header_bar, "frq"); |
| 49 | gtk_header_bar_set_show_close_button(header_bar, TRUE); | 49 | gtk_header_bar_set_show_close_button(header_bar, TRUE); |
| 50 | gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); | 50 | gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); |
| 51 | } else { | 51 | } else { |
| 52 | - gtk_window_set_title(window, "cljd_flutter"); | 52 | + gtk_window_set_title(window, "frq"); |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | gtk_window_set_default_size(window, 1280, 720); | 55 | gtk_window_set_default_size(window, 1280, 720); |
modified
flutter/src/frq/io/dart.cljd +32 -10 | @@ -1,5 +1,5 @@ | ||
| 1 | 1 | (ns frq.io.dart |
| 2 | - "The phone's answers to `frq.io`, over dart:io. | |
| 2 | + "Flutter's answers to `frq.io`, over dart:io. | |
| 3 | 3 | |
| 4 | 4 | The mirror of `frq.io.jolt`, and the reason the seam exists at all: every |
| 5 | 5 | line here is something the desktop gets from libc through jolt.ffi and the |
| @@ -12,9 +12,11 @@ | ||
| 12 | 12 | Flutter entry point awaits it and calls `install!` with the answer before it |
| 13 | 13 | runs anything else. |
| 14 | 14 | |
| 15 | - UNVERIFIED. Nothing in this tree compiles ClojureDart yet — there is no cljd | |
| 16 | - toolchain in the flake and no Flutter SDK — so treat the interop here as the | |
| 17 | - shape it should take rather than as code known to compile." | |
| 15 | + Two Flutter targets share this file, not one: `just apk` and | |
| 16 | + `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no | |
| 17 | + longer a safe assumption to bury in an implementation here. Where the answer | |
| 18 | + genuinely differs by platform it is asked rather than assumed — see | |
| 19 | + `write-private-file!`, which is the one place the difference has teeth." | |
| 18 | 20 | (:require ["dart:convert" :as conv] |
| 19 | 21 | ["dart:io" :as io] |
| 20 | 22 | ["package:url_launcher/url_launcher.dart" :as launcher] |
| @@ -37,13 +39,33 @@ | ||
| 37 | 39 | (catch Exception _ false))) |
| 38 | 40 | |
| 39 | 41 | (defn- write-private-file! |
| 40 | - "On Android the app's own storage is already private to the app — there is no | |
| 41 | - other uid that can read it — so mode 600 has nobody to exclude and this is an | |
| 42 | - ordinary write. That is the whole reason `frq.io` names the intent: the | |
| 43 | - desktop shells out to `install -m 600` for the same guarantee the platform | |
| 44 | - makes here for free." | |
| 42 | + "600 where that means something, an ordinary write where it does not. | |
| 43 | + | |
| 44 | + On Android the app's own storage is already private to the app — there is no | |
| 45 | + other uid that can read it — so mode 600 has nobody to exclude, and this is | |
| 46 | + the ordinary write it always was. On a Linux desktop, where this same backend | |
| 47 | + now paints the Flutter window, there very much is another uid: the file lands | |
| 48 | + under the XDG data directory with the process umask, which is world-readable | |
| 49 | + on most systems, and what it holds is a broker token. | |
| 50 | + | |
| 51 | + So the desktop branch does what `frq.io.jolt` does and in the same order — | |
| 52 | + create, restrict, *then* write — so the token is never on disk readable even | |
| 53 | + for an instant. Dart has no chmod of its own, which is why this is a process | |
| 54 | + and not a call. | |
| 55 | + | |
| 56 | + This is the seam earning its keep. `frq.io` names the intent rather than the | |
| 57 | + mechanism, so one `write-private-file!` can mean three different things and | |
| 58 | + no caller has to know which." | |
| 45 | 59 | [path s] |
| 46 | - (spit* path s)) | |
| 60 | + (if (.-isAndroid io/Platform) | |
| 61 | + (spit* path s) | |
| 62 | + (try | |
| 63 | + (let [f (io/File. path)] | |
| 64 | + (when-not (.existsSync f) (.createSync f .recursive true)) | |
| 65 | + (io/Process.runSync "chmod" ["600" path]) | |
| 66 | + (.writeAsStringSync f s) | |
| 67 | + true) | |
| 68 | + (catch Exception _ false)))) | |
| 47 | 69 | |
| 48 | 70 | (defn- local-offset-seconds |
| 49 | 71 | "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop. |
| @@ -1,5 +1,5 @@ | |||
| 1 | (ns frq.io.dart | 1 | (ns frq.io.dart |
| 2 | - "The phone's answers to `frq.io`, over dart:io. | 2 | + "Flutter's answers to `frq.io`, over dart:io. |
| 3 | 3 | ||
| 4 | The mirror of `frq.io.jolt`, and the reason the seam exists at all: every | 4 | The mirror of `frq.io.jolt`, and the reason the seam exists at all: every |
| 5 | line here is something the desktop gets from libc through jolt.ffi and the | 5 | line here is something the desktop gets from libc through jolt.ffi and the |
| @@ -12,9 +12,11 @@ | |||
| 12 | Flutter entry point awaits it and calls `install!` with the answer before it | 12 | Flutter entry point awaits it and calls `install!` with the answer before it |
| 13 | runs anything else. | 13 | runs anything else. |
| 14 | 14 | ||
| 15 | - UNVERIFIED. Nothing in this tree compiles ClojureDart yet — there is no cljd | 15 | + Two Flutter targets share this file, not one: `just apk` and |
| 16 | - toolchain in the flake and no Flutter SDK — so treat the interop here as the | 16 | + `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no |
| 17 | - shape it should take rather than as code known to compile." | 17 | + longer a safe assumption to bury in an implementation here. Where the answer |
| 18 | + genuinely differs by platform it is asked rather than assumed — see | ||
| 19 | + `write-private-file!`, which is the one place the difference has teeth." | ||
| 18 | (:require ["dart:convert" :as conv] | 20 | (:require ["dart:convert" :as conv] |
| 19 | ["dart:io" :as io] | 21 | ["dart:io" :as io] |
| 20 | ["package:url_launcher/url_launcher.dart" :as launcher] | 22 | ["package:url_launcher/url_launcher.dart" :as launcher] |
| @@ -37,13 +39,33 @@ | |||
| 37 | (catch Exception _ false))) | 39 | (catch Exception _ false))) |
| 38 | 40 | ||
| 39 | (defn- write-private-file! | 41 | (defn- write-private-file! |
| 40 | - "On Android the app's own storage is already private to the app — there is no | 42 | + "600 where that means something, an ordinary write where it does not. |
| 41 | - other uid that can read it — so mode 600 has nobody to exclude and this is an | 43 | + |
| 42 | - ordinary write. That is the whole reason `frq.io` names the intent: the | 44 | + On Android the app's own storage is already private to the app — there is no |
| 43 | - desktop shells out to `install -m 600` for the same guarantee the platform | 45 | + other uid that can read it — so mode 600 has nobody to exclude, and this is |
| 44 | - makes here for free." | 46 | + the ordinary write it always was. On a Linux desktop, where this same backend |
| 47 | + now paints the Flutter window, there very much is another uid: the file lands | ||
| 48 | + under the XDG data directory with the process umask, which is world-readable | ||
| 49 | + on most systems, and what it holds is a broker token. | ||
| 50 | + | ||
| 51 | + So the desktop branch does what `frq.io.jolt` does and in the same order — | ||
| 52 | + create, restrict, *then* write — so the token is never on disk readable even | ||
| 53 | + for an instant. Dart has no chmod of its own, which is why this is a process | ||
| 54 | + and not a call. | ||
| 55 | + | ||
| 56 | + This is the seam earning its keep. `frq.io` names the intent rather than the | ||
| 57 | + mechanism, so one `write-private-file!` can mean three different things and | ||
| 58 | + no caller has to know which." | ||
| 45 | [path s] | 59 | [path s] |
| 46 | - (spit* path s)) | 60 | + (if (.-isAndroid io/Platform) |
| 61 | + (spit* path s) | ||
| 62 | + (try | ||
| 63 | + (let [f (io/File. path)] | ||
| 64 | + (when-not (.existsSync f) (.createSync f .recursive true)) | ||
| 65 | + (io/Process.runSync "chmod" ["600" path]) | ||
| 66 | + (.writeAsStringSync f s) | ||
| 67 | + true) | ||
| 68 | + (catch Exception _ false)))) | ||
| 47 | 69 | ||
| 48 | (defn- local-offset-seconds | 70 | (defn- local-offset-seconds |
| 49 | "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop. | 71 | "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop. |
modified
flutter/src/frq/oauth/dart.cljd +17 -6 | @@ -21,11 +21,22 @@ | ||
| 21 | 21 | [frq.io :as host] |
| 22 | 22 | [frq.oauth.core :as core])) |
| 23 | 23 | |
| 24 | -(def ^:private return-url | |
| 25 | - "What brings the app back to the front once the browser is done. The activity | |
| 26 | - is `singleTask`, so this raises the app that is already running rather than | |
| 27 | - starting a second copy of it." | |
| 28 | - "frq://auth") | |
| 24 | +(defn- return-url | |
| 25 | + "What brings the app back to the front once the browser is done, or nil where | |
| 26 | + nothing needs to. | |
| 27 | + | |
| 28 | + On Android the browser is in front of the app and something has to raise it: | |
| 29 | + `frq://auth` is a scheme the manifest claims, and the activity is | |
| 30 | + `singleTask`, so it raises the app that is already running rather than | |
| 31 | + starting a second copy of it. | |
| 32 | + | |
| 33 | + On a Linux desktop the window is already beside the browser and the reader | |
| 34 | + switches to it, exactly as it is for `frq.oauth` under libcosmic — and | |
| 35 | + nothing on that machine claims `frq://`, so offering the link would be a | |
| 36 | + button that does nothing. `core/capture-html` takes nil for precisely this | |
| 37 | + and says so; this is the caller it was written for." | |
| 38 | + [] | |
| 39 | + (when (.-isAndroid io/Platform) "frq://auth")) | |
| 29 | 40 | |
| 30 | 41 | (defn ^:async await-callback! |
| 31 | 42 | "Serve the loopback capture until the browser posts the handoff back. |
| @@ -79,7 +90,7 @@ | ||
| 79 | 90 | (do |
| 80 | 91 | (.set (.-headers resp) "content-type" |
| 81 | 92 | "text/html; charset=utf-8") |
| 82 | - (.write resp (core/capture-html return-url)) | |
| 93 | + (.write resp (core/capture-html (return-url))) | |
| 83 | 94 | (.close resp))) |
| 84 | 95 | nil))) |
| 85 | 96 | (await (.-future done)) |
| @@ -21,11 +21,22 @@ | |||
| 21 | [frq.io :as host] | 21 | [frq.io :as host] |
| 22 | [frq.oauth.core :as core])) | 22 | [frq.oauth.core :as core])) |
| 23 | 23 | ||
| 24 | -(def ^:private return-url | 24 | +(defn- return-url |
| 25 | - "What brings the app back to the front once the browser is done. The activity | 25 | + "What brings the app back to the front once the browser is done, or nil where |
| 26 | - is `singleTask`, so this raises the app that is already running rather than | 26 | + nothing needs to. |
| 27 | - starting a second copy of it." | 27 | + |
| 28 | - "frq://auth") | 28 | + On Android the browser is in front of the app and something has to raise it: |
| 29 | + `frq://auth` is a scheme the manifest claims, and the activity is | ||
| 30 | + `singleTask`, so it raises the app that is already running rather than | ||
| 31 | + starting a second copy of it. | ||
| 32 | + | ||
| 33 | + On a Linux desktop the window is already beside the browser and the reader | ||
| 34 | + switches to it, exactly as it is for `frq.oauth` under libcosmic — and | ||
| 35 | + nothing on that machine claims `frq://`, so offering the link would be a | ||
| 36 | + button that does nothing. `core/capture-html` takes nil for precisely this | ||
| 37 | + and says so; this is the caller it was written for." | ||
| 38 | + [] | ||
| 39 | + (when (.-isAndroid io/Platform) "frq://auth")) | ||
| 29 | 40 | ||
| 30 | (defn ^:async await-callback! | 41 | (defn ^:async await-callback! |
| 31 | "Serve the loopback capture until the browser posts the handoff back. | 42 | "Serve the loopback capture until the browser posts the handoff back. |
| @@ -79,7 +90,7 @@ | |||
| 79 | (do | 90 | (do |
| 80 | (.set (.-headers resp) "content-type" | 91 | (.set (.-headers resp) "content-type" |
| 81 | "text/html; charset=utf-8") | 92 | "text/html; charset=utf-8") |
| 82 | - (.write resp (core/capture-html return-url)) | 93 | + (.write resp (core/capture-html (return-url))) |
| 83 | (.close resp))) | 94 | (.close resp))) |
| 84 | nil))) | 95 | nil))) |
| 85 | (await (.-future done)) | 96 | (await (.-future done)) |
modified
justfile +52 -0 | @@ -271,3 +271,55 @@ gen-moq lib="": | ||
| 271 | 271 | { sed -n '1,/^ (:require \[jolt.ffi :as ffi\]))$/p' src/frq/moq/raw.clj; echo; cat "$work/body.clj"; } > "$work/raw.clj" |
| 272 | 272 | mv "$work/raw.clj" src/frq/moq/raw.clj |
| 273 | 273 | echo "wrote src/frq/moq/raw.clj ($(grep -c '^(ffi/defcfn' src/frq/moq/raw.clj) entry points)" |
| 274 | + | |
| 275 | +# The other desktop GUI: the same screens, painted by Flutter instead of | |
| 276 | +# libcosmic. | |
| 277 | +# | |
| 278 | +# `just run` and this one are two frontends over one tree, and the split is | |
| 279 | +# the same one the APK already draws. Everything under `common/` — the | |
| 280 | +# screens, the cells, `frq.io` — is shared; what differs is who paints it and | |
| 281 | +# who answers the host. So this recipe is `just apk` with the Android half | |
| 282 | +# taken out: the same `clojure -M:cljd compile` over the same flutter/src, | |
| 283 | +# then Flutter's Linux target rather than its Android one. CMake and Ninja | |
| 284 | +# instead of Gradle, `flutter/linux/` as the runner, no SDK and no JDK. | |
| 285 | +# | |
| 286 | +# Still impure, for one of the two reasons `apk` is: pub.dev resolution and | |
| 287 | +# Flutter's own engine artifacts are network. What it does NOT need is the | |
| 288 | +# writable-ANDROID_HOME dance — nothing here writes into the store — so there | |
| 289 | +# is no `flutter/.home` on this path. | |
| 290 | +# | |
| 291 | +# nixGL for the reason `run` needs it and `tui` does not: Flutter paints | |
| 292 | +# through GL, and off NixOS the driver is the host's. | |
| 293 | +# | |
| 294 | +# just flutter-desktop build the debug bundle | |
| 295 | +# just flutter-desktop run build it and open the window | |
| 296 | +flutter-desktop action="build": | |
| 297 | + #!/usr/bin/env bash | |
| 298 | + set -euo pipefail | |
| 299 | + cd "{{justfile_directory()}}" | |
| 300 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | |
| 301 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | |
| 302 | + --command just flutter-desktop "$@" | |
| 303 | + fi | |
| 304 | + cd flutter | |
| 305 | + | |
| 306 | + clojure -M:cljd compile | |
| 307 | + flutter build linux --debug | |
| 308 | + | |
| 309 | + # x64/arm64 is Flutter's own name for the host arch, not uname's. | |
| 310 | + case "$(uname -m)" in | |
| 311 | + x86_64) arch=x64 ;; | |
| 312 | + aarch64) arch=arm64 ;; | |
| 313 | + *) echo "unknown arch $(uname -m)" >&2; exit 1 ;; | |
| 314 | + esac | |
| 315 | + bundle="build/linux/$arch/debug/bundle" | |
| 316 | + | |
| 317 | + case "{{action}}" in | |
| 318 | + build) echo "built $PWD/$bundle/frq" ;; | |
| 319 | + run) | |
| 320 | + runner=() | |
| 321 | + [ -e /run/current-system ] || runner=("$NIXGL") | |
| 322 | + exec "${runner[@]}" "$bundle/frq" | |
| 323 | + ;; | |
| 324 | + *) echo "usage: just flutter-desktop [build|run]" >&2; exit 1 ;; | |
| 325 | + esac | |
| @@ -271,3 +271,55 @@ gen-moq lib="": | |||
| 271 | { sed -n '1,/^ (:require \[jolt.ffi :as ffi\]))$/p' src/frq/moq/raw.clj; echo; cat "$work/body.clj"; } > "$work/raw.clj" | 271 | { sed -n '1,/^ (:require \[jolt.ffi :as ffi\]))$/p' src/frq/moq/raw.clj; echo; cat "$work/body.clj"; } > "$work/raw.clj" |
| 272 | mv "$work/raw.clj" src/frq/moq/raw.clj | 272 | mv "$work/raw.clj" src/frq/moq/raw.clj |
| 273 | echo "wrote src/frq/moq/raw.clj ($(grep -c '^(ffi/defcfn' src/frq/moq/raw.clj) entry points)" | 273 | echo "wrote src/frq/moq/raw.clj ($(grep -c '^(ffi/defcfn' src/frq/moq/raw.clj) entry points)" |
| 274 | + | ||
| 275 | +# The other desktop GUI: the same screens, painted by Flutter instead of | ||
| 276 | +# libcosmic. | ||
| 277 | +# | ||
| 278 | +# `just run` and this one are two frontends over one tree, and the split is | ||
| 279 | +# the same one the APK already draws. Everything under `common/` — the | ||
| 280 | +# screens, the cells, `frq.io` — is shared; what differs is who paints it and | ||
| 281 | +# who answers the host. So this recipe is `just apk` with the Android half | ||
| 282 | +# taken out: the same `clojure -M:cljd compile` over the same flutter/src, | ||
| 283 | +# then Flutter's Linux target rather than its Android one. CMake and Ninja | ||
| 284 | +# instead of Gradle, `flutter/linux/` as the runner, no SDK and no JDK. | ||
| 285 | +# | ||
| 286 | +# Still impure, for one of the two reasons `apk` is: pub.dev resolution and | ||
| 287 | +# Flutter's own engine artifacts are network. What it does NOT need is the | ||
| 288 | +# writable-ANDROID_HOME dance — nothing here writes into the store — so there | ||
| 289 | +# is no `flutter/.home` on this path. | ||
| 290 | +# | ||
| 291 | +# nixGL for the reason `run` needs it and `tui` does not: Flutter paints | ||
| 292 | +# through GL, and off NixOS the driver is the host's. | ||
| 293 | +# | ||
| 294 | +# just flutter-desktop build the debug bundle | ||
| 295 | +# just flutter-desktop run build it and open the window | ||
| 296 | +flutter-desktop action="build": | ||
| 297 | + #!/usr/bin/env bash | ||
| 298 | + set -euo pipefail | ||
| 299 | + cd "{{justfile_directory()}}" | ||
| 300 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | ||
| 301 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | ||
| 302 | + --command just flutter-desktop "$@" | ||
| 303 | + fi | ||
| 304 | + cd flutter | ||
| 305 | + | ||
| 306 | + clojure -M:cljd compile | ||
| 307 | + flutter build linux --debug | ||
| 308 | + | ||
| 309 | + # x64/arm64 is Flutter's own name for the host arch, not uname's. | ||
| 310 | + case "$(uname -m)" in | ||
| 311 | + x86_64) arch=x64 ;; | ||
| 312 | + aarch64) arch=arm64 ;; | ||
| 313 | + *) echo "unknown arch $(uname -m)" >&2; exit 1 ;; | ||
| 314 | + esac | ||
| 315 | + bundle="build/linux/$arch/debug/bundle" | ||
| 316 | + | ||
| 317 | + case "{{action}}" in | ||
| 318 | + build) echo "built $PWD/$bundle/frq" ;; | ||
| 319 | + run) | ||
| 320 | + runner=() | ||
| 321 | + [ -e /run/current-system ] || runner=("$NIXGL") | ||
| 322 | + exec "${runner[@]}" "$bundle/frq" | ||
| 323 | + ;; | ||
| 324 | + *) echo "usage: just flutter-desktop [build|run]" >&2; exit 1 ;; | ||
| 325 | + esac | ||