diff --git a/docs/explanations/2026-07-20-explanation-desktop-web-build.html b/docs/explanations/2026-07-20-explanation-desktop-web-build.html new file mode 100644 index 0000000..aa41f1d --- /dev/null +++ b/docs/explanations/2026-07-20-explanation-desktop-web-build.html @@ -0,0 +1,619 @@ + + +
+ + +How diet_guard's Linux companion moved off Flutter's GTK embedder onto a web
+build in a Chrome window — and what had to be rebuilt to survive the move.
+Commits 802ee0f..50f311b, 20 Jul 2026.
diet_guard is a log-to-unlock gate. Every ~30 minutes
+diet-guard-gate.timer runs a Python service that asks: has a meal slot
+(08:00, 12:00, 16:00, 20:00) come and gone without a logged meal? If so it throws a
+fullscreen Tk window over the desktop until you say what you ate.
Alongside that Python gate lives app/ — a Flutter companion app for logging
+on the phone. Both are clients of the same food log, and they meet through GitHub:
+diet-guard-sync.timer pushes each device's whole log as one JSON file into a
+private repo, under diet-guard-sync/devices/<id>/food_log.json. Merging is a
+union by entry id with tombstones winning, using the shared
+crdt_sync library. The Python side pushes as pc; the phone
+pushed as phone.
~/.local/share/diet_guard/food_log.json · pushes as pckuhyx/syncsphoneThat Flutter app also had a Linux target — app/linux/, the standard GTK
+embedder Flutter generates. It worked, and it was slow: Flutter's Linux embedder
+manages roughly 20fps at 3840×2160. That number comes from a stock
+flutter create counter app, so it is the toolkit, not any app's code. The
+same Dart rendered by Chrome sustains ~144fps on the same machine.
So the desktop target moves to Flutter's web build, served locally and
+shown in a Chrome --app window. The UI and domain code carry over untouched.
+Everything else in this change exists because of one fact: a web page cannot do
+what a native app could.
Three services each owned a File under
+getApplicationDocumentsDirectory():
| Service | File | Holds |
|---|---|---|
LogStorageService | food_log.json | every meal, by date key |
FoodBankService | food_bank.json | autocomplete index, rebuilt from the log |
AppSettingsService | app_settings.json | daily kcal budget, reward fields |
Photos went to <documents>/images/<uuid>.jpg, and the absolute path was
+stored on the entry as imagePath. Four plugins had no web implementation at all:
+workmanager, flutter_local_notifications,
+image_picker's camera path, and permission_handler.
The whole change follows from a single constraint and one nasty property of how +Dart compiles for the web.
+ +dart:io does not fail a web compile. It resolves to a stub
+ that throws at runtime. So the first web build succeeds, the browser opens…
+ and renders a blank white window with one unhelpful line in the console. There is no
+ compiler error pointing at the problem. Every platform-specific edge has to be found
+ by reading, not by building.
+Worse, Platform.isAndroid is itself dart:io. The old
+main() asked “am I on Android?” before registering background work —
+on web that question throws before the first frame ever renders. Hence the rule the whole
+codebase now follows: test kIsWeb first, always.
Each platform edge becomes a three-file seam: an interface, an Android +implementation, a web implementation, picked at compile time by a conditional export.
+ +read(name) / write(name, contents) — no dart:ioexport 'document_store_io.dart' if (dart.library.js_interop) 'document_store_web.dart';
A browser cannot write a file, cannot reach the OS keyring, and cannot call an endpoint +that refuses CORS. So the desktop app grew a second half: a small local Dart process that +serves the built web assets and does the things the page can't.
+ +--app window/ · mirrors /documents/* and /blobs/* to disk · proxies /github/*Two constants in that picture are load-bearing and look like trivia: +the port (8732) and the Chrome profile directory. IndexedDB +is keyed by origin and lives inside that profile. Change either one and the app opens +looking like a brand-new device with no history — no error, no warning, just an empty log.
+ +It's tempting to say: the log is on GitHub, so who cares if a browser profile is wiped?
+Two reasons. First, GitHub only has what was already pushed — a meal logged on the
+desktop and never synced exists nowhere else. Second, photos never sync at all:
+imagePath is stripped before push and ignored on pull, by design. For photos,
+the wrapper's disk copy is the only copy there is.
The phone's sync code pushed under a hard-coded phoneDeviceId = 'phone'.
+The desktop runs the same Dart. Ship it unchanged and you have two different
+machines writing the same file in the sync repo, each overwriting the other's copy on every
+tick — a data-loss bug that only shows up once both devices are actually used.
| Capability | On the desktop now |
|---|---|
Background nagging (workmanager) |
+ Only while the window is open — an in-page check every 5 minutes. A closed tab + runs nothing. On the PC the real backstop is the gate, which locks the screen. |
| Notifications | +Browser Notifications API, same window-open limitation. |
| Camera capture | +Gone. The browser picker is a file input; picking a file still works. |
| Battery-optimization exemption | +Hidden — an Android-only concept, and permission_handler has no web
+ implementation to call. |
DocumentStoreThe three services stopped owning a File and started owning a
+DocumentStore. Note the document names: they are the exact old filenames,
+so a phone that already has data keeps reading it after the upgrade.
class LogStorageService {
+ LogStorageService._(this._store);
+ static const documentName = 'food_log.json'; // unchanged on purpose
+ final DocumentStore _store;
+
+ static Future<LogStorageService> init() async {
+ if (_instance != null) return _instance!;
+ final svc = LogStorageService._(await openDocumentStore()); // io: files · web: IndexedDB
+ _instance = svc;
+ return svc;
+ }
+
+Atomicity moved with it. The file backend keeps the temp-write-then-rename trick, and +gained a per-process counter after two overlapping writes in one process collided on the +same temp name:
+ +final tmp = File('${file.path}.$pid.${_writeCounter++}.tmp');
+await tmp.writeAsString(contents);
+await tmp.rename(file.path);
+
+imagePath is now an opaque key: still an absolute path on Android, an IndexedDB
+key in the browser. Nothing may parse it. The picker reads bytes rather than copying a file,
+because the browser hands back a blob URL with no filesystem behind it:
final picked = await ImagePicker().pickImage(source: source);
+if (picked == null) return null;
+final store = await _resolveStore();
+return store.put(p.extension(picked.path), await picked.readAsBytes());
+
+Image.file cannot resolve an IndexedDB key, so the three call sites now use an
+AttachedImage widget: Image.file on Android, a
+FutureBuilder + Image.memory on web.
checkAndNotify — the due-slot logic — moved into its own library so it carries
+no scheduler plugin. Around it, a seam:
// background_tasks_io.dart // background_tasks_web.dart
+initBackgroundTasks() => WorkManager initBackgroundTasks() => Timer.periodic(5 min)
+enqueueSyncBackstop() => one-off task enqueueSyncBackstop() => no-op
+
+And in main(), a subtle fix found only by running it: the notification permission
+request must not be awaited before the first frame. The browser's
+Notification.requestPermission() doesn't resolve until the user answers the prompt —
+awaiting it left the app as a blank white window behind the permission bubble.
Two independent problems pushed the GitHub work out of the page:
+ +flutter_secure_storage on web is WebCrypto-wrapped localStorage
+ with the wrap key sitting beside the ciphertext — obfuscation, not encryption at rest.So the wrapper owns the token file and proxies the API; the page gets a client whose +requests are rewritten, carrying no credential of its own:
+ +GitHubClient createGitHubClient(SyncSettings settings, {http.Client? httpClient}) =>
+ GitHubClient(
+ owner: settings.owner, repo: settings.repo,
+ token: '', // the wrapper attaches the real one
+ httpClient: WrapperProxyClient(inner: httpClient),
+ );
+
+On the device-flow poll the wrapper deliberately answers {"status":"ok"} and keeps
+the token — the browser is never told what it is. It also falls back to reading the PC gate's
+existing ~/.config/diet_guard/sync_token, which is why the desktop app needed no
+setup at all on the machine that already runs the gate.
linux/Three packaging details, each learned by breaking it in an earlier migration:
+ +dart build cli, not dart compile exe — the latter refuses any
+ package whose dependency graph has native build hooks.options=('!strip') — makepkg would otherwise strip the AOT executable's
+ embedded snapshot. The stripped binary still runs; it just becomes the bare Dart VM
+ printing a usage message.depends — a machine policy uninstalls chromium
+ seconds after makepkg installs it, failing dependency resolution. The wrapper discovers
+ a browser at runtime instead.Two follow-ups matter as much as the migration itself. The IndexedDB stores were invisible
+to the test suite — they import the browser factory, which can't be imported on the VM — so the
+classes were split from their open…() functions and are now driven against
+idb_shim's in-memory backend. And the pre-push gate ran only Python; it now runs
+flutter analyze and flutter test, and fails rather than skips
+when the toolchain is missing:
if ! command -v flutter >/dev/null 2>&1; then
+ fail "flutter is not on PATH but app/ exists (cannot verify the app)"
+fi
+
+Score: 0 / 0
+ + + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 680dbd8..22b16ca 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,8 +30,12 @@1 explanation · interactive walkthroughs of notable changes, newest first.
+2 explanations · interactive walkthroughs of notable changes, newest first.