diff --git a/app/lib/data/local_desk_repo.dart b/app/lib/data/local_desk_repo.dart index 75b27ee..08d804a 100644 --- a/app/lib/data/local_desk_repo.dart +++ b/app/lib/data/local_desk_repo.dart @@ -36,6 +36,7 @@ import 'secure_key_store.dart'; import 'sift.dart'; import 'pull_walk.dart'; import 'sync_jobs.dart'; +import 'sync_plan.dart'; import 'sync_socket.dart'; import 'supabase_auth.dart'; @@ -606,6 +607,34 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { /// WorkerRepo.emailUnverified. final ValueNotifier emailUnverified = ValueNotifier(false); + /// The free plan's sync pause (403 sync_paused, data/sync_plan.dart): set + /// while the account syncs a different device. Capture and the local vault + /// keep working; queued changes wait and go up once this PC syncs again. The + /// shell watches [syncPaused] for its banner. Lockstep with WorkerRepo. + final SyncPauseGate _pause = SyncPauseGate(); + ValueNotifier get syncPaused => _pause.paused; + + /// Make this PC the one that keeps syncing (POST /account/sync-device). On + /// success the pause lifts and a sync runs straight away. Lockstep with + /// WorkerRepo.syncThisDevice. + Future syncThisDevice() async { + final id = _deviceId; + if (_syncUrl == null || id == null || id.isEmpty) { + return const SyncPickResult(SyncPickOutcome.failed); + } + final r = await postSyncDevice( + url: Uri.parse(_u('/account/sync-device')), + headers: _h, + deviceId: id, + ); + if (r.outcome == SyncPickOutcome.ok) { + _pause.clear(); + notifyListeners(); + unawaited(syncNow()); + } + return r; + } + /// True when [body] is the worker's `{"error":"email_unverified",...}` payload. /// Static + pure so the flag logic is unit-testable. Lockstep with WorkerRepo. /// Set true when the account's session has been revoked: the worker answered @@ -2692,6 +2721,9 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { SyncState get sync { if (_mk == null) return const SyncState(SyncKind.synced); // local-only final pending = _db?.pendingCount() ?? 0; + // Paused by the free plan: nothing is broken, and nothing will move until + // the person picks this PC or upgrades, so this outranks "pending". + if (_pause.holding) return SyncState(SyncKind.paused, pending: pending); if (pending > 0) return SyncState(SyncKind.pending, pending: pending); final rejected = _db?.rejectionCount() ?? 0; if (rejected > 0) return SyncState(SyncKind.quotaFull, pending: rejected); @@ -5375,6 +5407,7 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { _uploadProgress.clear(); _mk = null; _remoteAccount = null; + _pause.clear(); _online = false; _clearFailureStreak(); _supabaseMode = false; @@ -5418,6 +5451,7 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { _startTimer(); // re-arm the poll at the new cadence if (up) unawaited(_pullRemote()); // catch up on anything missed }, + holding: () => _pause.holding, ); _syncSocket!.start(); } @@ -5622,6 +5656,9 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { _flushAgain = true; return; } + // Paused by the free plan: the queue waits. The pull that follows in the + // same cycle asks the server again and lifts the pause when it may sync. + if (_pause.holding) return; _flushing = true; var changed = false; var skipped = 0; @@ -5636,6 +5673,7 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { stalled = true; break; } + if (_pause.holding) break; // this op was refused by the pause if (result.kind == _OutboundKind.skip) { // The server answered but wouldn't take THIS op right now — leave it // queued and keep draining, or one bad item head-of-line blocks the @@ -5756,6 +5794,12 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { emailUnverified.value = false; return _sent; } + if (_pause.absorb(resp.statusCode, resp.body)) { + // Free plan sync pause. Leave this op queued; the drain stops. + _online = true; + notifyListeners(); + return _skip; + } if (resp.statusCode == 403 && isEmailUnverifiedBody(resp.body)) { // Email not confirmed yet (VERIFY_GATE). Reachable, so keep draining; // leave this op queued to flush once they confirm. The banner explains. @@ -5796,6 +5840,11 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { _online = true; return _sent; } + if (_pause.absorb(resp.statusCode, resp.body)) { + _online = true; + notifyListeners(); + return _skip; // stays queued until this PC syncs again + } if (_permanentSyncStatus(resp.statusCode)) { return _rejected(resp.statusCode); } @@ -5820,6 +5869,9 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { Future _pullRemote() async { final db = _db; if (_pulling || _mk == null || db == null) return; + // Paused by the free plan and asked a moment ago: the answer will not have + // changed. A tap on "Sync now" asks anyway. + if (_pause.skipPull(force: _manualSync)) return; // First-ever pull of this bind: syncBusy is watching, so the chip has to // repaint on both edges. Later cycles skip the churn. final firstSync = _lastSyncAt == 0; @@ -5868,9 +5920,22 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { _refreshToken = null; sessionRevoked.value = true; } + // The free plan syncs another device. Reachable, not offline. + if (_pause.absorb(resp.statusCode, resp.body)) { + _online = true; + notifyListeners(); + return; + } _online = false; return; } + if (_pause.holding) { + // Picked elsewhere, or the account paid: this PC syncs again, and + // the changes queued while it was paused can go up now. + _pause.clear(); + notifyListeners(); + _kickSync(); + } final body = jsonDecode(resp.body) as Map; final items = (body['items'] as List).cast>(); for (final env in items) { @@ -6942,6 +7007,7 @@ class LocalDeskRepo extends ChangeNotifier implements RelicRepo, BillingRepo { historyCap: (j['history_cap'] as num?)?.toInt(), evictedCount: (j['evicted_count'] as num?)?.toInt() ?? 0, devicesCap: (j['devices_cap'] as num?)?.toInt(), + syncUntil: (j['sync_until'] as num?)?.toInt(), ); } } catch (_) {} diff --git a/app/lib/data/repo.dart b/app/lib/data/repo.dart index c445f90..af1d5b1 100644 --- a/app/lib/data/repo.dart +++ b/app/lib/data/repo.dart @@ -36,6 +36,11 @@ class AccountInfo { /// How many devices the plan allows. Null on Max and on a self-hosted server. final int? devicesCap; + + /// Unix seconds the free week of sync on every device ends (the free plan + /// sync rule, data/sync_plan.dart). Null on a paid plan, a self-hosted + /// server, and any server with the rule switched off. + final int? syncUntil; const AccountInfo({ required this.tier, required this.usedBytes, @@ -46,6 +51,7 @@ class AccountInfo { this.historyCap, this.evictedCount = 0, this.devicesCap, + this.syncUntil, }); /// True only when a ring is actually in force. A self-hosted server enrolls diff --git a/app/lib/data/sync_plan.dart b/app/lib/data/sync_plan.dart new file mode 100644 index 0000000..3e0a6d4 --- /dev/null +++ b/app/lib/data/sync_plan.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +/// The free plan's sync rule, as the apps see it (server: worker/src/syncplan.ts). +/// +/// Free is one device. A new account syncs on every device for its first +/// week; after that one device keeps syncing and the others get 403 +/// `sync_paused`. A paused device keeps everything it already has and keeps +/// capturing locally. Its outgoing changes stay queued, so they go up the +/// moment this device syncs again (it is picked, or the account pays). +/// +/// Shared by LocalDeskRepo and WorkerRepo so the two hosts read the 403 and +/// make the pick the same way. +class SyncPause { + /// Unix seconds the free week ended. Null from a server that did not say. + final int? syncUntil; + + /// The device that is still syncing. Null if the server has not settled one. + final String? syncDevice; + + /// The account's devices, newest first, as the server listed them. + final List devices; + + const SyncPause({this.syncUntil, this.syncDevice, this.devices = const []}); + + /// The pause carried by a 403 body, or null when [body] is any other error. + static SyncPause? fromBody(String body) { + try { + final j = jsonDecode(body); + if (j is! Map || j['error'] != 'sync_paused') return null; + return SyncPause( + syncUntil: (j['sync_until'] as num?)?.toInt(), + syncDevice: j['sync_device'] as String?, + devices: [ + for (final d in (j['devices'] as List? ?? const [])) + if (d is Map) SyncPlanDevice.fromJson(d.cast()), + ], + ); + } catch (_) { + return null; + } + } + + /// The device that keeps syncing, as a person would name it. + String? get syncDeviceName { + for (final d in devices) { + if (d.id == syncDevice) return d.name; + } + return null; + } + + /// The banner line. Names the syncing device when the server listed it. + String get message { + final other = syncDeviceName; + return other == null + ? 'Sync is paused here. The free plan syncs one device. ' + 'Everything on this device is still here.' + : 'Sync is paused here. The free plan syncs one device, and ' + 'yours is $other. Everything on this device is still here.'; + } +} + +class SyncPlanDevice { + final String id; + final String? label; + final String? platform; + final bool thisDevice; + + const SyncPlanDevice({ + required this.id, + this.label, + this.platform, + this.thisDevice = false, + }); + + factory SyncPlanDevice.fromJson(Map j) => SyncPlanDevice( + id: j['device_id'] as String? ?? '', + label: j['label'] as String?, + platform: j['platform'] as String?, + thisDevice: j['this_device'] == true, + ); + + /// "Jordan's PC", or "your Windows PC" when the device has no label. + String get name { + final l = label?.trim(); + if (l != null && l.isNotEmpty) return l; + return switch (platform) { + 'windows' => 'your Windows PC', + 'macos' => 'your Mac', + 'linux' => 'your Linux PC', + 'android' => 'your Android phone', + 'ios' => 'your iPhone', + _ => 'another device', + }; + } +} + +/// How often a paused device asks again whether it may sync. The answer only +/// changes when the person picks a device or pays, so a poll every few seconds +/// would be thousands of refused requests a day for nothing. +const kPausedRecheck = Duration(minutes: 5); + +/// One host's view of the pause: the notifier the UI watches, plus the clock +/// that spaces out the re-checks. Each repo owns one. +class SyncPauseGate { + /// The current pause, or null while this device syncs. + final ValueNotifier paused = ValueNotifier(null); + DateTime? _checkedAt; + + /// [kPausedRecheck], shortened by tests that need the re-check to happen. + @visibleForTesting + static Duration recheck = kPausedRecheck; + + /// True while this device may not sync. + bool get holding => paused.value != null; + + /// True when a pull should not go out yet: paused, and asked recently. + /// [force] is a person tapping "Sync now". + bool skipPull({bool force = false, DateTime? now}) { + final at = _checkedAt; + if (force || paused.value == null || at == null) return false; + return (now ?? DateTime.now()).difference(at) < recheck; + } + + /// Reads a refused response. True when it was the pause, which is then + /// published; false for any other error, which the caller handles as before. + bool absorb(int status, String body, {DateTime? now}) { + if (status != 403) return false; + final p = SyncPause.fromBody(body); + if (p == null) return false; + paused.value = p; + _checkedAt = now ?? DateTime.now(); + return true; + } + + /// The server took a sync request from this device: not paused. + void clear() { + _checkedAt = null; + if (paused.value != null) paused.value = null; + } +} + +/// What came of asking to sync this device instead. +enum SyncPickOutcome { ok, cooldown, failed } + +class SyncPickResult { + final SyncPickOutcome outcome; + + /// [SyncPickOutcome.cooldown] only: unix seconds a switch is allowed again. + final int? retryAt; + const SyncPickResult(this.outcome, {this.retryAt}); + + /// The line to show the person after the tap. + String message({DateTime? now}) { + switch (outcome) { + case SyncPickOutcome.ok: + return 'This device syncs now.'; + case SyncPickOutcome.failed: + return 'Could not switch right now. Try again in a minute.'; + case SyncPickOutcome.cooldown: + final at = retryAt; + if (at == null) return 'You can switch devices once a week.'; + final days = daysUntil(at, now: now); + return days <= 1 + ? 'You can switch devices once a week. Try again tomorrow.' + : 'You can switch devices once a week. Try again in $days days.'; + } + } +} + +/// POST /account/sync-device: make [deviceId] the device that keeps syncing. +Future postSyncDevice({ + required Uri url, + required Map headers, + required String deviceId, + Duration timeout = const Duration(seconds: 15), +}) async { + try { + final r = await http + .post( + url, + headers: {...headers, 'Content-Type': 'application/json'}, + body: jsonEncode({'device_id': deviceId}), + ) + .timeout(timeout); + if (r.statusCode == 200) return const SyncPickResult(SyncPickOutcome.ok); + if (r.statusCode == 429) { + int? retryAt; + try { + final j = jsonDecode(r.body); + if (j is Map && j['error'] == 'sync_switch_cooldown') { + retryAt = (j['retry_at'] as num?)?.toInt(); + } + } catch (_) {} + // A 429 with no cooldown body is the route's rate limiter: plain failure. + if (retryAt != null) { + return SyncPickResult(SyncPickOutcome.cooldown, retryAt: retryAt); + } + } + return const SyncPickResult(SyncPickOutcome.failed); + } catch (_) { + return const SyncPickResult(SyncPickOutcome.failed); + } +} + +/// Whole days from [now] until unix second [at], rounded up. 0 once it passed. +int daysUntil(int at, {DateTime? now}) { + final n = (now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000; + if (at <= n) return 0; + return ((at - n) + 86399) ~/ 86400; +} + +/// The countdown line for the free week, or null outside it. [syncUntil] is +/// GET /account's `sync_until`, which the server only sends while the rule is +/// switched on, so this stays silent until launch day. +String? syncWeekLine(int? syncUntil, {DateTime? now}) { + if (syncUntil == null) return null; + final days = daysUntil(syncUntil, now: now); + if (days == 0) return null; + return days == 1 + ? 'Free sync on all your devices ends in 1 day.' + : 'Free sync on all your devices ends in $days days.'; +} diff --git a/app/lib/data/sync_socket.dart b/app/lib/data/sync_socket.dart index bdf8b67..78916a7 100644 --- a/app/lib/data/sync_socket.dart +++ b/app/lib/data/sync_socket.dart @@ -33,6 +33,7 @@ class SyncSocket { required this.headers, required this.onWake, this.onConnectedChanged, + this.holding, }); /// Current sync base URL (e.g. `https://relic-worker...`). Null/empty → not @@ -50,9 +51,15 @@ class SyncSocket { /// while the socket is live and tighten it again when the socket drops. final void Function(bool connected)? onConnectedChanged; + /// Optional: true while the server refuses this device on purpose (the free + /// plan's sync pause). Reconnects then wait [_heldBackoff] instead of + /// hammering a door that stays shut until the person acts. + final bool Function()? holding; + static const _pingEvery = Duration(seconds: 30); static const _debounce = Duration(milliseconds: 250); static const _maxBackoff = Duration(seconds: 30); + static const _heldBackoff = Duration(minutes: 5); WebSocket? _ws; StreamSubscription? _sub; @@ -134,6 +141,10 @@ class SyncSocket { void _scheduleReconnect() { if (!_running) return; _reconnectTimer?.cancel(); + if (holding?.call() ?? false) { + _reconnectTimer = Timer(_heldBackoff, _connect); + return; + } _reconnectTimer = Timer(_backoff, _connect); final next = _backoff * 2; _backoff = next > _maxBackoff ? _maxBackoff : next; diff --git a/app/lib/data/worker_repo.dart b/app/lib/data/worker_repo.dart index 5ebd92f..1dc1108 100644 --- a/app/lib/data/worker_repo.dart +++ b/app/lib/data/worker_repo.dart @@ -30,6 +30,7 @@ import 'relic_db.dart'; import 'repo.dart'; import 'supabase_auth.dart'; import 'sync_jobs.dart'; +import 'sync_plan.dart'; import 'sync_socket.dart'; /// RelicRepo backed by the deployed Cloudflare Worker (docs/api.md), with @@ -150,6 +151,34 @@ class WorkerRepo implements RelicRepo { /// LocalDeskRepo.emailUnverified. final ValueNotifier emailUnverified = ValueNotifier(false); + /// The free plan's sync pause (403 sync_paused, data/sync_plan.dart): set + /// while the account syncs a different device. The cached vault stays + /// readable and captures queue up; they go out once this phone syncs again. + /// The shell watches [syncPaused] for its banner. Lockstep with + /// LocalDeskRepo.syncPaused. + final SyncPauseGate _pause = SyncPauseGate(); + ValueNotifier get syncPaused => _pause.paused; + + /// Make this phone the one that keeps syncing (POST /account/sync-device). + /// On success the pause lifts and a sync runs straight away. Lockstep with + /// LocalDeskRepo.syncThisDevice. + Future syncThisDevice() async { + final id = deviceId; + if (id == null || id.isEmpty) { + return const SyncPickResult(SyncPickOutcome.failed); + } + final r = await postSyncDevice( + url: Uri.parse(_u('/account/sync-device')), + headers: _headers, + deviceId: id, + ); + if (r.outcome == SyncPickOutcome.ok) { + _pause.clear(); + unawaited(syncDelta()); + } + return r; + } + /// Set true when the account's session has been revoked: the worker answered /// 401 `session_revoked` (this token predates a device removal), or GoTrue /// refused the refresh token outright. Both hosts watch this to tell the user @@ -1428,6 +1457,7 @@ class WorkerRepo implements RelicRepo { baseUrl: () => baseUrl, headers: () => _headers, onWake: () => unawaited(syncDelta()), + holding: () => _pause.holding, ); _syncSocket!.start(); } @@ -1482,6 +1512,12 @@ class WorkerRepo implements RelicRepo { return; } } + // Paused by the free plan and asked a moment ago: the answer will not have + // changed, so this pass is just the chip. + if (_pause.skipPull()) { + _sync = SyncState(SyncKind.paused, pending: _outbox.length); + return; + } // Blobs first: a relic put that lands before its blob would show up on // another device as an item whose bytes 404. await _flushBlobOutbox(); @@ -1536,9 +1572,20 @@ class WorkerRepo implements RelicRepo { _refreshToken = null; sessionRevoked.value = true; } + // The free plan syncs another device. Reachable, not offline. + if (_pause.absorb(r.statusCode, r.body)) { + _sync = SyncState(SyncKind.paused, pending: _outbox.length); + return; + } _sync = const SyncState(SyncKind.offline); return; } + if (_pause.holding) { + // Picked elsewhere, or the account paid: this phone syncs again. + // One more pass sends what queued up while it was paused. + _pause.clear(); + _syncAgain = true; + } final body = jsonDecode(r.body) as Map; final items = (body['items'] as List).cast>(); for (final env in items) { @@ -1645,6 +1692,7 @@ class WorkerRepo implements RelicRepo { historyCap: (j['history_cap'] as num?)?.toInt(), evictedCount: (j['evicted_count'] as num?)?.toInt() ?? 0, devicesCap: (j['devices_cap'] as num?)?.toInt(), + syncUntil: (j['sync_until'] as num?)?.toInt(), ); } } catch (_) {} @@ -1995,6 +2043,7 @@ class WorkerRepo implements RelicRepo { historyCap: (a['historyCap'] as num?)?.toInt(), evictedCount: (a['evictedCount'] as num?)?.toInt() ?? 0, devicesCap: (a['devicesCap'] as num?)?.toInt(), + syncUntil: (a['syncUntil'] as num?)?.toInt(), ); } } catch (_) { @@ -2202,6 +2251,7 @@ class WorkerRepo implements RelicRepo { 'historyCap': _account!.historyCap, 'evictedCount': _account!.evictedCount, 'devicesCap': _account!.devicesCap, + 'syncUntil': _account!.syncUntil, }, }; await writeCacheFile(f.path, doc); @@ -2221,6 +2271,7 @@ class WorkerRepo implements RelicRepo { _outbox.clear(); _blobOutbox.clear(); _account = null; // cached plan/storage belongs to the account being dropped + _pause.clear(); _cursor = 0; _walk = null; _tombCursor = 0; @@ -2287,7 +2338,8 @@ class WorkerRepo implements RelicRepo { /// Try to send every queued mutation. Stops at the first transient failure /// (keeps the rest queued); drops permanently-rejected ops. Future _flushOutbox() async { - if (_outbox.isEmpty || _mk == null) return; + // Paused by the free plan: the queue waits for the next pull to lift it. + if (_outbox.isEmpty || _mk == null || _pause.holding) return; final pending = List>.from(_outbox); var sent = false; for (final op in pending) { @@ -2311,6 +2363,8 @@ class WorkerRepo implements RelicRepo { if (resp.statusCode == 200) emailUnverified.value = false; _outbox.remove(op); sent = true; + } else if (_pause.absorb(resp.statusCode, resp.body)) { + break; // free plan sync pause: the op stays queued } else if (resp.statusCode == 403 && isEmailUnverifiedBody(resp.body)) { // Email not confirmed yet (VERIFY_GATE). Leave the op queued and stop; @@ -2480,7 +2534,7 @@ class WorkerRepo implements RelicRepo { /// queued; drops keys that are permanently rejected or whose local file has /// gone (cache eviction — nothing left to send). Future _flushBlobOutbox() async { - if (_blobOutbox.isEmpty || _mk == null) return; + if (_blobOutbox.isEmpty || _mk == null || _pause.holding) return; await _ensureBlobDir(); var changed = false; for (final key in List.from(_blobOutbox)) { diff --git a/app/lib/desktop.dart b/app/lib/desktop.dart index 1413ce3..77ddb36 100644 --- a/app/lib/desktop.dart +++ b/app/lib/desktop.dart @@ -39,6 +39,7 @@ import 'data/local_desk_repo.dart'; import 'data/recovery.dart'; import 'data/repo.dart' show ringNoticeBody, sizeCapNoticeBody; import 'data/self_update.dart'; +import 'data/sync_plan.dart'; import 'data/supabase_auth.dart'; import 'data/update_check.dart'; import 'models/relic.dart'; @@ -229,6 +230,8 @@ class _RealAppState extends State final ValueNotifier _deviceCount = ValueNotifier(null); bool _showingKit = false; // recovery-kit screen up: don't let blur hide it bool _emailBannerDismissed = false; // verify-to-sync banner, session-only + bool _syncWeekDismissed = false; // free-week countdown banner, session-only + bool _syncPickBusy = false; // "Sync this PC instead" in flight DateTime? _shownAt; // when the popup was last summoned (guards blur-to-close) // Mini picker geometry. Captured once per summon so the window can re-hug the @@ -2542,6 +2545,7 @@ class _RealAppState extends State listenable: Listenable.merge([ widget.repo.emailUnverified, widget.repo.sessionRevoked, + widget.repo.syncPaused, ]), builder: (context, _) { final unverified = widget.repo.emailUnverified.value; @@ -2552,6 +2556,19 @@ class _RealAppState extends State if (unverified && !_emailBannerDismissed) { banners.add(_verifyBanner()); } + // The free plan syncs one device. Not dismissible: sync stays off on + // this PC until it is picked or the account pays. + final paused = widget.repo.syncPaused.value; + if (paused != null) { + banners.add(_syncPausedBanner(paused)); + } else if (!_syncWeekDismissed) { + final line = syncWeekLine(widget.repo.account?.syncUntil); + // Only the last three days: earlier than that it is noise. + final until = widget.repo.account?.syncUntil; + if (line != null && until != null && daysUntil(until) <= 3) { + banners.add(_syncWeekBanner(line)); + } + } if (widget.repo.isDemo && !widget.repo.demoNudgeDismissed) { banners.add(_demoNudgeBanner()); } @@ -2743,6 +2760,121 @@ class _RealAppState extends State ); } + /// The free plan's sync pause (403 sync_paused). This PC keeps everything it + /// has and keeps capturing; only sync is off. Two ways out: make this PC the + /// one that syncs, or upgrade. + Widget _syncPausedBanner(SyncPause paused) { + final c = _useDark ? RelicColors.dark : RelicColors.light; + return _planBanner( + c, + icon: LucideIcons.circlePause, + text: paused.message, + actions: [ + GhostButton( + label: _syncPickBusy + ? 'Switching…' + : Platform.isMacOS + ? 'Sync this Mac instead' + : 'Sync this PC instead', + onTap: _syncPickBusy + ? null + : () async { + setState(() => _syncPickBusy = true); + final r = await widget.repo.syncThisDevice(); + if (!mounted) return; + setState(() => _syncPickBusy = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(_snack(c, r.message())); + }, + ), + if (!storeSafeBuild) ...[ + const SizedBox(width: Insets.xs), + GhostButton( + label: 'Get Pro', + onTap: () => _ringUpgrade('sync_paused'), + ), + ], + ], + ); + } + + /// The free week's countdown, in its last three days. Dismissible for the + /// session; it comes back next launch while the week is still running. + Widget _syncWeekBanner(String line) { + final c = _useDark ? RelicColors.dark : RelicColors.light; + return _planBanner( + c, + icon: LucideIcons.clock, + text: '$line After that, one device keeps syncing.', + actions: [ + if (!storeSafeBuild) + GhostButton( + label: 'Get Pro', + onTap: () => _ringUpgrade('sync_week'), + ), + const SizedBox(width: Insets.xs), + GhostIconButton( + icon: LucideIcons.x, + iconSize: 15, + tooltip: 'Dismiss', + onTap: () => setState(() => _syncWeekDismissed = true), + ), + ], + ); + } + + /// Banner chrome for the plan banners: the line on top, the actions right + /// aligned under it, since two buttons and a sentence do not fit one row of + /// the popup. + Widget _planBanner( + RelicColors c, { + required IconData icon, + required String text, + required List actions, + }) { + return Material( + color: c.panel, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: c.border, width: 1)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + Insets.lg, + Insets.md, + Insets.sm, + Insets.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: c.accent, size: 17), + const SizedBox(width: Insets.md), + Expanded( + child: Text( + text, + style: RelicTheme.sans( + size: 12.5, + color: c.text, + height: 1.35, + ), + ), + ), + ], + ), + const SizedBox(height: Insets.xs), + Row(mainAxisAlignment: MainAxisAlignment.end, children: actions), + ], + ), + ), + ), + ); + } + /// One-time demo nudge shown after "Try the demo" seeding. Dismissal persists /// (repo prefs), so it never nags twice; the button opens onboarding. Widget _searchRebuildBanner(int left) { diff --git a/app/lib/mobile.dart b/app/lib/mobile.dart index e060b7a..c14d4f2 100644 --- a/app/lib/mobile.dart +++ b/app/lib/mobile.dart @@ -27,6 +27,7 @@ import 'data/save_prefs.dart'; import 'data/secure_key_store.dart'; import 'data/share_dedup.dart'; import 'data/supabase_auth.dart'; +import 'data/sync_plan.dart'; import 'data/wifi_watch.dart'; import 'data/worker_repo.dart'; import 'onboarding/add_device.dart'; @@ -346,8 +347,12 @@ class _MobileAppState extends State with WidgetsBindingObserver { if (identical(__repo, r)) return; __repo?.changes.removeListener(_onRepoChanged); __repo?.sessionRevoked.removeListener(_onRepoChanged); + __repo?.syncPaused.removeListener(_onRepoChanged); __repo = r; r?.changes.addListener(_onRepoChanged); + // Same reason as sessionRevoked below: the pull that finds the pause + // brings nothing back, so `changes` stays quiet. + r?.syncPaused.addListener(_onRepoChanged); // Watched separately from `changes`: a revoked session is discovered by a // pull that, by definition, brought nothing back, so `changes` never fires // and the banner would not appear until something else repainted. @@ -380,6 +385,8 @@ class _MobileAppState extends State with WidgetsBindingObserver { /// second-vault notice, which stays quiet until a real number lands. final ValueNotifier _deviceCount = ValueNotifier(null); bool _emailBannerDismissed = false; // verify-to-sync banner, session-only + bool _syncWeekDismissed = false; // free-week countdown banner, session-only + bool _syncPickBusy = false; // "Sync this phone instead" in flight final SecureKeyStore _keyStore = SecureKeyStore.forPlatform(); // The bottom sheet must open from a context *below* MaterialApp's Navigator; // this key gives us one (the State's own context is above it). @@ -502,6 +509,7 @@ class _MobileAppState extends State with WidgetsBindingObserver { _linkSub?.cancel(); __repo?.changes.removeListener(_onRepoChanged); __repo?.sessionRevoked.removeListener(_onRepoChanged); + __repo?.syncPaused.removeListener(_onRepoChanged); _deviceCount.dispose(); _semModel?.dispose(); super.dispose(); @@ -901,6 +909,91 @@ class _MobileAppState extends State with WidgetsBindingObserver { ), ); + /// The free plan's sync pause (403 sync_paused). The phone keeps what it + /// has and keeps saving; only sync is off. Store-safe builds (iPhone) state + /// the facts and offer the switch, with no upgrade button. + Widget _syncPausedBanner(RelicColors c, SyncPause paused) => Material( + color: c.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 6, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(LucideIcons.circlePause, color: c.accent, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text(paused.message, + style: RelicTheme.sans( + size: 12.5, color: c.text, height: 1.35)), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _syncPickBusy + ? null + : () async { + setState(() => _syncPickBusy = true); + final r = await _repo?.syncThisDevice(); + if (!mounted) return; + setState(() => _syncPickBusy = false); + if (r != null) _toast(r.message()); + }, + child: Text( + _syncPickBusy + ? 'Switching…' + : 'Sync this phone instead', + style: RelicTheme.sans( + size: 13, + color: c.accentMuted, + weight: FontWeight.w600)), + ), + if (!storeSafeBuild) + TextButton( + onPressed: () => + _openUpgradeGuidance(source: 'sync_paused'), + child: Text('Get Pro', + style: RelicTheme.sans( + size: 13, + color: c.accentMuted, + weight: FontWeight.w600)), + ), + ], + ), + ], + ), + ), + ); + + /// The free week's countdown, in its last three days. Dismissible for the + /// session. + Widget _syncWeekBanner(RelicColors c, String line) => Material( + color: c.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 6, 8), + child: Row( + children: [ + Icon(LucideIcons.clock, color: c.accent, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text('$line After that, one device keeps syncing.', + style: RelicTheme.sans( + size: 12.5, color: c.text, height: 1.35)), + ), + IconButton( + icon: Icon(LucideIcons.x, size: 16, color: c.textMuted), + onPressed: () => setState(() => _syncWeekDismissed = true), + ), + ], + ), + ), + ); + /// The one-time first-connect run, in order: what a phone actually does, /// then how to get things into it. The expectation screen goes first because /// it is the frame the walkthrough hangs on. @@ -2597,10 +2690,23 @@ class _MobileAppState extends State with WidgetsBindingObserver { ); // Stack banners above the list: signed-out (the session was revoked) and // the verify-to-sync notice (email not confirmed). + final paused = _repo!.syncPaused.value; + final until = _repo!.account?.syncUntil; + final weekLine = syncWeekLine(until); final banners = [ if (_repo!.sessionRevoked.value) _signedOutBanner(colors), if (_repo!.emailUnverified.value && !_emailBannerDismissed) _verifyBanner(colors), + // The free plan syncs one device. Not dismissible: sync stays off + // here until this phone is picked or the account pays. + if (paused != null) + _syncPausedBanner(colors, paused) + // The free week's last three days. + else if (weekLine != null && + until != null && + daysUntil(until) <= 3 && + !_syncWeekDismissed) + _syncWeekBanner(colors, weekLine), ]; body = banners.isEmpty ? popup diff --git a/app/lib/widgets/chrome.dart b/app/lib/widgets/chrome.dart index e42727e..9823d7a 100644 --- a/app/lib/widgets/chrome.dart +++ b/app/lib/widgets/chrome.dart @@ -11,7 +11,7 @@ import 'relic_mark.dart'; enum Scope { all, vault } -enum SyncKind { synced, pending, offline, quotaFull, historyFull } +enum SyncKind { synced, pending, offline, quotaFull, historyFull, paused } class SyncState { final SyncKind kind; @@ -257,6 +257,12 @@ class _SyncChip extends StatelessWidget { icon = LucideIcons.history; color = c.warningDim; label = '${sync.historyCount} / ${sync.historyCap}'; + // The free plan syncs one device and this is not it. The banner above + // the list explains and offers the switch. + case SyncKind.paused: + icon = LucideIcons.circlePause; + color = c.warningDim; + label = 'Sync paused'; } chip = Row( mainAxisSize: MainAxisSize.min, diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c115eac..51968e0 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.63+81 +version: 1.0.64+82 environment: sdk: ^3.12.2 diff --git a/app/test/desk_sync_chip_test.dart b/app/test/desk_sync_chip_test.dart index 0c03fb4..518a65c 100644 --- a/app/test/desk_sync_chip_test.dart +++ b/app/test/desk_sync_chip_test.dart @@ -30,6 +30,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:relic_app/data/local_desk_repo.dart'; +import 'package:relic_app/data/sync_plan.dart' show SyncPickOutcome; import 'package:relic_app/models/relic.dart'; import 'package:relic_app/widgets/chrome.dart' show SyncKind; @@ -54,6 +55,8 @@ void main() { var putDelay = Duration.zero; // hold each relic put this long var dropPuts = 0; // destroy the socket under the next N relic puts var failPulls = false; // answer /relics with 500 + var paused = false; // the free plan syncs another device: 403 sync_paused + var picks = 0; // POST /account/sync-device calls var puts = []; // relic uids the server accepted, in order Future json(HttpRequest req, Object body) async { @@ -70,12 +73,33 @@ void main() { putDelay = Duration.zero; dropPuts = 0; failPulls = false; + paused = false; + picks = 0; puts = []; server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); url = 'http://127.0.0.1:${server.port}'; unawaited(server.forEach((req) async { final path = req.uri.path; await req.drain(); + if (path == '/account/sync-device') { + picks++; + paused = false; + await json(req, {'ok': true, 'sync_device': 'this-pc'}); + return; + } + if (paused && (path.startsWith('/relic') || path == '/tombstones')) { + req.response + ..statusCode = 403 + ..headers.contentType = ContentType.json + ..write(jsonEncode({ + 'error': 'sync_paused', + 'sync_until': 1000, + 'sync_device': 'other', + 'devices': const [], + })); + await req.response.close(); + return; + } if (req.method == 'PUT' && path.startsWith('/relic/')) { if (dropPuts > 0) { dropPuts--; @@ -205,4 +229,33 @@ void main() { failPulls = false; await until(() => repo.sync.kind == SyncKind.synced); }); + + test('a paused PC says so, keeps its captures, and syncs once picked', + () async { + if (guarded) { + markTestSkipped('RELIC_DATA_DIR sandbox not set — skipping repo test'); + return; + } + final repo = await bound(); + repo.debugBindSync(url, mk, deviceId: 'this-pc'); + paused = true; + await repo.syncNow(); + expect(repo.syncPaused.value, isNotNull); + expect(repo.sync.kind, SyncKind.paused); + + final text = 'kept ${DateTime.now().microsecondsSinceEpoch}'; + repo.captureText(text); + await Future.delayed(const Duration(milliseconds: 300)); + expect(puts, isEmpty, reason: 'a paused PC must not push'); + expect(repo.sync.kind, SyncKind.paused); + expect(repo.relicSync(rowWith(repo, text)), RelicSync.syncing, + reason: 'the op stays queued, not rejected'); + + final r = await repo.syncThisDevice(); + expect(r.outcome, SyncPickOutcome.ok); + expect(picks, 1); + await until(() => puts.length == 1); + await until(() => repo.sync.kind == SyncKind.synced); + expect(repo.syncPaused.value, isNull); + }); } diff --git a/app/test/sync_paused_test.dart b/app/test/sync_paused_test.dart new file mode 100644 index 0000000..479b2f2 --- /dev/null +++ b/app/test/sync_paused_test.dart @@ -0,0 +1,317 @@ +// The free plan syncs one device (worker/src/syncplan.ts). A device that is +// not the one gets 403 sync_paused on every sync route. These tests pin what +// the apps do with that: say so instead of "Offline", keep what was captured +// queued rather than dropping it, ask again only every few minutes, and pick +// this device on request. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:relic_app/data/supabase_auth.dart'; +import 'package:relic_app/data/sync_plan.dart'; +import 'package:relic_app/data/worker_repo.dart'; +import 'package:relic_app/widgets/chrome.dart' show SyncKind; + +class _RealNetwork extends HttpOverrides {} + +String pausedBody({String keep = 'pc'}) => jsonEncode({ + 'error': 'sync_paused', + 'message': 'Free plans sync one device. Sync is paused on this one.', + 'sync_until': 1000, + 'sync_device': keep, + 'devices': [ + { + 'device_id': 'pc', + 'label': "Jordan's PC", + 'platform': 'windows', + 'this_device': false, + }, + { + 'device_id': 'phone', + 'label': null, + 'platform': 'android', + 'this_device': true, + }, + ], + }); + +void main() { + group('SyncPause', () { + test('reads the 403 body', () { + final p = SyncPause.fromBody(pausedBody())!; + expect(p.syncUntil, 1000); + expect(p.syncDevice, 'pc'); + expect(p.devices, hasLength(2)); + expect(p.devices[1].thisDevice, isTrue); + expect(p.syncDeviceName, "Jordan's PC"); + expect(p.message, contains("yours is Jordan's PC")); + }); + + test('ignores every other error', () { + expect(SyncPause.fromBody('{"error":"email_unverified"}'), isNull); + expect(SyncPause.fromBody('not json'), isNull); + expect(SyncPause.fromBody('[]'), isNull); + }); + + test('names an unlabelled device by its platform', () { + final p = SyncPause.fromBody(pausedBody(keep: 'phone'))!; + expect(p.syncDeviceName, 'your Android phone'); + }); + + test('falls back when the syncing device is not listed', () { + final p = SyncPause.fromBody(pausedBody(keep: 'gone'))!; + expect(p.syncDeviceName, isNull); + expect(p.message, startsWith('Sync is paused here.')); + }); + }); + + group('SyncPauseGate', () { + test('publishes a pause and spaces out the re-checks', () { + final g = SyncPauseGate(); + final t0 = DateTime(2026, 11, 1, 12); + expect(g.absorb(403, pausedBody(), now: t0), isTrue); + expect(g.holding, isTrue); + expect(g.skipPull(now: t0.add(const Duration(minutes: 1))), isTrue); + expect( + g.skipPull(force: true, now: t0.add(const Duration(minutes: 1))), + isFalse, + ); + expect(g.skipPull(now: t0.add(kPausedRecheck)), isFalse); + g.clear(); + expect(g.holding, isFalse); + expect(g.skipPull(now: t0), isFalse); + }); + + test('leaves other refusals to the caller', () { + final g = SyncPauseGate(); + expect(g.absorb(403, '{"error":"email_unverified"}'), isFalse); + expect(g.absorb(500, pausedBody()), isFalse); + expect(g.holding, isFalse); + }); + }); + + group('the free week countdown', () { + final now = DateTime.fromMillisecondsSinceEpoch(1000000 * 1000); + int inDays(double d) => 1000000 + (d * 86400).round(); + + test('counts whole days, rounding up', () { + expect(daysUntil(inDays(2.2), now: now), 3); + expect(daysUntil(inDays(0.1), now: now), 1); + expect(daysUntil(inDays(-1), now: now), 0); + }); + + test('says nothing outside the week', () { + expect(syncWeekLine(null, now: now), isNull); + expect(syncWeekLine(inDays(-0.5), now: now), isNull); + }); + + test('reads plainly', () { + expect( + syncWeekLine(inDays(3), now: now), + 'Free sync on all your devices ends in 3 days.', + ); + expect( + syncWeekLine(inDays(0.4), now: now), + 'Free sync on all your devices ends in 1 day.', + ); + }); + }); + + group('postSyncDevice', () { + Future pick(http.Client client) => http.runWithClient( + () => postSyncDevice( + url: Uri.parse('https://x/account/sync-device'), + headers: const {'Authorization': 'Bearer t'}, + deviceId: 'phone', + ), + () => client, + ); + + test('sends the device id', () async { + late Map sent; + final r = await pick(MockClient((req) async { + sent = jsonDecode(req.body) as Map; + expect(req.headers['Authorization'], 'Bearer t'); + return http.Response('{"ok":true,"sync_device":"phone"}', 200); + })); + expect(sent, {'device_id': 'phone'}); + expect(r.outcome, SyncPickOutcome.ok); + expect(r.message(), 'This device syncs now.'); + }); + + test('reports the once-a-week limit with its date', () async { + final at = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3 * 86400 - 60; + final r = await pick(MockClient((_) async => http.Response( + jsonEncode({'error': 'sync_switch_cooldown', 'retry_at': at}), + 429, + ))); + expect(r.outcome, SyncPickOutcome.cooldown); + expect(r.retryAt, at); + expect(r.message(), contains('Try again in 3 days')); + }); + + test('a bare rate-limit 429 or a dead network is a plain failure', + () async { + expect( + (await pick(MockClient((_) async => http.Response('', 429)))).outcome, + SyncPickOutcome.failed, + ); + expect( + (await pick(MockClient((_) async => throw const SocketException('x')))) + .outcome, + SyncPickOutcome.failed, + ); + }); + }); + + group('WorkerRepo on a paused phone', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final mk = Uint8List.fromList(List.generate(32, (i) => i * 7 & 0xff)); + + late Directory tmp; + late HttpServer server; + late String url; + late bool paused; + late List arrivals; // "METHOD /path", in order + late List picks; // bodies of POST /account/sync-device + + setUp(() async { + HttpOverrides.global = _RealNetwork(); + tmp = Directory.systemTemp.createTempSync('relic_sync_paused_'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => tmp.path, + ); + paused = true; + arrivals = []; + picks = []; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + url = 'http://127.0.0.1:${server.port}'; + unawaited(server.forEach((req) async { + final path = req.uri.path; + arrivals.add('${req.method} $path'); + final body = await utf8.decoder.bind(req).join(); + final account = path.startsWith('/account'); + if (path == '/account/sync-device') { + picks.add(jsonDecode(body)); + paused = false; + req.response + ..statusCode = 200 + ..write('{"ok":true,"sync_device":"phone"}'); + } else if (paused && !account) { + req.response + ..statusCode = 403 + ..headers.contentType = ContentType.json + ..write(pausedBody()); + } else { + req.response + ..statusCode = 200 + ..headers.contentType = ContentType.json + ..write(switch (path) { + '/account' => jsonEncode({ + 'tier': 'free', + 'storage_used': 0, + 'storage_quota': 1000, + 'vault_count': 0, + 'sync_until': 1000, + 'sync_device': 'pc', + 'sync_paused': paused, + }), + '/relics' => jsonEncode({'items': [], 'next_cursor': null}), + _ => jsonEncode({'items': []}), + }); + } + await req.response.close(); + })); + }); + + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + null, + ); + await server.close(force: true); + try { + tmp.deleteSync(recursive: true); + } catch (_) {} + }); + + Future repo() async { + final r = await WorkerRepo.bindSupabaseWithMk( + baseUrl: url, + session: const SupabaseSession( + accessToken: 't', + refreshToken: 'r', + expiresAt: 4102444800, // far future: never triggers a token refresh + userId: 'test-user', + ), + mk: mk, + ); + r.deviceId = 'phone'; + await r.loadLocal(); + return r; + } + + test('reads as paused, not offline, and keeps captures queued', () async { + final r = await repo(); + await r.syncDelta(); + expect(r.syncPaused.value, isNotNull); + expect(r.syncPaused.value!.syncDevice, 'pc'); + expect(r.sync.kind, SyncKind.paused); + + await r.captureText('saved while paused'); + expect(arrivals.where((a) => a.startsWith('PUT /relic/')), isEmpty, + reason: 'a paused phone must not push, and must not drop the op'); + await r.syncDelta(); // inside the re-check window: no request at all + expect(arrivals.where((a) => a == 'GET /relics'), hasLength(1)); + expect(r.sync.kind, SyncKind.paused); + expect(r.sync.pending, 1); + }); + + test('syncing this phone instead lifts the pause and sends the queue', + () async { + final r = await repo(); + await r.syncDelta(); + await r.captureText('saved while paused'); + + final res = await r.syncThisDevice(); + expect(res.outcome, SyncPickOutcome.ok); + expect(picks, [ + {'device_id': 'phone'}, + ]); + expect(r.syncPaused.value, isNull); + + final end = DateTime.now().add(const Duration(seconds: 10)); + while (!arrivals.any((a) => a.startsWith('PUT /relic/'))) { + if (DateTime.now().isAfter(end)) fail('the queued capture never went'); + await Future.delayed(const Duration(milliseconds: 20)); + } + await r.syncDelta(); + expect(r.sync.kind, SyncKind.synced); + }); + + test('a pull that goes through lifts a pause set earlier', () async { + final r = await repo(); + await r.syncDelta(); + await r.captureText('saved while paused'); + expect(r.syncPaused.value, isNotNull); + paused = false; // say the account paid on the web + SyncPauseGate.recheck = Duration.zero; + addTearDown(() => SyncPauseGate.recheck = kPausedRecheck); + await r.syncDelta(); + expect(r.syncPaused.value, isNull); + // The pass that found the pause gone runs once more for the queue. + final end = DateTime.now().add(const Duration(seconds: 10)); + while (!arrivals.any((a) => a.startsWith('PUT /relic/'))) { + if (DateTime.now().isAfter(end)) fail('the queued capture never went'); + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + }); +}