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 @@ + + + + + +diet_guard: the desktop app becomes a web build + + + + +

The desktop app stops being a desktop app

+

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.

+ + + +

1. Background: two apps, one food log

+ +

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.

+ +
+
+
+
PC — Python gate
+
Tk lock window · ~/.local/share/diet_guard/food_log.json · pushes as pc
+
↔
+
GitHub kuhyx/syncs
+
dumb file storage via the REST Contents API — one file per device
+
↔
+
Phone — Flutter app
+
JSON files in app documents dir · pushes as phone
+
+
+
Before this change: two real clients, syncing through a repo used as a + dumb key–value store. No device ever talks to another directly.
+
+ +

That 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.

+ +

What the app stored, and where

+ +

Three services each owned a File under +getApplicationDocumentsDirectory():

+ + + + + + +
ServiceFileHolds
LogStorageServicefood_log.jsonevery meal, by date key
FoodBankServicefood_bank.jsonautocomplete index, rebuilt from the log
AppSettingsServiceapp_settings.jsondaily 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.

+ +

2. Intuition: the browser can't touch your disk

+ +

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.

+ +

Seams, not rewrites

+ +

Each platform edge becomes a three-file seam: an interface, an Android +implementation, a web implementation, picked at compile time by a conditional export.

+ +
+
+
+
document_store.dart
+
abstract read(name) / write(name, contents) — no dart:io
+
→
+
+
…_io.dart
files, atomic temp + rename
+
…_web.dart
IndexedDB + wrapper mirror
+
+
+

export 'document_store_io.dart' if (dart.library.js_interop) 'document_store_web.dart';

+
+
The same shape repeats seven times: documents, photo blobs, background + scheduling, notifications, the GitHub token, the GitHub client, and the image widget.
+
+ +

The wrapper: the other half of the desktop app

+ +

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.

+ +
+
+
+
Chrome --app window
+
the Flutter web build · IndexedDB holds the log, settings, photos
+
↔
+
wrapper (Dart, localhost:8732)
+
serves / · mirrors /documents/* and /blobs/* to disk · proxies /github/*
+
→
+
+
~/.local/share/diet-guard-desktop/
disk copy of everything
+
api.github.com
token attached server-side
+
+
+
+
The page never holds the token and never touches the filesystem; the wrapper + never draws anything. Together they are the desktop app.
+
+ +

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.

+ +

Why the disk mirror is not redundant with sync

+ +

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.

+ +
+

Micro-world: where does a read come from?

+

Flip the switches and press Read food_log.json. + This traces the actual resolution order in the new code.

+
+ + + + +
+ +
+
+ +

The device-id trap

+ +

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.

+ +
+

Micro-world: two devices, one id

+

Each device logs a meal, then syncs. Choose what id the desktop pushes + under and step through the ticks.

+
+ + + + +
+
+
+ +

What the desktop genuinely gives up

+ + + + + + + + + + + +
CapabilityOn 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.
NotificationsBrowser Notifications API, same window-open limitation.
Camera captureGone. The browser picker is a file input; picking a file still works.
Battery-optimization exemptionHidden — an Android-only concept, and permission_handler has no web + implementation to call.
+ +

3. The code, by concern

+ +

a. Storage moves behind DocumentStore

+ +

The 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);
+ +

b. Photos: a blob store plus an image seam

+ +

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.

+ +

c. Scheduling and notifications

+ +

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.

+ +

d. GitHub: the wrapper holds the token

+ +

Two independent problems pushed the GitHub work out of the page:

+ + + +

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.

+ +

e. Packaging, and deleting linux/

+ +

Three packaging details, each learned by breaking it in an earlier migration:

+ + + +

f. Closing the verification holes

+ +

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
+ +
+ A gate that quietly passes when it can't do its job is the same as no gate. This one was + checked by breaking a Dart file on purpose and confirming the push was refused. +
+ +

4. Quiz

+

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 @@
diet-guard

Change explanations

-

1 explanation · interactive walkthroughs of notable changes, newest first.

+

2 explanations · interactive walkthroughs of notable changes, newest first.

+ + 2026-07-20 + diet_guard: the desktop app becomes a web build + 2026-07-13 Calendar, streaks, and a freely-editable synced budget