Migrate phone_app alarm sync from GitHub Gist to crdt_sync
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

phone_app: the alarm now syncs through the shared crdt_sync transport
(v0.2.0) into the fixed private repo kuhyx/syncs, replacing the bespoke
Gist. AlarmSyncService is the sole writer: it holds the alarm locally in a
crdt_sync LogStore (one HLC-stamped Record) and pushes it to
wake-alarm-sync/devices/phone/alarm.json. gist_service.dart is deleted;
SyncSettings drops the Gist id and keeps only a token; the device-flow scope
moves from `gist` to `repo` (the Contents API needs private-repo access).

shutdown-wrapper.sh: the PC reader now fetches that alarm from the Contents
API (Accept: application/vnd.github.raw, so no base64) and parses the
crdt_sync Record Log with jq (.alarm.fields.alarm_hour[0]). Single-file read
of the sole "phone" device; unchanged fallback to WAKE_AFTER_HOURS on any
miss so a parse failure can never brick the wake. Config moves from
gist_sync.json {token, gist_id} to sync.json {token}.

Verified: flutter analyze clean + tests pass; shellcheck clean and the full
get_synced_alarm_epoch verified against the real Log format; on-device the
app builds, installs with adb install -r (no data loss), launches and
renders. Live round-trip needs a one-time repo-scoped re-auth on the phone
and ~/.config/wake_alarm/sync.json on the PC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 22:45:14 +02:00
parent d2f75f7f21
commit 512fdd5c95
10 changed files with 323 additions and 185 deletions

View File

