Updater v1 --upgrade + roadmap as flat backlog
Updater: watch-together --upgrade checks updates/manifest.json, downloads the tarball, verifies sha256, applies over the live install (extract-temp + delete-first move-over; rename-safe on Linux while running). Zero Flutter imports, 7 tests incl. stub-server flows. Version stamped via --dart-define=APP_VERSION in package script. Warns (never silently jumps) on proto mismatch. Roadmap: phases retired as tracker (history in git log), now flat Built/Backlog/Epics/Deferred; Jellyfin next feature, transcode never; updater expansion stays an epic. Endpoint publishing (manifest + release tarball) is user-owned.
This commit is contained in:
1 parent
b2173e82c8
commit
d77b0e804d
9 files changed
+505
-103
No files matched your search
@@ -8,8 +8,13 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/update/updater.dart';
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
if (args.contains('--upgrade')) {
|
||||
exitCode = await runUpgrade();
|
||||
exit(exitCode);
|
||||
}
|
||||
initLogging();
|
||||
if (args.contains('--headless')) {
|
||||
await runHeadless(args);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// Zero Flutter imports: this file must stay unit-testable with plain
|
||||
// `flutter test` (no widgets, no media_kit). Linux-first v1: a CLI flag,
|
||||
// `watch-together --upgrade`, that checks the manifest, downloads the
|
||||
// tarball, verifies sha256, and extracts it over the live install.
|
||||
// Linux allows replacing (unlink/rename) files under a running process —
|
||||
// only opening the running binary for writing is refused (ETXTBSY) — so
|
||||
// apply is extract-to-temp then delete-first move-over, and the running
|
||||
// (old) image just prints the result and exits. Next launch is new.
|
||||
|
||||
const updateManifestUrl =
|
||||
'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json';
|
||||
|
||||
const appVersion = String.fromEnvironment('APP_VERSION',
|
||||
defaultValue: '0.0.0-dev');
|
||||
|
||||
class UpdateManifest {
|
||||
final String version;
|
||||
final int protocol;
|
||||
final String url;
|
||||
final String sha256;
|
||||
final String notes;
|
||||
const UpdateManifest(
|
||||
{required this.version,
|
||||
required this.protocol,
|
||||
required this.url,
|
||||
required this.sha256,
|
||||
required this.notes});
|
||||
|
||||
factory UpdateManifest.fromJson(Map<String, Object?> json) {
|
||||
final version = json['version'];
|
||||
final protocol = json['protocol'];
|
||||
final url = json['url'];
|
||||
final sha = json['sha256'];
|
||||
if (version is! String ||
|
||||
version.isEmpty ||
|
||||
protocol is! int ||
|
||||
url is! String ||
|
||||
url.isEmpty ||
|
||||
sha is! String ||
|
||||
sha.isEmpty) {
|
||||
throw const FormatException('update manifest missing version/protocol/url/sha256');
|
||||
}
|
||||
return UpdateManifest(
|
||||
version: version,
|
||||
protocol: protocol,
|
||||
url: url,
|
||||
sha256: sha,
|
||||
notes: (json['notes'] as String?) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
List<int> parseVersion(String v) {
|
||||
final out = <int>[];
|
||||
for (final part in v.split('.')) {
|
||||
final m = RegExp(r'\d+').firstMatch(part);
|
||||
if (m == null) break;
|
||||
out.add(int.parse(m.group(0)!));
|
||||
if (out.length == 3) break;
|
||||
}
|
||||
while (out.length < 3) {
|
||||
out.add(0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool isNewerVersion(String current, String latest) {
|
||||
final c = parseVersion(current);
|
||||
final l = parseVersion(latest);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (l[i] != c[i]) return l[i] > c[i];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<UpdateManifest> fetchManifest(
|
||||
{HttpClient? http, String url = updateManifestUrl}) async {
|
||||
final client = http ?? HttpClient();
|
||||
try {
|
||||
final req = await client
|
||||
.getUrl(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final res = await req.close();
|
||||
final body = await res.transform(utf8.decoder).join();
|
||||
if (res.statusCode != 200) {
|
||||
throw HttpException('manifest fetch failed: HTTP ${res.statusCode}');
|
||||
}
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('manifest is not a JSON object');
|
||||
}
|
||||
return UpdateManifest.fromJson(decoded);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> downloadFile(String url, File dest, {HttpClient? http}) async {
|
||||
final client = http ?? HttpClient();
|
||||
try {
|
||||
final req = await client
|
||||
.getUrl(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final res = await req.close();
|
||||
if (res.statusCode != 200) {
|
||||
throw HttpException('download failed: HTTP ${res.statusCode}');
|
||||
}
|
||||
final sink = dest.openWrite();
|
||||
try {
|
||||
await res.pipe(sink);
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifySha256(File file, String want) async {
|
||||
final digest = await sha256.bind(file.openRead()).first;
|
||||
if (digest.toString() != want.trim().toLowerCase()) {
|
||||
throw const FormatException('sha256 mismatch — download is corrupt');
|
||||
}
|
||||
}
|
||||
|
||||
// Release bundle layout is <root>/<binary> with <root>/data and <root>/lib
|
||||
// siblings. Anything else (dev runs, dart run) is not a packaged install.
|
||||
Directory? findInstallRoot(String executablePath) {
|
||||
final dir = File(executablePath).parent;
|
||||
if (Directory('${dir.path}/data').existsSync() &&
|
||||
Directory('${dir.path}/lib').existsSync()) {
|
||||
return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract to temp, then move every file over delete-first: opening the
|
||||
// running executable for writing throws ETXTBSY, delete+rename does not.
|
||||
Future<void> applyTarball(File tarball, Directory root) async {
|
||||
final raw = await tarball.readAsBytes();
|
||||
final archive = TarDecoder().decodeBytes(GZipDecoder().decodeBytes(raw));
|
||||
final stage =
|
||||
await Directory.systemTemp.createTemp('watch-together-update');
|
||||
try {
|
||||
for (final entry in archive) {
|
||||
final name = entry.name;
|
||||
if (name.isEmpty || name.startsWith('/') || name.contains('..')) {
|
||||
throw FormatException('unsafe tar entry: $name');
|
||||
}
|
||||
final target = File('${stage.path}/$name');
|
||||
if (entry.isDirectory) {
|
||||
await Directory(target.path).create(recursive: true);
|
||||
} else if (entry.isFile) {
|
||||
await target.parent.create(recursive: true);
|
||||
await target.writeAsBytes(entry.content as List<int>);
|
||||
} else {
|
||||
throw FormatException('unsupported tar entry: $name');
|
||||
}
|
||||
}
|
||||
await for (final entity
|
||||
in stage.list(recursive: true, followLinks: false)) {
|
||||
if (entity is! File) continue;
|
||||
final rel = entity.path.substring(stage.path.length + 1);
|
||||
final dest = File('${root.path}/$rel');
|
||||
await dest.parent.create(recursive: true);
|
||||
if (await dest.exists() || await Link(dest.path).exists()) {
|
||||
await dest.delete();
|
||||
}
|
||||
await entity.rename(dest.path);
|
||||
}
|
||||
} finally {
|
||||
if (await stage.exists()) {
|
||||
await stage.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a process exit code: 0 ok / already current, 1 failure,
|
||||
// 2 not a packaged install (update manually).
|
||||
Future<int> runUpgrade(
|
||||
{String currentVersion = appVersion,
|
||||
String manifestUrl = updateManifestUrl,
|
||||
HttpClient? http,
|
||||
String? executablePath,
|
||||
Directory? installRoot}) async {
|
||||
stdout.writeln('watch-together $currentVersion (proto ${RelayConstants.protoVersion})');
|
||||
late final UpdateManifest manifest;
|
||||
try {
|
||||
manifest = await fetchManifest(http: http, url: manifestUrl);
|
||||
} catch (e) {
|
||||
stdout.writeln('update check failed: $e');
|
||||
return 1;
|
||||
}
|
||||
if (!isNewerVersion(currentVersion, manifest.version)) {
|
||||
stdout.writeln('already up to date (${manifest.version}).');
|
||||
return 0;
|
||||
}
|
||||
stdout.writeln('update available: ${manifest.version} (proto ${manifest.protocol})');
|
||||
if (manifest.notes.isNotEmpty) {
|
||||
stdout.writeln(manifest.notes);
|
||||
}
|
||||
if (manifest.protocol != RelayConstants.protoVersion) {
|
||||
stdout.writeln('WARNING: protocol ${manifest.protocol} != ${RelayConstants.protoVersion} — '
|
||||
'update together with everyone in the room, mixed versions cannot sync.');
|
||||
}
|
||||
final root = installRoot ??
|
||||
findInstallRoot(executablePath ?? Platform.resolvedExecutable);
|
||||
if (root == null) {
|
||||
stdout.writeln('not a packaged install — download the tarball and update manually.');
|
||||
return 2;
|
||||
}
|
||||
final tmp = await Directory.systemTemp.createTemp('watch-together-dl');
|
||||
try {
|
||||
final file = File('${tmp.path}/update.tar.gz');
|
||||
stdout.writeln('downloading ${manifest.url} ...');
|
||||
await downloadFile(manifest.url, file, http: http);
|
||||
await verifySha256(file, manifest.sha256);
|
||||
stdout.writeln('verified, applying over ${root.path} ...');
|
||||
await applyTarball(file, root);
|
||||
} catch (e) {
|
||||
stdout.writeln('update failed: $e');
|
||||
return 1;
|
||||
} finally {
|
||||
if (await tmp.exists()) {
|
||||
await tmp.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
stdout.writeln('updated to ${manifest.version} — restart the app to use it.');
|
||||
return 0;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
@@ -90,7 +90,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.5+5"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
||||
@@ -23,6 +23,8 @@ dependencies:
|
||||
hive_ce_flutter: ^2.3.4
|
||||
image: ^4.9.2
|
||||
window_manager: ^0.5.2
|
||||
archive: ^4.0.7
|
||||
crypto: ^3.0.6
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/update/updater.dart';
|
||||
|
||||
void main() {
|
||||
test('version compare', () {
|
||||
expect(isNewerVersion('0.1.0', '0.1.1'), isTrue);
|
||||
expect(isNewerVersion('0.1.0', '0.2.0'), isTrue);
|
||||
expect(isNewerVersion('0.1.0', '1.0.0'), isTrue);
|
||||
expect(isNewerVersion('0.0.0-dev', '0.1.0'), isTrue);
|
||||
expect(isNewerVersion('0.1.0', '0.1.0'), isFalse);
|
||||
expect(isNewerVersion('0.2.0', '0.1.9'), isFalse);
|
||||
});
|
||||
|
||||
test('manifest rejects missing fields', () {
|
||||
expect(() => UpdateManifest.fromJson({}), throwsFormatException);
|
||||
expect(
|
||||
() => UpdateManifest.fromJson({
|
||||
'version': '0.2.0',
|
||||
'protocol': 4,
|
||||
'url': 'https://x/y.tar.gz',
|
||||
}),
|
||||
throwsFormatException);
|
||||
});
|
||||
|
||||
test('findInstallRoot honors bundle layout', () async {
|
||||
final tmp = await Directory.systemTemp.createTemp('wt-root-test');
|
||||
try {
|
||||
final root = Directory('${tmp.path}/root');
|
||||
await Directory('${root.path}/data').create(recursive: true);
|
||||
await Directory('${root.path}/lib').create(recursive: true);
|
||||
final exe = File('${root.path}/watch_together')..createSync();
|
||||
expect(findInstallRoot(exe.path)?.path, root.path);
|
||||
expect(findInstallRoot('${tmp.path}/stray-binary'), isNull);
|
||||
} finally {
|
||||
await tmp.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('upgrade flow', () {
|
||||
late HttpServer server;
|
||||
late Directory work;
|
||||
late List<int> tarball;
|
||||
late String tarSha;
|
||||
var tarHits = 0;
|
||||
|
||||
setUp(() async {
|
||||
tarHits = 0;
|
||||
work = await Directory.systemTemp.createTemp('wt-up-test');
|
||||
final archive = Archive();
|
||||
final v2 = utf8.encode('binary-v2');
|
||||
archive.addFile(ArchiveFile('watch_together', v2.length, v2));
|
||||
final cfg = utf8.encode('fresh-config');
|
||||
archive.addFile(ArchiveFile('data/fresh.cfg', cfg.length, cfg));
|
||||
tarball = GZipEncoder().encode(TarEncoder().encode(archive));
|
||||
tarSha = sha256.convert(tarball).toString();
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((req) async {
|
||||
if (req.uri.path == '/manifest.json') {
|
||||
final manifest = {
|
||||
'version': '0.2.0',
|
||||
'protocol': 4,
|
||||
'url':
|
||||
'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz',
|
||||
'sha256': tarSha,
|
||||
'notes': 'test build',
|
||||
};
|
||||
req.response
|
||||
..statusCode = 200
|
||||
..write(jsonEncode(manifest));
|
||||
} else if (req.uri.path == '/watch-together-0.2.0.tar.gz') {
|
||||
tarHits++;
|
||||
req.response
|
||||
..statusCode = 200
|
||||
..add(tarball);
|
||||
} else {
|
||||
req.response.statusCode = 404;
|
||||
}
|
||||
await req.response.close();
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close(force: true);
|
||||
if (await work.exists()) {
|
||||
await work.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
Future<Directory> fakeRoot() async {
|
||||
final root = Directory('${work.path}/root');
|
||||
await Directory('${root.path}/data').create(recursive: true);
|
||||
await Directory('${root.path}/lib').create(recursive: true);
|
||||
await File('${root.path}/watch_together')
|
||||
.writeAsString('binary-v1', flush: true);
|
||||
return root;
|
||||
}
|
||||
|
||||
String manifestUrl() =>
|
||||
'http://127.0.0.1:${server.port}/manifest.json';
|
||||
|
||||
test('downloads, verifies and applies', () async {
|
||||
final root = await fakeRoot();
|
||||
final rc = await runUpgrade(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: manifestUrl(),
|
||||
installRoot: root);
|
||||
expect(rc, 0);
|
||||
expect(tarHits, 1);
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v2');
|
||||
expect(await File('${root.path}/data/fresh.cfg').readAsString(),
|
||||
'fresh-config');
|
||||
});
|
||||
|
||||
test('up to date downloads nothing', () async {
|
||||
final root = await fakeRoot();
|
||||
final rc = await runUpgrade(
|
||||
currentVersion: '0.2.0',
|
||||
manifestUrl: manifestUrl(),
|
||||
installRoot: root);
|
||||
expect(rc, 0);
|
||||
expect(tarHits, 0);
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v1');
|
||||
});
|
||||
|
||||
test('bad hash aborts, install untouched', () async {
|
||||
await server.close(force: true);
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((req) async {
|
||||
if (req.uri.path == '/manifest.json') {
|
||||
req.response
|
||||
..statusCode = 200
|
||||
..write(jsonEncode({
|
||||
'version': '0.2.0',
|
||||
'protocol': 4,
|
||||
'url':
|
||||
'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz',
|
||||
'sha256': '0' * 64,
|
||||
'notes': '',
|
||||
}));
|
||||
} else {
|
||||
tarHits++;
|
||||
req.response
|
||||
..statusCode = 200
|
||||
..add(tarball);
|
||||
}
|
||||
await req.response.close();
|
||||
});
|
||||
final root = await fakeRoot();
|
||||
final rc = await runUpgrade(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: manifestUrl(),
|
||||
installRoot: root);
|
||||
expect(rc, 1);
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v1');
|
||||
});
|
||||
|
||||
test('unknown layout asks for manual update', () async {
|
||||
final rc = await runUpgrade(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: manifestUrl(),
|
||||
executablePath: '${work.path}/stray-binary');
|
||||
expect(rc, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user