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

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