@ -4,10 +4,11 @@ import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter/material.dart';
import 'package:wake_alarm_sync/screens/settings_screen.dart';
import 'package:wake_alarm_sync/services/gist_service.dart';
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
import 'package:wake_alarm_sync/services/sync_settings.dart';
/// Main screen: displays the current alarm time and syncs it to the Gist.
/// Main screen: displays the current alarm time and syncs it to the PC via
/// the shared crdt_sync transport.
class HomeScreen extends StatefulWidget {
/// Creates a [HomeScreen].
const HomeScreen({super.key});
@ -22,6 +23,7 @@ class _HomeScreenState extends State<HomeScreen> {
bool _busy = false;
String _status = '';
bool _configured = false;
AlarmSyncService? _service;
@override
void initState() {
@ -30,25 +32,27 @@ class _HomeScreenState extends State<HomeScreen> {
}
Future<void> _loadSavedTime() async {
final settings = await SyncSettings.load();
if (!mounted) return;
setState(() => _configured = settings.isConfigured);
if (!settings.isConfigured) return;
final SyncSettings settings;
final AlarmSyncService service;
try {
final time = await readGistAlarm(
token: settings.token,
gistId: settings.gistId,
);
if (time != null && mounted) {
setState(() {
_hour = time.hour;
_minute = time.minute;
_status = 'Loaded from Gist';
});
}
settings = await SyncSettings.load();
service = await AlarmSyncService.open();
} on Exception catch (_) {
// Non-fatal: local defaults used.
// Storage/plugins unavailable (e.g. under a widget test, or a keystore
// hiccup): keep the on-screen defaults rather than crashing.
return;
}
if (!mounted) return;
_service = service;
setState(() => _configured = settings.isConfigured);
final alarm = service.currentAlarm;
if (alarm != null && mounted) {
setState(() {
_hour = alarm.hour;
_minute = alarm.minute;
_status = 'Loaded saved alarm';
});
}
}
@ -88,7 +92,8 @@ class _HomeScreenState extends State<HomeScreen> {
Future<void> _syncAndSet() async {
final settings = await SyncSettings.load();
if (!settings.isConfigured) {
final service = _service;
if (!settings.isConfigured || service == null) {
setState(
() => _status = 'Configure GitHub in Settings first.',
);
@ -97,17 +102,13 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() {
_busy = true;
_status = 'Syncing to Gist…';
_status = 'Syncing to PC…';
});
try {
await writeGistAlarm(
token: settings.token,
gistId: settings.gistId,
hour: _hour,
minute: _minute,
);
await service.setLocal(_hour, _minute);
await service.push(settings.token);
setState(
() => _status = 'Gist updated ✓ — opening Clock app…',
() => _status = 'Synced ✓ — opening Clock app…',
);
await _setPhoneAlarm();
setState(

View File

@ -4,11 +4,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wake_alarm_sync/services/gist_service.dart';
import 'package:wake_alarm_sync/services/github_device_auth.dart';
import 'package:wake_alarm_sync/services/sync_settings.dart';
/// Settings screen for GitHub connection and Gist configuration.
/// Settings screen for the GitHub connection used to sync the alarm.
///
/// The "Connect GitHub" button runs the OAuth device flow — the user
/// enters a short code on github.com; no token pasting required.
@ -21,7 +20,7 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
SyncSettings _settings = const SyncSettings(token: '', gistId: '');
SyncSettings _settings = const SyncSettings(token: '');
bool _busy = false;
String _status = '';
@ -52,16 +51,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() {
_busy = true;
_status = 'Connected — creating Gist…';
_status = 'Connecting…';
});
final gistId = await createGist(token: token);
final updated = SyncSettings(token: token, gistId: gistId);
final updated = SyncSettings(token: token);
await updated.save();
if (mounted) {
setState(() {
_settings = updated;
_busy = false;
_status = 'Connected! Gist ID: $gistId';
_status = 'Connected! Alarm will sync to kuhyx/syncs.';
});
}
} on Exception catch (e) {
@ -80,7 +78,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
await SyncSettings.clear();
if (mounted) {
setState(() {
_settings = const SyncSettings(token: '', gistId: '');
_settings = const SyncSettings(token: '');
_status = 'Disconnected.';
});
}
@ -96,7 +94,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_StatusCard(connected: isConnected, gistId: _settings.gistId),
_StatusCard(connected: isConnected),
const SizedBox(height: 16),
if (!isConnected)
ElevatedButton.icon(
@ -133,8 +131,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(height: 32),
const Text(
'PC config — create this file:\n'
'~/.config/wake_alarm/gist_sync.json\n\n'
'{ "token": "<same token>", "gist_id": "<shown above>" }',
'~/.config/wake_alarm/sync.json\n\n'
'{ "token": "<same token>" }\n\n'
'The token needs "repo" scope so the PC can read '
'kuhyx/syncs at shutdown.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
@ -145,10 +145,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
class _StatusCard extends StatelessWidget {
const _StatusCard({required this.connected, required this.gistId});
const _StatusCard({required this.connected});
final bool connected;
final String gistId;
@override
Widget build(BuildContext context) {
@ -164,7 +163,7 @@ class _StatusCard extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
connected ? 'Connected — Gist: $gistId' : 'Not connected',
connected ? 'Connected to GitHub' : 'Not connected',
),
),
],

View File

@ -0,0 +1,100 @@
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync/crdt_sync_io.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// The alarm time this device holds, as read from or written to the store.
typedef Alarm = ({int hour, int minute});
/// Cross-device alarm sync for wake_alarm_sync, on the shared `crdt_sync`
/// transport instead of a bespoke GitHub Gist.
///
/// The phone is the **sole writer**: it stores the chosen alarm locally in a
/// [LogStore] (one HLC-stamped [Record], id [_recordId]) and pushes that log
/// to `wake-alarm-sync/devices/phone/alarm.json` in the private `kuhyx/syncs`
/// repo — the same "GitHub as dumb file storage under `devices/<id>/`" layout
/// diet_guard's app and the workout app already use. The PC side
/// (`shutdown-wrapper.sh`) is read-only: it fetches that file at shutdown to
/// set `rtcwake`. There is no pull/merge: the phone is the only writer, so its
/// local store is always the source of truth.
class AlarmSyncService {
/// Wraps an already-loaded log store.
AlarmSyncService(this._store);
final LogStore _store;
/// Fixed sync target: the shared private repo used by every companion app.
static const owner = 'kuhyx';
/// Repo name within [owner] that holds every app's `*-sync/` data.
static const repo = 'syncs';
/// This device's id in the `devices/<id>/` layout. The phone is the only
/// writer, so a constant id is enough (HLCs still order re-installs).
static const deviceId = 'phone';
static const _remotePath = 'wake-alarm-sync/devices/$deviceId/alarm.json';
static const _recordId = 'alarm';
static const _hourField = 'alarm_hour';
static const _minuteField = 'alarm_minute';
/// Opens the on-disk alarm store.
///
/// [persistence] is injectable so tests can supply an in-memory backend
/// instead of touching the real app-documents directory.
static Future<AlarmSyncService> open({LogPersistence? persistence}) async {
final backend = persistence ?? await _defaultPersistence();
final store = LogStore(persistence: backend, nodeId: deviceId);
await store.load();
return AlarmSyncService(store);
}
static Future<LogPersistence> _defaultPersistence() async {
final dir = await getApplicationDocumentsDirectory();
return FileLogPersistence(File(p.join(dir.path, 'alarm_log.json')));
}
/// The alarm currently stored locally, or `null` if none is set yet.
Alarm? get currentAlarm {
final record = _store.get(_recordId);
if (record == null || record.deleted) return null;
final hour = record.fields[_hourField]?.$1;
final minute = record.fields[_minuteField]?.$1;
if (hour is int && minute is int) return (hour: hour, minute: minute);
return null;
}
/// Stores [hour]:[minute] locally, stamped with a fresh monotonic HLC.
///
/// Both fields share one clock tick — they are always set together, so a
/// concurrent per-field merge would never split them.
Future<void> setLocal(int hour, int minute) async {
final hlc = _store.nextHlc();
await _store.upsert(
Record(
id: _recordId,
fields: {_hourField: (hour, hlc), _minuteField: (minute, hlc)},
),
);
}
/// Pushes the local alarm log to `kuhyx/syncs` using [client].
Future<void> pushWith(GitHubClient client) => client.putFileText(
_remotePath,
logToJson(_store.snapshot()),
message: 'wake_alarm_sync: update alarm',
);
/// Pushes using a [token]-authenticated client for the fixed repo.
Future<void> push(String token, {http.Client? httpClient}) => pushWith(
GitHubClient(
owner: owner,
repo: repo,
token: token,
httpClient: httpClient,
),
);
}

View File

@ -1,87 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
const _gistFileName = 'wake_alarm_sync.json';
/// Reads the current alarm time from the Gist.
///
/// Returns null if the Gist is not yet configured or the request fails.
Future<({int hour, int minute})?> readGistAlarm({
required String token,
required String gistId,
}) async {
final uri = Uri.parse('https://api.github.com/gists/$gistId');
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode != 200) return null;
final body = jsonDecode(response.body) as Map<String, dynamic>;
final files = body['files'] as Map<String, dynamic>?;
final fileEntry = files?[_gistFileName] as Map<String, dynamic>?;
final fileContent = fileEntry?['content'] as String?;
if (fileContent == null) return null;
final data = jsonDecode(fileContent) as Map<String, dynamic>;
final hour = data['alarm_hour'] as int?;
final minute = data['alarm_minute'] as int?;
if (hour == null || minute == null) return null;
return (hour: hour, minute: minute);
}
/// Writes the chosen alarm time to the Gist, creating the file if absent.
Future<void> writeGistAlarm({
required String token,
required String gistId,
required int hour,
required int minute,
}) async {
final content = jsonEncode({'alarm_hour': hour, 'alarm_minute': minute});
final uri = Uri.parse('https://api.github.com/gists/$gistId');
final response = await http.patch(
uri,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({
'files': {
_gistFileName: {'content': content},
},
}),
);
if (response.statusCode != 200) {
throw Exception(
'Gist write failed: HTTP ${response.statusCode}',
);
}
}
/// Creates a new private Gist and returns its ID.
Future<String> createGist({required String token}) async {
final content = jsonEncode({'alarm_hour': 7, 'alarm_minute': 0});
final uri = Uri.parse('https://api.github.com/gists');
final response = await http.post(
uri,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({
'description': 'wake_alarm PC sync',
'public': false,
'files': {
_gistFileName: {'content': content},
},
}),
);
if (response.statusCode != 201) {
throw Exception(
'Gist create failed: HTTP ${response.statusCode}',
);
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
return body['id'] as String;
}

View File

@ -62,12 +62,12 @@ class DeviceAuthException implements Exception {
///
/// Device flow needs only a public `client_id` (no client secret), which
/// makes it safe for a distributed app. The resulting access token is then
/// used exactly like a PAT by the Gist service.
/// used exactly like a PAT by the crdt_sync GitHub-Contents transport.
class GitHubDeviceAuth {
/// Creates a [GitHubDeviceAuth] instance.
GitHubDeviceAuth({
required this.clientId,
this.scope = 'gist',
this.scope = 'repo',
http.Client? httpClient,
Future<void> Function(Duration)? delay,
}) : _http = httpClient ?? http.Client(),
@ -76,7 +76,8 @@ class GitHubDeviceAuth {
/// OAuth App client ID (public identifier, not a secret).
final String clientId;
/// OAuth scope requested. `gist` is required to read/write Gists.
/// OAuth scope requested. `repo` is required for the Contents API to
/// read/write the private `kuhyx/syncs` repo (Gists are no longer used).
final String scope;
final http.Client _http;

View File

@ -3,31 +3,31 @@ import 'package:shared_preferences/shared_preferences.dart';
/// Locally-stored GitHub sync configuration for wake_alarm_sync.
///
/// The GitHub token is kept in the OS keystore (Android Keystore) via
/// [FlutterSecureStorage]; the Gist ID (not a secret) lives in
/// `SharedPreferences`. Legacy builds stored the token in plaintext
/// prefs; [load]/[save] migrate it transparently and never drop the
/// plaintext copy until a secure write is confirmed.
/// Only a GitHub token is stored now: the alarm syncs through the shared
/// `crdt_sync` transport into the fixed private repo `kuhyx/syncs` (see
/// `AlarmSyncService`), so there is no per-install Gist id to keep. The token
/// is kept in the OS keystore (Android Keystore) via [FlutterSecureStorage].
/// Legacy builds stored the token in plaintext prefs (and a `gist_id`);
/// [load]/[save] migrate the token transparently and drop the stale keys.
class SyncSettings {
/// Creates a [SyncSettings].
const SyncSettings({required this.token, required this.gistId});
const SyncSettings({required this.token});
/// GitHub access token (PAT or device-flow token).
/// GitHub access token (device-flow token or PAT) with `repo` scope so the
/// Contents API can read/write the private `kuhyx/syncs` repo.
final String token;
/// ID of the private Gist used to sync the alarm time.
final String gistId;
/// Baked-in OAuth App client ID for the device-flow "Connect" button.
/// Device-flow client IDs are public identifiers, not secrets.
static const defaultClientId = 'Ov23liGUPrPe1FFRSel6';
/// True when both token and gist ID are present and sync can proceed.
bool get isConfigured => token.isNotEmpty && gistId.isNotEmpty;
/// True when a token is present and sync can proceed.
bool get isConfigured => token.isNotEmpty;
// Legacy plaintext key — read-only; removed once migrated to keystore.
static const _kLegacyToken = 'github_token';
static const _kGistId = 'gist_id';
// Legacy Gist id key — no longer used; cleared on load/save/clear.
static const _kLegacyGistId = 'gist_id';
static const _secureTokenKey = 'github_token';
static const _secure = FlutterSecureStorage();
@ -35,15 +35,12 @@ class SyncSettings {
/// Loads settings from persistent storage.
static Future<SyncSettings> load() async {
final prefs = await SharedPreferences.getInstance();
return SyncSettings(
token: await _loadToken(prefs),
gistId: prefs.getString(_kGistId) ?? '',
);
return SyncSettings(token: await _loadToken(prefs));
}
/// Reads the token from the keystore, falling back to the legacy
/// plaintext value. A legacy value is migrated into the keystore on
/// read, but only deleted from prefs once that write succeeds.
/// Reads the token from the keystore, falling back to the legacy plaintext
/// value. A legacy value is migrated into the keystore on read, but only
/// deleted from prefs once that write succeeds.
static Future<String> _loadToken(SharedPreferences prefs) async {
String? secure;
try {
@ -60,10 +57,10 @@ class SyncSettings {
return legacy;
}
/// Persists current settings.
/// Persists current settings and drops the stale Gist id.
Future<void> save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kGistId, gistId);
await prefs.remove(_kLegacyGistId);
if (await _writeSecureToken(token)) {
await prefs.remove(_kLegacyToken);
} else {
@ -74,13 +71,13 @@ class SyncSettings {
/// Removes all stored credentials.
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kGistId);
await prefs.remove(_kLegacyGistId);
await prefs.remove(_kLegacyToken);
await _writeSecureToken('');
}
/// Writes [token] to the keystore. Returns false if the platform
/// secret service is unavailable.
/// Writes [token] to the keystore. Returns false if the platform secret
/// service is unavailable.
static Future<bool> _writeSecureToken(String token) async {
try {
if (token.isEmpty) {
@ -95,10 +92,7 @@ class SyncSettings {
}
/// Returns a copy with optional field overrides.
SyncSettings copyWith({String? token, String? gistId}) {
return SyncSettings(
token: token ?? this.token,
gistId: gistId ?? this.gistId,
);
SyncSettings copyWith({String? token}) {
return SyncSettings(token: token ?? this.token);
}
}

View File

@ -65,6 +65,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crdt_sync:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.2.0"
resolved-ref: "5be99ab11d1657ec04edd65508b8c4ecae4da906"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.0"
crypto:
dependency: transitive
description:
@ -289,7 +298,7 @@ packages:
source: hosted
version: "2.2.0"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@ -297,7 +306,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825

View File

@ -1,5 +1,5 @@
name: wake_alarm_sync
description: "Wake alarm phone-side sync app — sets phone alarm and writes time to GitHub Gist."
description: "Wake alarm phone-side sync app — sets the phone alarm and syncs the time to the PC via the shared crdt_sync transport."
publish_to: 'none'
version: 1.0.0+1
@ -9,11 +9,18 @@ environment:
dependencies:
android_intent_plus: ^5.0.0
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.2.0
path: crdt_sync_dart
cupertino_icons: ^1.0.8
flutter:
sdk: flutter
flutter_secure_storage: ^9.0.0
http: ^1.2.0
path: ^1.9.0
path_provider: ^2.1.0
shared_preferences: ^2.3.0
url_launcher: ^6.3.0

View File

@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
const _alarmPath = 'wake-alarm-sync/devices/phone/alarm.json';
http.Response _response(int statusCode, [Object? jsonBody]) =>
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
/// In-memory persistence so the store never touches the real documents dir.
class _MemPersistence implements LogPersistence {
String? text;
@override
Future<String?> read() async => text;
@override
Future<void> write(String value) async => text = value;
}
class _PutCall {
_PutCall(this.path, this.body);
final String path;
final Map<String, dynamic> body;
}
/// Mock GitHub Contents API: GET `.../contents/<key>` returns
/// [contentResponses]`[key]` (404 if absent), the bare repo-existence GET
/// always succeeds, and every PUT is recorded. Mirrors the workout_app test.
({http.Client httpClient, List<_PutCall> putCalls}) _mockGitHub({
Map<String, http.Response> contentResponses = const {},
}) {
final putCalls = <_PutCall>[];
final client = http_testing.MockClient((request) async {
final path = request.url.path;
if (!path.contains('/contents/')) return _response(200);
final key = path.split('/contents/').last;
if (request.method == 'PUT') {
putCalls.add(
_PutCall(key, jsonDecode(request.body) as Map<String, dynamic>),
);
return _response(200);
}
return contentResponses[key] ?? _response(404);
});
return (httpClient: client, putCalls: putCalls);
}
Map<String, dynamic> _decodePush(_PutCall call) =>
jsonDecode(utf8.decode(base64.decode(call.body['content'] as String)))
as Map<String, dynamic>;
void main() {
test('currentAlarm is null before anything is set', () async {
final service = await AlarmSyncService.open(persistence: _MemPersistence());
expect(service.currentAlarm, isNull);
});
test('setLocal stores the alarm locally and survives a reopen', () async {
final persistence = _MemPersistence();
final service = await AlarmSyncService.open(persistence: persistence);
await service.setLocal(6, 30);
expect(service.currentAlarm, (hour: 6, minute: 30));
// The same persistence backend rehydrates the alarm on the next open.
final reopened = await AlarmSyncService.open(persistence: persistence);
expect(reopened.currentAlarm, (hour: 6, minute: 30));
});
test('push PUTs the alarm log to the phone device path', () async {
final service = await AlarmSyncService.open(persistence: _MemPersistence());
await service.setLocal(7, 15);
final (:httpClient, :putCalls) = _mockGitHub();
await service.push('tok', httpClient: httpClient);
expect(putCalls, hasLength(1));
expect(putCalls.single.path, _alarmPath);
final pushed = _decodePush(putCalls.single);
final fields = (pushed['alarm'] as Map<String, dynamic>)['fields']
as Map<String, dynamic>;
expect((fields['alarm_hour'] as List).first, 7);
expect((fields['alarm_minute'] as List).first, 15);
});
test('push is a network round-trip that swallows a server error', () async {
final service = await AlarmSyncService.open(persistence: _MemPersistence());
await service.setLocal(9, 0);
final client = http_testing.MockClient(
(request) async => http.Response('', 500),
);
// A GitHubSyncError from the transport propagates as an Exception the UI
// can catch; it must be a normal Exception, not an Error.
await expectLater(
service.push('tok', httpClient: client),
throwsA(isA<Exception>()),
);
});
}

View File

@ -7,10 +7,11 @@
# Install to /usr/local/bin/shutdown (takes priority over /usr/bin/shutdown
# because /usr/local/bin appears first in PATH).
#
# Wake time is taken from the private GitHub Gist written by the phone app
# (see phone_app/). Falls back to WAKE_AFTER_HOURS if the Gist is not
# configured or unreachable. Config: ~/.config/wake_alarm/gist_sync.json
# with keys "token" (GitHub PAT with gist scope) and "gist_id".
# Wake time is taken from the alarm the phone app pushes to the private
# kuhyx/syncs repo via the shared crdt_sync transport (see phone_app/). Falls
# back to WAKE_AFTER_HOURS if sync is not configured or unreachable. Config:
# ~/.config/wake_alarm/sync.json with key "token" (GitHub PAT/token with
# "repo" scope, so the Contents API can read the private repo).
set -euo pipefail
@ -29,27 +30,36 @@ for arg in "$@"; do
esac
done
# Read alarm time from the private GitHub Gist written by the phone app.
# Read the alarm time the phone pushed to kuhyx/syncs via crdt_sync.
# Returns the Unix epoch (seconds) of the next occurrence of the stored H:M.
# Exits non-zero if the Gist is not configured, unreachable, or malformed.
get_gist_alarm_epoch() {
local cfg="$HOME/.config/wake_alarm/gist_sync.json"
# Exits non-zero if sync is not configured, unreachable, or malformed.
#
# The phone is the sole writer, under the fixed device id "phone", so a single
# Contents-API read of its alarm.json is enough (no device discovery / HLC
# ordering needed here — that would only matter with multiple writers). The
# stored payload is a crdt_sync Log: {"alarm": {"fields": {"alarm_hour":
# [H, "<hlc>"], "alarm_minute": [M, "<hlc>"]}, ...}}. The raw media type makes
# the Contents API return that JSON directly, so no base64 decode is needed.
get_synced_alarm_epoch() {
local cfg="$HOME/.config/wake_alarm/sync.json"
[[ -f "$cfg" ]] || return 1
local token gist_id raw_content hour minute target_epoch now_epoch
local token log_json hour minute target_epoch now_epoch
token=$(jq -r .token "$cfg" 2>/dev/null) || return 1
gist_id=$(jq -r .gist_id "$cfg" 2>/dev/null) || return 1
[[ -n "$token" && "$token" != "null" ]] || return 1
[[ -n "$gist_id" && "$gist_id" != "null" ]] || return 1
raw_content=$(curl -sf --max-time 10 \
local url="https://api.github.com/repos/kuhyx/syncs/contents"
url="$url/wake-alarm-sync/devices/phone/alarm.json"
log_json=$(curl -sf --max-time 10 \
-H "Authorization: token $token" \
"https://api.github.com/gists/$gist_id" \
| jq -r '.files["wake_alarm_sync.json"].content') || return 1
[[ -n "$raw_content" && "$raw_content" != "null" ]] || return 1
-H "Accept: application/vnd.github.raw" \
"$url") || return 1
[[ -n "$log_json" ]] || return 1
hour=$(printf '%s' "$raw_content" | jq -r .alarm_hour 2>/dev/null) || return 1
minute=$(printf '%s' "$raw_content" | jq -r .alarm_minute 2>/dev/null) || return 1
hour=$(printf '%s' "$log_json" \
| jq -r '.alarm.fields.alarm_hour[0]' 2>/dev/null) || return 1
minute=$(printf '%s' "$log_json" \
| jq -r '.alarm.fields.alarm_minute[0]' 2>/dev/null) || return 1
[[ "$hour" =~ ^[0-9]+$ && "$minute" =~ ^[0-9]+$ ]] || return 1
printf -v now_epoch '%(%s)T' -1
@ -64,13 +74,13 @@ get_gist_alarm_epoch() {
tomorrow_dow=$(date -d "tomorrow" +%u)
case "$tomorrow_dow" in
1|2|3|4|5|6|7)
if wake_epoch=$(get_gist_alarm_epoch 2>/dev/null); then
if wake_epoch=$(get_synced_alarm_epoch 2>/dev/null); then
logger -t shutdown-wrapper \
"Gist sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
"crdt_sync: waking at $(date -d "@$wake_epoch") (epoch $wake_epoch)"
else
wake_epoch=$(( $(printf '%(%s)T' -1) + WAKE_AFTER_HOURS * 3600 ))
logger -t shutdown-wrapper \
"Gist unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
"sync unavailable — fallback +${WAKE_AFTER_HOURS}h (epoch $wake_epoch)"
fi
sudo "$RTCWAKE" -m no -t "$wake_epoch"
exec /usr/bin/systemctl hibernate