mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 16:53:57 +01:00
Flutter's Linux embedder manages ~20fps at 3840x2160 -- measured on a stock `flutter create` counter app, so it is the toolkit rather than this code -- where the same Dart rendered by Chrome sustains ~144fps (evidence in ~/todo/docs/desktop-performance-findings.md). The desktop target therefore becomes a Flutter *web* build; Android is untouched. `dart:io` does not fail a web *compile* -- it resolves to a stub that throws at runtime -- so every platform edge moves behind a conditional export rather than being discovered later as a blank white window: - storage: `document_store*.dart`, files on Android and IndexedDB in the browser (mirrored to the desktop wrapper, which is what makes a wiped Chrome profile recoverable). Document names stay the exact pre-web filenames so an installed phone keeps reading its existing log. - photos: `blob_store*.dart` plus `attached_image*.dart`, since `Image.file` cannot resolve an IndexedDB key. The picker itself has web support; what changed is the storage and display model. Camera *capture* is genuinely lost on desktop -- and photos never crossed devices anyway, `imagePath` being stripped before push. - scheduling: `background_tasks*.dart`. A browser has no WorkManager equivalent -- a closed tab runs nothing -- so the desktop runs an in-page 5-minute check while its window is open. That loses nothing on the PC, where `diet-guard-gate.timer` already locks the screen on an unlogged slot, which is strictly stronger than a notification. - notifications: `notification_backend*.dart`, the browser Notifications API in place of flutter_local_notifications. - the GitHub token: `token_vault*.dart`. On web it is *not* held by the browser at all -- flutter_secure_storage's web backend is WebCrypto-wrapped localStorage with the wrap key beside the ciphertext, which is obfuscation, not encryption at rest -- so the desktop wrapper owns it and proxies the API (`github_client_factory*.dart`). `Platform.isAndroid` itself throws on web, so every platform branch now tests `kIsWeb` first; the battery-optimization row (permission_handler, no web implementation) is hidden off Android. The desktop pushes under device id `desktop`, not `phone`: sharing an id would have made the two devices overwrite each other's pushed log on every tick. The Python side enumerates device directories generically, so it needs no change. Tests move onto the same seams -- the file-backed stores keep the atomicity and unreadable-file guarantees where they belong. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
5.2 KiB
Dart
137 lines
5.2 KiB
Dart
/// Locally-stored GitHub sync configuration, ported from `~/todo`'s
|
|
/// `sync/sync_settings.dart`, including the OAuth device-flow fields: the
|
|
/// "Connect GitHub" button is the primary path, with a pasted PAT kept as a
|
|
/// manual fallback.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/services/token_vault.dart';
|
|
import 'package:diet_guard_app/services/token_vault_factory.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// The GitHub token is kept out of `SharedPreferences`, which holds only the
|
|
/// non-secret owner/repo: on Android it lives in the OS keystore, and in the
|
|
/// desktop web build it lives in the wrapper process rather than the browser
|
|
/// at all (see [TokenVault]). Older builds stored the token in plaintext
|
|
/// prefs; [load]/[save] migrate it transparently and never drop the plaintext
|
|
/// copy until a vault write is confirmed (so we degrade to -- never below --
|
|
/// the old behaviour when no secure store is available).
|
|
class SyncSettings {
|
|
/// Creates a [SyncSettings] from its owner/repo/token.
|
|
const SyncSettings({
|
|
required this.owner,
|
|
required this.repo,
|
|
required this.token,
|
|
this.clientId = '',
|
|
});
|
|
|
|
/// The repo owner/org (e.g. `"kuhyx"`).
|
|
final String owner;
|
|
|
|
/// The repo name (e.g. `"syncs"`).
|
|
final String repo;
|
|
|
|
/// A GitHub PAT with contents read/write on [owner]/[repo].
|
|
final String token;
|
|
|
|
/// GitHub OAuth App client id used by the device-flow "Connect" button.
|
|
/// Not a secret (device flow needs no client secret), so it is safe to
|
|
/// ship as a compile-time default and commit to source — see
|
|
/// [defaultClientId].
|
|
final String clientId;
|
|
|
|
/// The app's own GitHub OAuth App (device-flow enabled) client id, baked
|
|
/// in so "Connect GitHub" works with zero setup. Registered 2026-06-23 at
|
|
/// github.com/settings/developers, device flow enabled — distinct from
|
|
/// the sibling notes app's OAuth App, which belongs to a different
|
|
/// product.
|
|
static const defaultClientId = 'Ov23li8wIQBai3qtbsqa';
|
|
|
|
/// True when enough is set to attempt a sync.
|
|
bool get isConfigured =>
|
|
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
|
|
|
|
/// True when device-flow "Connect GitHub" can be offered.
|
|
bool get canUseDeviceFlow => clientId.isNotEmpty;
|
|
|
|
static const _kOwner = 'sync.owner';
|
|
static const _kRepo = 'sync.repo';
|
|
static const _kClientId = 'sync.clientId';
|
|
// Legacy plaintext location for the token; read-only now and removed once
|
|
// the token has been migrated into secure storage.
|
|
static const _kToken = 'sync.token';
|
|
|
|
static final TokenVault _vault = openTokenVault();
|
|
|
|
/// True when this platform can show the stored token back to the user.
|
|
static bool get exposesTokenValue => _vault.exposesTokenValue;
|
|
|
|
/// Loads settings, defaulting the owner/repo to `kuhyx/syncs`
|
|
/// (matching the PC's `SYNC_REPO_OWNER`/`SYNC_REPO_NAME` constants) and the
|
|
/// client id to the baked-in [defaultClientId], so a fresh install needs
|
|
/// only "Connect GitHub" (once an OAuth App is registered) or a pasted PAT.
|
|
///
|
|
/// A device that already persisted the old standalone `diet-guard-sync`
|
|
/// repo name (before it was folded into the shared `syncs` monorepo) is
|
|
/// migrated to `syncs` here and the corrected value is written straight
|
|
/// back to prefs, so this only ever runs once per device.
|
|
static Future<SyncSettings> load() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
var repo = prefs.getString(_kRepo) ?? 'syncs';
|
|
if (repo == 'diet-guard-sync') {
|
|
repo = 'syncs';
|
|
await prefs.setString(_kRepo, repo);
|
|
}
|
|
return SyncSettings(
|
|
owner: prefs.getString(_kOwner) ?? 'kuhyx',
|
|
repo: repo,
|
|
token: await _loadToken(prefs),
|
|
clientId: prefs.getString(_kClientId) ?? defaultClientId,
|
|
);
|
|
}
|
|
|
|
/// Reads the token, preferring the vault and falling back to the legacy
|
|
/// plaintext value. A legacy value is migrated into the vault on read, but
|
|
/// only dropped from prefs once that write succeeds.
|
|
static Future<String> _loadToken(SharedPreferences prefs) async {
|
|
final stored = await _vault.read();
|
|
if (stored.isNotEmpty) return stored;
|
|
|
|
final legacy = prefs.getString(_kToken) ?? '';
|
|
if (legacy.isNotEmpty && await _vault.write(legacy)) {
|
|
await prefs.remove(_kToken);
|
|
}
|
|
return legacy;
|
|
}
|
|
|
|
/// Persists [owner]/[repo] to prefs and [token] to the vault (or plaintext
|
|
/// prefs as a fallback -- see [_loadToken]).
|
|
Future<void> save() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kOwner, owner);
|
|
await prefs.setString(_kRepo, repo);
|
|
await prefs.setString(_kClientId, clientId);
|
|
// Confirm-before-delete: only remove the plaintext copy once the vault
|
|
// write succeeds; otherwise keep persisting it to prefs as before.
|
|
if (await _vault.write(token)) {
|
|
await prefs.remove(_kToken);
|
|
} else {
|
|
await prefs.setString(_kToken, token);
|
|
}
|
|
}
|
|
|
|
/// Returns a copy of this with only the given fields replaced.
|
|
SyncSettings copyWith({
|
|
String? owner,
|
|
String? repo,
|
|
String? token,
|
|
String? clientId,
|
|
}) {
|
|
return SyncSettings(
|
|
owner: owner ?? this.owner,
|
|
repo: repo ?? this.repo,
|
|
token: token ?? this.token,
|
|
clientId: clientId ?? this.clientId,
|
|
);
|
|
}
|
|
}
|