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