mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 22:13:54 +01:00
The phone is lost, so no workout can be recorded and enforcement has been disarmed since 2026-08-21. This gives the PC a first-class way to do the workout, reusing the existing app rather than reimplementing the progression rules a second time -- two implementations would guarantee the divergence the "same weights everywhere" requirement forbids. Storage: sqflite has no Linux plugin, so main() installs sqflite_common_ffi's factory, which runs the same schema and migrations against the same SQL. It was already a dev dependency driving the test suite; this only promotes it. Auth: the account password in ~/.config/crdt-sync no longer authenticates (verified: HTTP 400 INVALID_LOGIN_CREDENTIALS). The PC fleet has been running on the cached refresh token all along, so the desktop credential store reads that same 0600 file -- the Dart and Python credential JSON keys are already identical -- and passes null instead of the stale password. This also avoids google_sign_in, which ships no Linux implementation. Lock mode: --lock-mode makes the window the lock surface. There is no Dart API for this, so the runner sets override-redirect and takes an exclusive gdk_seat_grab. Two details cost a debugging cycle each and are commented in place: override-redirect must be set BEFORE the first map, or the window stays mapped and never repaints; and gtk_window_fullscreen() only ASKS the window manager, which cannot see an override-redirect window -- so the geometry is set from the monitor directly, otherwise a strip of live desktop is left uncovered at the edge of a larger screen. The grab is acquired by retry rather than by asking the supervisor to release first: the Python locker keeps its grab while this window maps on top, so the screen is covered continuously instead of exposing the desktop for the whole of Flutter's cold start. Verified against the live data: pulls all 7 exercises from Firebase (Bench 17.5x12, RDL 27.5x8, Row 25x6, Situp 10x31 -- the real weights, not factory defaults), leaves the remote progression byte-identical, and mints its own device id rather than reusing the phone's. A Tk client is refused the grab while the lock holds it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
235 lines
9.7 KiB
Dart
235 lines
9.7 KiB
Dart
/// Wiring for the Firebase backend, during and after the GitHub cutover.
|
|
///
|
|
/// Split by what is safe to publish, because this repo is public:
|
|
///
|
|
/// * [kProject] holds the Web API key and database URL. Both are public
|
|
/// identifiers that already ship inside the APK; the security rules, not
|
|
/// their secrecy, are what protect the data.
|
|
/// * The account email and password are entered once per device and kept in
|
|
/// the OS keystore, next to the GitHub token this app already stores there.
|
|
///
|
|
/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python
|
|
/// half. On Android there is no such file.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:developer';
|
|
import 'dart:io';
|
|
|
|
import 'package:crdt_sync/crdt_sync.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:workout_app/services/google_sign_in_backend.dart';
|
|
|
|
part 'firebase_backend_desktop.dart';
|
|
part 'firebase_backend_google.dart';
|
|
|
|
/// The shared `kuhy-syncs` project.
|
|
///
|
|
/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form
|
|
/// answers 404 with a `correctUrl` body rather than an obvious error, which
|
|
/// reads like an auth failure and wastes a debugging session.
|
|
const kProject = FirebaseProject(
|
|
apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E',
|
|
databaseUrl:
|
|
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
|
|
);
|
|
|
|
/// Same options the GitHub token uses: off Android's deprecated
|
|
/// `encryptedSharedPreferences` path, and libsecret on Linux.
|
|
const _secure = FlutterSecureStorage();
|
|
|
|
// Everything below reaches the OS keystore through a platform channel, which
|
|
// `flutter test` has no binding for -- the same reason `main.dart` and
|
|
// `openRepository()` are excluded. The logic these wrap (parsing, the
|
|
// public/private split, sign-in) lives in `crdt_sync` and is covered there at
|
|
// 100%; what is left here is the two-line adapter.
|
|
// coverage:ignore-start
|
|
|
|
/// The keystore-backed home for the Firebase refresh token.
|
|
///
|
|
/// On Linux the "keystore" is the shared 0600 file above rather than
|
|
/// libsecret, so the desktop build signs in with the same durable refresh
|
|
/// token the Python side already holds — the account password on this fleet
|
|
/// is stale, and only the refresh token still authenticates.
|
|
SecureCredentialStore credentialStore() {
|
|
if (Platform.isLinux) {
|
|
return SecureCredentialStore(
|
|
read: (key) async {
|
|
final file = _desktopCredentialFile();
|
|
if (!file.existsSync()) return null;
|
|
return file.readAsString();
|
|
},
|
|
write: (key, value) async {
|
|
final file = _desktopCredentialFile();
|
|
await file.parent.create(recursive: true);
|
|
await file.writeAsString(value);
|
|
// The refresh token is the secret worth protecting; keep it 0600 the
|
|
// way the Python half writes it.
|
|
await Process.run('chmod', ['600', file.path]);
|
|
},
|
|
delete: (key) async {
|
|
final file = _desktopCredentialFile();
|
|
if (file.existsSync()) await file.delete();
|
|
},
|
|
);
|
|
}
|
|
return SecureCredentialStore(
|
|
read: (key) => _secure.read(key: key),
|
|
write: (key, value) => _secure.write(key: key, value: value),
|
|
delete: (key) => _secure.delete(key: key),
|
|
);
|
|
}
|
|
|
|
/// Reads the per-device account, or null when sync has not been set up.
|
|
Future<FirebaseAccount?> loadAccount() async {
|
|
// Linux desktop reads the shared PC credential instead of the keystore,
|
|
// so a fresh desktop install is connected the moment it starts.
|
|
if (Platform.isLinux) return _accountFromDesktopConfig();
|
|
try {
|
|
return FirebaseAccount.tryParse(
|
|
await _secure.read(key: kFirebaseAccountKey),
|
|
);
|
|
} on Object catch (error) {
|
|
// No secret service available: behave as "not configured" rather than
|
|
// crashing the caller. Deliberately catches Object, not Exception: with no
|
|
// platform-channel binding (the `flutter test` host) this throws a
|
|
// FlutterError -- an Error, not an Exception -- which slipped straight
|
|
// through the old guard and failed any test whose code path reached the
|
|
// keystore.
|
|
//
|
|
// "Not configured" is the honest answer in both cases, and it is never
|
|
// silent: callers log their own "no Firebase account" reason, and this
|
|
// says which failure produced it.
|
|
// `log`, not `debugPrint`: debugPrint is stripped in release builds, and a
|
|
// keystore that has stopped answering means every sync path on this device
|
|
// silently reports "not configured" — the exact invisible failure the
|
|
// repo's CLAUDE.md forbids.
|
|
log(
|
|
'WorkoutApp: cannot read the Firebase account from the keystore '
|
|
'($error) — treating this device as NOT connected to sync, so '
|
|
'progression and workouts will not reach any other device.',
|
|
level: 1000,
|
|
error: error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Reads the account from the keystore only, with no wrapper fallback.
|
|
///
|
|
/// [loadAccount] falls back to the desktop wrapper's `/sync-account` route
|
|
/// when the keystore is empty, which on Android resolves to `file:///` and
|
|
/// throws `No host specified in URI`. Callers reading back an account they
|
|
/// just wrote -- where a fallback would be wrong anyway -- use this instead.
|
|
/// Verified on the phone: without it, sign-in succeeded and then the settings
|
|
/// screen hung on "Signing in..." forever.
|
|
Future<FirebaseAccount?> storedAccount() async =>
|
|
FirebaseAccount.tryParse(await _secure.read(key: kFirebaseAccountKey));
|
|
|
|
/// Stores the per-device account. Keystore only — never prefs, never source.
|
|
Future<void> saveAccount(FirebaseAccount account) =>
|
|
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
|
|
|
|
/// Forgets the account and any cached session.
|
|
Future<void> clearAccount() async {
|
|
await _secure.delete(key: kFirebaseAccountKey);
|
|
await credentialStore().clear();
|
|
}
|
|
|
|
/// Returns a signed-in Firebase client, or null when not configured.
|
|
///
|
|
/// Signs in with the stored password only when there is no cached refresh
|
|
/// token, so the usual path costs no authentication round trip.
|
|
Future<FirebaseRestClient?> openFirebase() async {
|
|
final account = await loadAccount();
|
|
if (account == null) {
|
|
// A stored refresh token IS a signed-in device, even with no account
|
|
// marker beside it. Treating the marker as the source of truth is what
|
|
// made a phone with a live session sync over GitHub and 401 forever --
|
|
// the credential was in the keystore the whole time, unused.
|
|
return _clientFromStoredSession();
|
|
}
|
|
return firebaseClientFor(
|
|
config: kProject.configFor(account.email),
|
|
store: credentialStore(),
|
|
// A Google-provisioned account stores an empty password.
|
|
// Passing '' would make firebaseClientFor treat it as a usable
|
|
// credential and sign in with it, which fails; null correctly
|
|
// means "no password on this device".
|
|
//
|
|
// On Linux the shared refresh token in ~/.config/screen_locker is the
|
|
// real credential and short-circuits sign-in before any password is
|
|
// consulted. The password in ~/.config/crdt-sync no longer authenticates
|
|
// on this fleet, so offering it would only turn a working token into an
|
|
// INVALID_LOGIN_CREDENTIALS failure.
|
|
password: (Platform.isLinux || account.password.isEmpty)
|
|
? null
|
|
: account.password,
|
|
// Deliberately NOT offering Google here. This path runs from background
|
|
// timers and, in some apps, before runApp -- offering it would let a
|
|
// non-interactive tick raise the OS account picker with no user action
|
|
// behind it. Interactive sign-in uses openFirebaseWithGoogle instead.
|
|
expectedUid: kSyncUid,
|
|
);
|
|
}
|
|
|
|
/// a working device "not connected", which is exactly how a phone that was in
|
|
/// fact syncing looked broken.
|
|
Future<bool> isFirebaseConfigured() async {
|
|
try {
|
|
final auth = FirebaseTokenProvider(
|
|
apiKey: kProject.apiKey,
|
|
store: credentialStore(),
|
|
);
|
|
if (await auth.hasSession()) return true;
|
|
// storedAccount, not loadAccount: on Android the latter falls back to the
|
|
// desktop wrapper's /sync-account route, which resolves to file:/// and
|
|
// throws "No host specified in URI" -- observed on the phone, where it
|
|
// turned a successful sign-in into "Google sign-in failed".
|
|
if (await storedAccount() == null) return false;
|
|
// A marker with no session behind it cannot sign a single request. Drop
|
|
// just the marker so the settings screen offers a sign-in instead of a
|
|
// dead banner -- not clearAccount(), which also sets the opt-out flag and
|
|
// would stop the desktop wrapper re-provisioning after the next sign-in.
|
|
await _secure.delete(key: kFirebaseAccountKey);
|
|
return false;
|
|
} on Object catch (error, stackTrace) {
|
|
log(
|
|
'session probe failed; reporting this device as not configured',
|
|
level: 1000,
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Returns a client built from the keystore's refresh token alone, or null.
|
|
///
|
|
/// The recovery path for a device that has a live session but no account
|
|
/// marker -- the state a Google sign-in used to leave behind. Costs one
|
|
/// keystore read and no network round trip when there is no session, so it is
|
|
/// safe on the background-tick path [openFirebase] also serves.
|
|
Future<FirebaseRestClient?> _clientFromStoredSession() async {
|
|
try {
|
|
final auth = FirebaseTokenProvider(
|
|
apiKey: kProject.apiKey,
|
|
store: credentialStore(),
|
|
);
|
|
if (!await auth.hasSession()) return null;
|
|
return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth);
|
|
} on Object catch (error, stackTrace) {
|
|
log(
|
|
'stored-session recovery failed; falling back to the GitHub mirror',
|
|
level: 1000,
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// coverage:ignore-end
|