Restructure step 0 done: notice table test, flaky-link proxy, liveness on the engine clock, STARSYNC_PROFILE
- notice_table_test: one row per AppEvent variant through noticeFor. - FlakyProxy (core/testing.dart): pass, blackhole (half-open), delay, hold, cut after N bytes, reset; demo tests against today's server. - Liveness (stale/drop/ping) on the engine clock, so grace can be tested with a fake clock; Connection takes an injectable clock. - AppDirs with STARSYNC_PROFILE=<name>: separate data and logs, so two instances run on one machine. - DEVELOPMENT: scenario list per restructure step; test tags follow the new step order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eac45746ce
commit
f4ca02c3cf
23 files changed
+959
-48
No files matched your search
@@ -7,6 +7,7 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/platform/app_dirs.dart';
|
||||
import 'src/platform/app_log.dart';
|
||||
import 'src/platform/app_shutdown.dart';
|
||||
import 'src/platform/desktop_entry.dart';
|
||||
@@ -41,14 +42,17 @@ Future<void> main(List<String> args) async {
|
||||
),
|
||||
);
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await startAppLogging();
|
||||
await Hive.initFlutter();
|
||||
final dirs = await AppDirs.resolve();
|
||||
await startAppLogging(dirs);
|
||||
// An absolute subDir replaces the documents folder (package:path join).
|
||||
await Hive.initFlutter(dirs.hive);
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||
// see "left", a hosted server shuts down and no player outlives the
|
||||
// engine.
|
||||
final ports = SessionState.instance.ports;
|
||||
await ports.shell.start(title: appName, onCloseRequest: shutDownApp);
|
||||
await ports.shell.start(
|
||||
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
} catch (e, st) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// The environment variable that picks a separate profile (DEVELOPMENT.md
|
||||
/// "Dev loop": two instances on one machine).
|
||||
const profileEnv = 'STARSYNC_PROFILE';
|
||||
|
||||
/// Where the app keeps its own files, resolved once in `main` ([resolve])
|
||||
/// and handed on from there.
|
||||
///
|
||||
/// Without a profile it is exactly today's layout: Hive's boxes in Hive's
|
||||
/// default folder (the documents folder) and the logs in
|
||||
/// `<app support>/logs`. With `STARSYNC_PROFILE=<name>` (letters, digits,
|
||||
/// `-`, `_`; at most 32) the per-install data moves to
|
||||
/// `<app support>/profiles/<name>/`: the Hive boxes (settings and so the
|
||||
/// profile, connect form, window bounds, folders, Jellyfin sign-ins; the
|
||||
/// client id from restructure step 1 on) under `hive/`, the logs under
|
||||
/// `logs/`. Downloads and caches stay shared: yt-dlp and its cookies, the
|
||||
/// extracted shaders, thumbnails, the AniList cache.
|
||||
class AppDirs {
|
||||
/// The profile name; null for the normal app.
|
||||
final String? profile;
|
||||
/// The app support folder (yt-dlp, shaders; logs without a profile).
|
||||
final String support;
|
||||
/// Hive's folder; null: Hive's default (`Hive.initFlutter()`).
|
||||
final String? hive;
|
||||
/// The app's log folder.
|
||||
final String logs;
|
||||
|
||||
const AppDirs({required this.profile, required this.support, required this.hive, required this.logs});
|
||||
|
||||
/// The layout for [support] (the app support folder) and [env]; an
|
||||
/// invalid profile name is ignored ([invalidProfile] says which).
|
||||
static AppDirs fromEnv({required String support, required Map<String, String> env}) {
|
||||
final sep = Platform.pathSeparator;
|
||||
final name = validProfile(env[profileEnv]);
|
||||
if (name == null) return AppDirs(profile: null, support: support, hive: null, logs: '$support${sep}logs');
|
||||
final root = '$support${sep}profiles$sep$name';
|
||||
return AppDirs(profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs');
|
||||
}
|
||||
|
||||
/// [raw] when it is a usable profile name, else null.
|
||||
static String? validProfile(String? raw) {
|
||||
final v = raw?.trim() ?? '';
|
||||
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
|
||||
}
|
||||
|
||||
/// The env value when it is set but not a usable name (for a warning).
|
||||
static String? invalidProfile(Map<String, String> env) {
|
||||
final raw = env[profileEnv];
|
||||
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
|
||||
}
|
||||
|
||||
/// Resolves the layout from the platform and the process environment.
|
||||
/// Needs Flutter's binding (path_provider).
|
||||
static Future<AppDirs> resolve() async {
|
||||
final support = (await getApplicationSupportDirectory()).path;
|
||||
final dirs = fromEnv(support: support, env: Platform.environment);
|
||||
final hive = dirs.hive;
|
||||
if (hive != null) await Directory(hive).create(recursive: true);
|
||||
return dirs;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||
import 'app_dirs.dart';
|
||||
import '../state/session_state.dart';
|
||||
|
||||
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
|
||||
@@ -17,13 +17,11 @@ final _appLog = createLogger('app');
|
||||
/// The logs folder, once [startAppLogging] ran.
|
||||
String? appLogDir;
|
||||
|
||||
/// Opens the log files and routes `debugPrint` and uncaught errors into
|
||||
/// the log. Needs Flutter's binding (path_provider).
|
||||
Future<void> startAppLogging() async {
|
||||
/// Opens the log files in [dirs]' log folder and routes `debugPrint` and
|
||||
/// uncaught errors into the log.
|
||||
Future<void> startAppLogging(AppDirs dirs) async {
|
||||
try {
|
||||
final base = await getApplicationSupportDirectory();
|
||||
final dir = '${base.path}${Platform.pathSeparator}logs';
|
||||
if (startLogFiles(dir) != null) appLogDir = dir;
|
||||
if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
|
||||
} catch (e) {
|
||||
_appLog.warning('no log files: $e');
|
||||
}
|
||||
@@ -39,7 +37,10 @@ Future<void> startAppLogging() async {
|
||||
return false;
|
||||
};
|
||||
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
|
||||
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}');
|
||||
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
|
||||
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
|
||||
final bad = AppDirs.invalidProfile(Platform.environment);
|
||||
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
|
||||
}
|
||||
|
||||
/// The text "Copy diagnostics" gives: versions, then the last [since] of
|
||||
|
||||
@@ -196,23 +196,30 @@ class Connection {
|
||||
final Duration reconnectDelay;
|
||||
/// No state broadcast for this long means the connection is lost.
|
||||
final Duration silenceLimit;
|
||||
/// The clock [silenceLimit] is measured on (UTC; a test passes a fake
|
||||
/// one). The heartbeat that checks it stays a real 1 s timer.
|
||||
final DateTime Function() now;
|
||||
|
||||
Connection(
|
||||
{void Function(AppEvent event)? notify,
|
||||
SyncClient? client,
|
||||
this.hello = defaultHello,
|
||||
this.reconnectDelay = const Duration(milliseconds: 1500),
|
||||
this.silenceLimit = const Duration(seconds: 5)})
|
||||
this.silenceLimit = const Duration(seconds: 5),
|
||||
DateTime Function()? now})
|
||||
: _notify = notify ?? ((_) {}),
|
||||
now = now ?? _utcNow,
|
||||
client = client ?? SyncClient() {
|
||||
_subs = [
|
||||
this.client.status.listen(_onStatus),
|
||||
this.client.states.listen((_) => _lastStateAt = DateTime.now().toUtc()),
|
||||
this.client.states.listen((_) => _lastStateAt = this.now()),
|
||||
];
|
||||
}
|
||||
|
||||
late final List<StreamSubscription> _subs;
|
||||
|
||||
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||
|
||||
final _state = signal<ConnState>(const ConnClosed());
|
||||
/// Every change is notified, a repeat too.
|
||||
ReadonlySignal<ConnState> get state => _state;
|
||||
@@ -368,7 +375,7 @@ class Connection {
|
||||
_notify(const ConnectionBack());
|
||||
}
|
||||
_resumeToken = client.token;
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
_lastStateAt = now();
|
||||
_set(const ConnOnline());
|
||||
_startHeartbeat();
|
||||
}
|
||||
@@ -400,13 +407,13 @@ class Connection {
|
||||
/// Every second while connected: a clock sample, the diagnostics, and
|
||||
/// the silence check.
|
||||
void _beat() {
|
||||
final now = DateTime.now().toUtc();
|
||||
final t = now();
|
||||
if (client.token.isNotEmpty) client.sendPing();
|
||||
diag
|
||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||
..tick();
|
||||
final last = _lastStateAt;
|
||||
if (!_closed && last != null && now.difference(last) >= silenceLimit) {
|
||||
if (!_closed && last != null && t.difference(last) >= silenceLimit) {
|
||||
_silence.add(null);
|
||||
if (!_state.peek().retrying) _dropped();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user