diff --git a/.github/workflows/mf33-release.yml b/.github/workflows/mf33-release.yml index 03840ba..ea6ebf1 100644 --- a/.github/workflows/mf33-release.yml +++ b/.github/workflows/mf33-release.yml @@ -2,13 +2,15 @@ name: MF-33 verified Mac release on: push: - branches: [codex/mf-33-release] + branches: [codex/mf-33-auto-update] paths: - serverbox/** - scripts/native_acceptance.py - scripts/test_pty_native.py - scripts/package_unified_macos.py - scripts/package_mac_preview.py + - scripts/test_mac_updater.py + - scripts/test_queue_dart.py - .github/workflows/mf33-release.yml - gpuqueue/** - tests/** @@ -45,6 +47,9 @@ jobs: mkdir -p "$HOME/scratch" python3 -m unittest discover -s tests -p 'test_*.py' node --test tests/cloud.test.js tests/modes.test.cjs tests/widget.test.cjs tests/widget-update.test.cjs + - name: Exercise native installer and rollback with signed disposable apps + working-directory: source + run: python3 scripts/test_mac_updater.py - name: Prepare the pinned Flutter SDK run: | git init flutter-sdk @@ -83,10 +88,10 @@ jobs: GH_TOKEN: ${{ github.token }} CANDIDATE_SHA: ${{ github.sha }} run: | - tag=v2.0.2003 + tag=v2.0.2004 gh release create "$tag" build/unified-release/* \ --repo Ferryyer/gpu-queue --target "$CANDIDATE_SHA" \ - --latest --title 'GPU Queue 2.0.2003 · GPU 申请修复版' \ + --latest --title 'GPU Queue 2.0.2004 · 应用内自动安装更新' \ --notes-file build/unified-release/RELEASE-NOTES.md - name: Upload native evidence and unsigned QA app if: always() @@ -96,6 +101,7 @@ jobs: path: | source/build/native-acceptance/ source/build/unified-release/ + source/build/mac-updater-tests/ if-no-files-found: warn retention-days: 7 - name: Save native compiler cache even if app compilation fails diff --git a/docs/MAC-AUTO-UPDATE.md b/docs/MAC-AUTO-UPDATE.md new file mode 100644 index 0000000..a2ef16f --- /dev/null +++ b/docs/MAC-AUTO-UPDATE.md @@ -0,0 +1,11 @@ +应用内安装从 2.0.2004 起提供。2.0.2003 及更早版本仍须手动下载并覆盖一次,以获得安装器。 + +检查更新后点击“下载并安装”,应用显示下载和校验进度,再退出、替换并重启。更新会断开本地 SSH 终端连接,远端任务继续运行。安装器不修改用户配置、钥匙串或服务器代码。 + +安装前验证正式 GitHub tag 的精确版本资产、字节数和 SHA-256,原生进程再次校验摘要、DMG、bundle ID、递增 build、CPU 架构及代码签名。只有准备好同目录暂存副本后才要求应用退出。不能写入当前应用目录时保留原应用,并提示迁移至可写的 Applications 文件夹;不使用 sudo,也不改动 Gatekeeper 设置。 + +独立的 `Contents/Helpers/GPUQueueUpdater` 等待旧进程退出,将原应用移至同目录的 `GPU Queue.previous--.app`,放入新版并启动。新版首次渲染后通过原生通道确认启动。45 秒内无确认则停止本次启动的新进程、恢复备份并重开旧版。退出超时或取消不会替换应用。突然断电、安装器被外部终止等情况可能需要从备份手动恢复。 + +状态和日志位于用户的 `Library/Application Support/GPU Queue/Updater` 中。失败后可通过设置重新检查更新;如已回滚,下一次打开会提示结果。旧版备份保留供用户自行清理。 + +`scripts/test_mac_updater.py` 必须在真实 Mac 前台会话中运行。它编译生产安装器,构造并签名临时 app/DMG,验证正常更新重启、错误摘要、错误 bundle ID、退出超时、启动回滚和取消。发布流程只有在六项测试及完整原生构建通过后才打包发布,相关报告随 Release 提供。测试使用隔离的临时应用,不访问真实应用、SSH 或钥匙串。 diff --git a/scripts/native_acceptance.py b/scripts/native_acceptance.py index c6c37eb..95f8652 100644 --- a/scripts/native_acceptance.py +++ b/scripts/native_acceptance.py @@ -222,13 +222,17 @@ def preflight(args, report): def test_commands(args): return [ + ([args.flutter, 'analyze', '--no-pub', '--no-fatal-infos', '--no-fatal-warnings', + 'lib/core/update', 'lib/main.dart', 'packages/fl_lib/lib/src/core/update.dart', + 'test/unit/app/mac_update_release_test.dart'], APP), ([sys.executable, 'scripts/test_pty_native.py', '--flutter', args.flutter], ROOT), (['swiftc', '-swift-version', '5', 'scripts/macos-reconnect/TunnelRecovery.swift', '-o', str(args.out.resolve() / 'TunnelRecovery')], APP), ([sys.executable, '-m', 'unittest', 'discover', '-s', 'scripts/macos-reconnect', '-v'], APP), ([args.flutter, 'test', '--no-pub', 'test/unit/app/queue_protocol_test.dart', 'test/unit/app/queue_session_test.dart', 'test/unit/app/queue_presentation_test.dart', - 'test/unit/app/queue_resource_widget_test.dart', 'test/widget/terminal_reconnect_test.dart'], APP), + 'test/unit/app/queue_resource_widget_test.dart', 'test/unit/app/mac_update_release_test.dart', + 'test/widget/terminal_reconnect_test.dart'], APP), ] diff --git a/scripts/package_mac_preview.py b/scripts/package_mac_preview.py index ea0f902..72d5a80 100755 --- a/scripts/package_mac_preview.py +++ b/scripts/package_mac_preview.py @@ -22,6 +22,17 @@ def main(): tag = 'v'+version if not report.get('macBuildVerified') or report.get('sourceCommit') != commit: raise RuntimeError('Release requires native tests and build evidence for this exact commit') + installer_report = json.loads((ROOT / 'build/mac-updater-tests/report.json').read_text()) + scenarios = {item['scenario'] for item in installer_report.get('scenarios', []) if item.get('passed')} + if not installer_report.get('native') or scenarios != { + 'success', 'checksum_mismatch', 'wrong_bundle', 'quit_timeout', 'rollback', 'cancelled'}: + raise RuntimeError('Release requires all real macOS installer scenarios to pass') + helpers = app / 'Contents/Helpers' + helpers.mkdir(parents=True, exist_ok=True) + helper = helpers / 'GPUQueueUpdater' + subprocess.run(['swiftc', '-swift-version', '5', str(ROOT/'serverbox/macos/Updater/main.swift'), + '-o', str(helper)], check=True) + subprocess.run(['codesign', '--force', '--sign', '-', '--timestamp=none', str(helper)], check=True) subprocess.run(['codesign', '--force', '--deep', '--sign', '-', '--timestamp=none', str(app)], check=True) subprocess.run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True) out = ROOT / 'build/unified-release' @@ -33,18 +44,20 @@ def main(): metadata.update(releaseTag=tag, channel='release', sourceCommit=commit, architecture=arch, minimumMacOS='13.0', signature='ad-hoc', notarized=False, launchSmokePassed=False, checksSkippedByUser=False, - interactiveSshSftpVerified=False, installationVerified=False) + interactiveSshSftpVerified=False, installationVerified=False, + inAppInstaller=True, installerNativeScenariosVerified=True) metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n') (out / 'native-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') + (out / 'installer-report.json').write_text(json.dumps(installer_report, ensure_ascii=False, indent=2) + '\n') (out / 'RELEASE-NOTES.md').write_text(f'''GPU Queue Mac 发布版,版本 {version} ({build}),适用 Apple Silicon、macOS 13+。 -源码:`{commit}`,分支 `codex/mf-33-release`。基于 2.0.2002 统一桌面应用,合入已合并的 PR #6。 +源码:`{commit}`,分支 `codex/mf-33-auto-update`。基于 2.0.2003 GPU 申请修复版。 -更新:旧 GPU 只读查询超时后,按个人偏好为已启动但未投放的任务补申请一次;重启不重复申请。修复 GPU 内部查询超时识别、旧通知重复触发探测和旧卡通知错误阻塞补申请。申请提交回执不明时仍保留原 UUID 核查;网页和原生任务详情显示真实阶段与申请编号。服务器适配器已在个人服务更新,安装客户端不会替换服务器代码。 +更新:加入应用内“下载并安装”流程,显示下载进度,核对官方发布资产的大小及 SHA-256,并验证应用标识、版本、架构和代码签名。安装准备好后退出旧应用,保留同目录旧版备份,替换并重启;新版启动未确认时自动恢复上一版。SSH 配置、钥匙串和用户数据不参与替换。远端任务继续运行,本地终端连接会随应用重启断开。 -下载 `GPU-Queue-macos-arm64-{build}-unsigned.dmg`,核对 SHA256SUMS 后挂载,退出旧应用并拖入 Applications。保留 SSH 配置和钥匙串。此包使用 ad-hoc 签名,未做 Apple Developer ID 签名或公证;如系统阻止打开,核对来源后在系统设置的隐私与安全性中选择“仍要打开”。 +首次安装或从 2.0.2003 及更早版本升级:下载 `GPU-Queue-macos-arm64-{build}-unsigned.dmg`,核对 SHA256SUMS 后挂载,退出旧应用并拖入 Applications 覆盖一次。旧版没有安装器,无法自行安装本次升级;安装本版后,后续版本可在应用内完成。应用须位于可写目录,如 `~/Applications`;安装器不请求管理员权限。此包使用 ad-hoc 签名,未做 Apple Developer ID 签名或公证;如系统阻止打开,核对来源后在系统设置的隐私与安全性中选择“仍要打开”。 -发布前执行 CPU 调度回归、JavaScript 更新兼容测试、Mac 原生环境检查、PTY/Swift/Flutter 测试及完整 Xcode 构建,并验证 ad-hoc 签名,原生结果见 native-report.json。真实服务器 SSH/SFTP、安装覆盖、休眠、字体和全屏的人工作业验收尚未在本轮完成;详见 docs/MAC-NATIVE-HANDOFF.md。保留旧 Widget 更新入口。若需回退,退出新版并恢复此前备份的应用。 +发布前执行 CPU/JavaScript 回归、Dart 元数据校验、Mac 原生环境检查、PTY/Swift/Flutter 测试及完整 Xcode 构建,并验证 ad-hoc 签名。真实 macOS 临时应用覆盖测试包含安装重启、摘要错误、包标识错误、退出超时、启动失败回滚和取消六种场景,结果见 installer-report.json。完整应用的人工作业验收仍见 docs/MAC-NATIVE-HANDOFF.md。保留旧 Widget 更新入口。 ''', encoding='utf-8') # Every published file except the checksum manifest is covered. (out / 'SHA256SUMS').write_text(''.join( diff --git a/scripts/test_mac_updater.py b/scripts/test_mac_updater.py new file mode 100644 index 0000000..f45ca0d --- /dev/null +++ b/scripts/test_mac_updater.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Exercise the real Swift installer against disposable signed fixture apps on macOS.""" +import hashlib +import json +import os +from pathlib import Path +import plistlib +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = r''' +import Cocoa +let app = NSApplication.shared +app.setActivationPolicy(.prohibited) +let info = Bundle.main.infoDictionary! +let receipt = URL(fileURLWithPath: info["FixtureReceipt"] as! String) +let build = info["CFBundleVersion"] as! String +let value: [String: Any] = ["pid": ProcessInfo.processInfo.processIdentifier, "build": build] +try! JSONSerialization.data(withJSONObject: value).write(to: receipt, options: .atomic) +DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if info["FixtureAck"] as? Bool == true { + let status = URL(fileURLWithPath: info["FixtureStatus"] as! String) + if let data = try? Data(contentsOf: status), + var state = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + state["phase"] as? String == "awaiting_launch", state["build"] as? String == build { + state["phase"] = "complete" + try! JSONSerialization.data(withJSONObject: state).write(to: status, options: .atomic) + } + } +} +app.run() +''' + + +def run(*command): + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode: + print(result.stdout.decode(errors='replace'), file=sys.stderr) + result.check_returncode() + + +def wait_for(predicate, timeout=40): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(.05) + raise AssertionError('Fixture deadline exceeded') + + +def read(path): + try: + return json.loads(path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def main(): + if sys.platform != 'darwin': + raise RuntimeError('Native installer acceptance requires macOS; no simulated pass') + out = ROOT / 'build/mac-updater-tests' + out.mkdir(parents=True, exist_ok=True) + helper = out / 'GPUQueueUpdater' + run('swiftc', '-swift-version', '5', str(ROOT/'serverbox/macos/Updater/main.swift'), '-o', str(helper)) + results = [] + with tempfile.TemporaryDirectory(prefix='gpuq-installer-') as folder: + root = Path(folder) + source = root/'Fixture.swift' + source.write_text(FIXTURE) + fixture = root/'Fixture' + run('swiftc', '-swift-version', '5', str(source), '-o', str(fixture)) + + for scenario in ('success', 'checksum_mismatch', 'wrong_bundle', 'quit_timeout', 'rollback', 'cancelled'): + case = root/scenario + case.mkdir() + work = case/'work' + work.mkdir() + status = case/'state/status.json' + receipt = case/'receipt.json' + destination = case/'GPU Queue.app' + bundle_id = 'work.gpuqueue.updater.fixture.' + uuid.uuid4().hex + pids = set() + child = None + log = (out/f'{scenario}.log').open('wb') + try: + def make_app(path, build, ack, identifier=bundle_id): + contents = path/'Contents' + (contents/'MacOS').mkdir(parents=True) + (contents/'MacOS/Fixture').write_bytes(fixture.read_bytes()) + (contents/'MacOS/Fixture').chmod(0o755) + (contents/'Info.plist').write_bytes(plistlib.dumps({ + 'CFBundleExecutable': 'Fixture', 'CFBundleIdentifier': identifier, + 'CFBundleVersion': str(build), 'CFBundleShortVersionString': f'2.0.{build}', + 'CFBundleName': 'GPU Queue Fixture', 'CFBundlePackageType': 'APPL', + 'LSUIElement': True, 'FixtureAck': ack, 'FixtureStatus': str(status), + 'FixtureReceipt': str(receipt), + })) + run('codesign', '--force', '--sign', '-', str(path)) + + make_app(destination, 100, False) + payload = case/'payload' + make_app(payload/'GPU Queue.app', 101, scenario != 'rollback', + bundle_id if scenario != 'wrong_bundle' else 'work.gpuqueue.wrong') + dmg = work/'update.dmg' + run('hdiutil', 'create', '-srcfolder', str(payload), '-format', 'UDZO', str(dmg)) + run('open', '-n', str(destination)) + old = wait_for(lambda: read(receipt)) + pids.add(old['pid']) + # User data is deliberately outside both application bundles. + data = case/'settings.json' + data.write_text('{"ssh":"keep","token":"fixture"}') + plan = {'id': str(uuid.uuid4()), 'destination': str(destination), 'dmg': str(dmg), + 'sha256': hashlib.sha256(dmg.read_bytes()).hexdigest(), 'build': '101', + 'bundleID': bundle_id, 'pid': old['pid'], 'status': str(status), + 'quitTimeout': 1.5 if scenario == 'quit_timeout' else 15., 'launchTimeout': 3.} + if scenario == 'checksum_mismatch': + plan['sha256'] = '0' * 64 + if scenario == 'cancelled': + (work/'cancel').touch() + planfile = work/'plan.json' + planfile.write_text(json.dumps(plan)) + child = subprocess.Popen([str(helper), str(planfile)], stdout=log, stderr=log) + if scenario in ('success', 'rollback', 'quit_timeout'): + wait_for(lambda: read(status).get('phase') in ('ready', 'failed')) + assert read(status)['phase'] == 'ready', read(status) + if scenario in ('success', 'rollback'): + os.kill(old['pid'], signal.SIGTERM) + code = child.wait(timeout=55) + state = read(status) + installed = plistlib.loads((destination/'Contents/Info.plist').read_bytes()) + if scenario == 'success': + assert code == 0 and state['phase'] == 'complete', state + assert installed['CFBundleVersion'] == '101' + assert Path(state['backup']).is_dir() + assert read(receipt)['build'] == '101' + elif scenario == 'rollback': + assert code == 0 and state['phase'] == 'rolled_back', state + assert installed['CFBundleVersion'] == '100' + wait_for(lambda: read(receipt).get('build') == '100' and read(receipt).get('pid') != old['pid']) + else: + assert code != 0 and state['phase'] == 'failed', state + assert installed['CFBundleVersion'] == '100' + os.kill(old['pid'], 0) # A failed preflight never terminates the old application. + assert data.read_text() == '{"ssh":"keep","token":"fixture"}' + results.append({'scenario': scenario, 'passed': True}) + print(f'PASS {scenario}', flush=True) + except Exception: + log.flush() + print((out/f'{scenario}.log').read_text(errors='replace'), file=sys.stderr) + raise + finally: + if child and child.poll() is None: + child.terminate() + child.wait(timeout=10) + if read(receipt).get('pid'): + pids.add(read(receipt)['pid']) + for pid in pids: + try: + # Only a fixture PID whose executable remains inside this test directory. + command = subprocess.check_output(['ps', '-p', str(pid), '-o', 'comm='], text=True).strip() + if str(case) in command: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, subprocess.CalledProcessError): + pass + if (work/'volume').is_mount(): + run('hdiutil', 'detach', '-force', str(work/'volume')) + log.close() + (out/'report.json').write_text(json.dumps({'native': True, 'scenarios': results}, indent=2)+'\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/test_queue_dart.py b/scripts/test_queue_dart.py index a6168a9..f1a8c8b 100644 --- a/scripts/test_queue_dart.py +++ b/scripts/test_queue_dart.py @@ -20,7 +20,7 @@ def main(): (harness/'test').mkdir() ui = ' material_ui: 1.5.0\n cupertino_ui: 1.1.1\n' if args.widgets else '' (harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\n"+ui+"dev_dependencies:\n flutter_test:\n sdk: flutter\nflutter:\n uses-material-design: true\n") - names = ['queue_protocol_test.dart', 'queue_session_test.dart', 'queue_presentation_test.dart'] + names = ['queue_protocol_test.dart', 'queue_session_test.dart', 'queue_presentation_test.dart', 'mac_update_release_test.dart'] if args.widgets: names += ['queue_resource_widget_test.dart', 'queue_workspace_widget_test.dart'] for name in names: diff --git a/serverbox/lib/core/update/mac_update.dart b/serverbox/lib/core/update/mac_update.dart new file mode 100644 index 0000000..11bec46 --- /dev/null +++ b/serverbox/lib/core/update/mac_update.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:server_box/core/update/release.dart'; + +abstract final class MacAutoUpdate { + static const _channel = MethodChannel('work.gpuqueue/update'); + static bool _active = false; + + static void configure() { + if (!Platform.isMacOS) return; + AppUpdateIface.installer = install; + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + await _channel.invokeMethod('confirmLaunch'); + final status = await _channel.invokeMapMethod('lastResult'); + if (status == null) return; + Toast.show(status['phase'] == 'complete' + ? '已完成应用更新' + : '更新未完成,已恢复上一版。${status['message'] ?? ''}'); + } catch (error) { + Loggers.app.warning('Update result unavailable', error); + } + }); + } + + static Future _metadata(HttpClient client, Uri uri) async { + final req = await client.getUrl(uri).timeout(const Duration(seconds: 30)); + req.headers.set(HttpHeaders.acceptHeader, 'application/vnd.github+json'); + final response = await req.close().timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) throw HttpException('更新服务返回 ${response.statusCode}'); + final bytes = []; + await for (final part in response.timeout(const Duration(seconds: 30))) { + bytes.addAll(part); + if (bytes.length > 4 * 1024 * 1024) throw const FormatException('更新信息过大'); + } + return utf8.decode(bytes); + } + + static Future install(BuildContext context, String url, int build) async { + if (_active) return; + _active = true; + final progress = ValueNotifier<(String, double?)>(('正在读取更新信息…', null)); + final client = HttpClient()..userAgent = 'GPU-Queue-Updater'..connectionTimeout = const Duration(seconds: 30); + Directory? work; + var handedOff = false; + var nativeStarted = false; + var dialogOpen = true; + unawaited(context.showRoundDialog( + title: '下载并安装更新', barrierDismiss: false, + child: ValueListenableBuilder<(String, double?)>( + valueListenable: progress, + builder: (_, value, _) => SizedBox(width: 360, child: Column(mainAxisSize: MainAxisSize.min, children: [ + LinearProgressIndicator(value: value.$2), const SizedBox(height: 16), + Text(value.$1), const SizedBox(height: 8), + const Text('准备完成后会退出并重启应用,远端任务继续运行。'), + ])), + ), + ).whenComplete(() => dialogOpen = false)); + try { + final release = MacUpdateRelease.parse(await _metadata(client, MacUpdateRelease.apiFor(url, build)), url, build); + work = await Directory.systemTemp.createTemp('gpuq-update-'); + final file = File('${work.path}/update.dmg'); + final req = await client.getUrl(release.url).timeout(const Duration(seconds: 30)); + final response = await req.close().timeout(const Duration(seconds: 30)); + if (response.statusCode != 200) throw HttpException('安装包下载返回 ${response.statusCode}'); + final sink = file.openWrite(); + var count = 0; + try { + await for (final part in response.timeout(const Duration(seconds: 45))) { + count += part.length; + if (count > release.bytes) throw const FormatException('安装包超过发布大小'); + sink.add(part); + progress.value = ('正在下载 ${(count / release.bytes * 100).floor()}%', count / release.bytes); + } + } finally { await sink.close(); } + if (count != release.bytes) throw const FormatException('安装包下载不完整,请重试'); + progress.value = ('正在校验安装包…', null); + final digest = await sha256.bind(file.openRead()).first; + if (digest.toString() != release.sha256) throw const FormatException('安装包 SHA-256 校验失败'); + progress.value = ('正在检查并准备安装…', null); + nativeStarted = true; + await _channel.invokeMethod('install', { + 'dmg': file.path, 'sha256': release.sha256, 'build': build, + }); + handedOff = true; + progress.value = ('准备完成,正在重启应用…', null); + // Normally the process exits in half a second. If a close/save prompt + // prevents quitting, surface the helper's timeout instead of a stuck dialog. + final quitDeadline = DateTime.now().add(const Duration(seconds: 75)); + while (DateTime.now().isBefore(quitDeadline)) { + await Future.delayed(const Duration(seconds: 1)); + final status = await _channel.invokeMapMethod('installStatus'); + if (status?['phase'] == 'failed') throw StateError(status?['message']?.toString() ?? '安装未完成'); + } + await _channel.invokeMethod('cancelInstall'); + throw StateError('应用未能退出,已取消更新。请关闭待保存窗口后重试。'); + } catch (error) { + handedOff = false; + if (context.mounted && dialogOpen) { context.popDialog(); dialogOpen = false; } + if (context.mounted) { + await context.showRoundDialog(title: '更新未完成', child: Text('$error\n当前应用仍保留,可重新检查更新后重试。'), actions: [Btn.ok()]); + } + } finally { + client.close(force: true); + if (!handedOff) { + // Once started, the helper owns its workspace (including slow copies + // that outlive a channel timeout). Never delete beneath that process. + if (!nativeStarted && work != null && await work.exists()) await work.delete(recursive: true); + if (context.mounted && dialogOpen) context.popDialog(); + // The progress dialog is removed before its notifier is disposed. + await Future.delayed(Duration.zero); + progress.dispose(); + } + if (!handedOff) _active = false; + } + } +} diff --git a/serverbox/lib/core/update/release.dart b/serverbox/lib/core/update/release.dart new file mode 100644 index 0000000..dcdd102 --- /dev/null +++ b/serverbox/lib/core/update/release.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +/// The installer accepts only this project's immutable, versioned DMG asset. +class MacUpdateRelease { + final Uri url; + final int build; + final int bytes; + final String sha256; + final String version; + + const MacUpdateRelease(this.url, this.build, this.bytes, this.sha256, this.version); + + static Uri apiFor(String raw, int build) { + final uri = Uri.parse(raw); + final match = RegExp(r'^/Ferryyer/gpu-queue/releases/download/(v(2\.0\.(\d+)))/GPU-Queue-macos-(arm64|x86_64)-(\d+)-unsigned\.dmg$').firstMatch(uri.path); + if (raw != 'https://github.com${uri.path}' || uri.scheme != 'https' || uri.host != 'github.com' || uri.hasPort || + uri.userInfo.isNotEmpty || uri.hasQuery || uri.hasFragment || match == null || + int.parse(match.group(3)!) != build || int.parse(match.group(5)!) != build) { + throw const FormatException('更新下载地址或版本不匹配'); + } + return Uri.https('api.github.com', '/repos/Ferryyer/gpu-queue/releases/tags/${match.group(1)}'); + } + + factory MacUpdateRelease.parse(String raw, String url, int build) { + apiFor(url, build); + final data = jsonDecode(raw) as Map; + final version = '2.0.$build'; + if (data['draft'] != false || data['prerelease'] != false || data['tag_name'] != 'v$version') { + throw const FormatException('更新版本尚未正式发布'); + } + final assets = (data['assets'] as List).whereType>().where((a) => a['browser_download_url'] == url).toList(); + if (assets.length != 1) throw const FormatException('找不到唯一的更新安装包'); + final asset = assets.single; + final digest = asset['digest']?.toString() ?? ''; + final bytes = asset['size']; + if (asset['state'] != 'uploaded' || bytes is! int || bytes <= 0 || bytes > 512 * 1024 * 1024 || + !RegExp(r'^sha256:[a-f0-9]{64}$').hasMatch(digest)) { + throw const FormatException('安装包大小或 SHA-256 校验信息不完整'); + } + return MacUpdateRelease(Uri.parse(url), build, bytes, digest.substring(7), version); + } +} diff --git a/serverbox/lib/main.dart b/serverbox/lib/main.dart index 57eb74c..798bda5 100644 --- a/serverbox/lib/main.dart +++ b/serverbox/lib/main.dart @@ -22,6 +22,7 @@ import 'package:server_box/core/service/theme_host.dart'; import 'package:server_box/core/service/watch_sync.dart'; import 'package:server_box/core/service/widget_sync.dart'; import 'package:server_box/core/sync.dart'; +import 'package:server_box/core/update/mac_update.dart'; import 'package:server_box/core/utils/rootfs.dart'; import 'package:server_box/core/utils/rootfs_manifest_source.dart'; import 'package:server_box/core/utils/sandbox_import.dart'; @@ -293,6 +294,7 @@ Future _initData() async { // It may effect the following logic, so await it. // DO DB migration before load any provider. await _doDbMigrate(); + MacAutoUpdate.configure(); if (Stores.setting.betaTest.fetch()) AppUpdate.chan = AppUpdateChan.beta; diff --git a/serverbox/macos/Runner/AppDelegate.swift b/serverbox/macos/Runner/AppDelegate.swift index 919d96a..431f699 100644 --- a/serverbox/macos/Runner/AppDelegate.swift +++ b/serverbox/macos/Runner/AppDelegate.swift @@ -36,6 +36,11 @@ class AppDelegate: FlutterAppDelegate { } if let controller = mainFlutterWindow?.contentViewController as? FlutterViewController { + let updateChannel = FlutterMethodChannel( + name: "work.gpuqueue/update", binaryMessenger: controller.engine.binaryMessenger) + updateChannel.setMethodCallHandler { call, result in + GPUQueueUpdate.handle(call, result) + } let channel = FlutterMethodChannel(name: "about", binaryMessenger: controller.engine.binaryMessenger) channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in if call.method == "showAboutPanel" { @@ -129,3 +134,120 @@ class AppDelegate: FlutterAppDelegate { super.applicationDidFinishLaunching(notification) } } + +private enum GPUQueueUpdate { + static var installing = false + static var activeWork: URL? + static var statusURL: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("GPU Queue/Updater/status.json") + } + static func readState() -> [String: Any]? { + guard let data = try? Data(contentsOf: statusURL) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + static func writeState(_ value: [String: Any]) throws { + try JSONSerialization.data(withJSONObject: value).write(to: statusURL, options: .atomic) + } + static func handle(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + switch call.method { + case "installStatus": + let state = readState() + if state?["phase"] as? String == "failed" { installing = false } + result(state) + case "cancelInstall": + if let work = activeWork { try? Data().write(to: work.appendingPathComponent("cancel")) } + installing = false + result(nil) + case "confirmLaunch": + if var state = readState(), state["phase"] as? String == "awaiting_launch", + state["build"] as? String == build, + state["destination"] as? String == Bundle.main.bundleURL.standardizedFileURL.path { + state["phase"] = "complete" + try? writeState(state) + } + result(nil) + case "lastResult": + if var state = readState(), state["notified"] as? Bool != true, + ["complete", "rolled_back"].contains(state["phase"] as? String ?? "") { + state["notified"] = true + try? writeState(state) + result(state) + } else { result(nil) } + case "install": + guard !installing else { + result(FlutterError(code: "busy", message: "正在安装更新", details: nil)); return + } + guard let args = call.arguments as? [String: Any], + let dmg = args["dmg"] as? String, let hash = args["sha256"] as? String, + let nextBuild = args["build"] as? Int, + hash.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil, + nextBuild > (Int(build ?? "0") ?? 0), + let bundleID = Bundle.main.bundleIdentifier else { + result(FlutterError(code: "invalid_update", message: "无效的更新信息", details: nil)); return + } + installing = true + let destination = Bundle.main.bundleURL.standardizedFileURL + let helper = destination.appendingPathComponent("Contents/Helpers/GPUQueueUpdater") + let id = UUID().uuidString + let work = URL(fileURLWithPath: dmg).deletingLastPathComponent() + activeWork = work + let plan = work.appendingPathComponent("plan.json") + let statePath = statusURL + DispatchQueue.global(qos: .userInitiated).async { + do { + let fm = FileManager.default + try fm.createDirectory(at: statePath.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let body: [String: Any] = ["id": id, "destination": destination.path, + "dmg": dmg, "sha256": hash, "build": String(nextBuild), "bundleID": bundleID, + "pid": ProcessInfo.processInfo.processIdentifier, "status": statePath.path] + try JSONSerialization.data(withJSONObject: body).write(to: plan, options: .atomic) + let process = Process() + process.executableURL = helper + process.arguments = [plan.path] + let logURL = statePath.deletingLastPathComponent().appendingPathComponent("install.log") + if !fm.fileExists(atPath: logURL.path) { fm.createFile(atPath: logURL.path, contents: nil) } + let log = try FileHandle(forWritingTo: logURL) + try log.truncate(atOffset: 0) + process.standardOutput = log + process.standardError = log + try process.run() + // Process has inherited its log descriptor; it survives the app exiting. + try log.close() + let deadline = Date().addingTimeInterval(180) + while Date() < deadline { + if let state = readState(), state["id"] as? String == id { + if state["phase"] as? String == "ready" { + DispatchQueue.main.async { + result(nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NSApp.terminate(nil) } + } + return + } + if state["phase"] as? String == "failed" { + throw NSError(domain: "GPUQueueUpdater", code: 1, + userInfo: [NSLocalizedDescriptionKey: state["message"] as? String ?? "更新准备失败"]) + } + } + if !process.isRunning { throw NSError(domain: "GPUQueueUpdater", code: 2, + userInfo: [NSLocalizedDescriptionKey: "安装器未能启动,请查看安装日志。"] ) } + Thread.sleep(forTimeInterval: 0.1) + } + // A slow copy can finish after this deadline. Explicit cancellation + // prevents that helper from installing later when the user quits. + try Data().write(to: work.appendingPathComponent("cancel")) + throw NSError(domain: "GPUQueueUpdater", code: 3, + userInfo: [NSLocalizedDescriptionKey: "更新准备超时,应用仍在运行,请稍后重试。"]) + } catch { + DispatchQueue.main.async { + installing = false + result(FlutterError(code: "install_failed", message: error.localizedDescription, details: nil)) + } + } + } + default: result(FlutterMethodNotImplemented) + } + } +} diff --git a/serverbox/macos/Updater/main.swift b/serverbox/macos/Updater/main.swift new file mode 100644 index 0000000..8dc8f08 --- /dev/null +++ b/serverbox/macos/Updater/main.swift @@ -0,0 +1,204 @@ +import Cocoa +import CryptoKit +import Darwin + +// Runs independently of Flutter so replacing the app never depends on its lifetime. +let fm = FileManager.default +struct Failure: LocalizedError { + let message: String + var errorDescription: String? { message } +} +func fail(_ message: String) throws -> Never { throw Failure(message: message) } +func read(_ url: URL) throws -> [String: Any] { + guard let value = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any] else { + try fail("Invalid update state") + } + return value +} +func write(_ value: [String: Any], _ url: URL) throws { + try JSONSerialization.data(withJSONObject: value).write(to: url, options: .atomic) +} +func command(_ executable: String, _ args: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = args + // Do not use an undrained pipe: codesign/ditto can produce large diagnostics. + process.standardOutput = FileHandle.standardError + process.standardError = FileHandle.standardError + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { try fail("\(URL(fileURLWithPath: executable).lastPathComponent) failed (\(process.terminationStatus))") } +} +func bundleInfo(_ app: URL) throws -> [String: Any] { + let data = try Data(contentsOf: app.appendingPathComponent("Contents/Info.plist")) + guard let info = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + try fail("Invalid application bundle") + } + return info +} +func launch(_ app: URL) throws -> NSRunningApplication { + var finished = false + var application: NSRunningApplication? + var launchError: Error? + let config = NSWorkspace.OpenConfiguration() + config.activates = true + config.createsNewApplicationInstance = true + NSWorkspace.shared.openApplication(at: app, configuration: config) { running, error in + application = running + launchError = error + finished = true + } + let deadline = Date().addingTimeInterval(30) + while !finished && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + guard let application = application else { throw launchError ?? Failure(message: "Application did not launch") } + return application +} + +var statusURL: URL? +var state: [String: Any] = [:] +var stage: URL? +var mount: URL? +var lockFD: Int32 = -1 +var ownedWork: URL? +func status(_ phase: String, _ message: String? = nil) throws { + state["phase"] = phase + state["message"] = message + state["notified"] = false + if let url = statusURL { try write(state, url) } +} + +do { + guard CommandLine.arguments.count == 2 else { try fail("Expected a private update plan") } + let planURL = URL(fileURLWithPath: CommandLine.arguments[1]) + let plan = try read(planURL) + func string(_ key: String) throws -> String { + guard let value = plan[key] as? String, !value.isEmpty else { try fail("Missing \(key)") } + return value + } + let destination = URL(fileURLWithPath: try string("destination")).standardizedFileURL + let work = planURL.deletingLastPathComponent() + ownedWork = work + let dmg = URL(fileURLWithPath: try string("dmg")) + let bundleID = try string("bundleID") + let build = try string("build") + let id = try string("id") + let expectedHash = try string("sha256") + let oldPID = (plan["pid"] as? NSNumber)?.int32Value ?? 0 + let quitTimeout = (plan["quitTimeout"] as? Double) ?? 60 + let launchTimeout = (plan["launchTimeout"] as? Double) ?? 45 + let output = URL(fileURLWithPath: try string("status")) + try fm.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + lockFD = Darwin.open(output.deletingLastPathComponent().appendingPathComponent("install.lock").path, + O_CREAT | O_RDWR, 0o600) + guard lockFD >= 0, flock(lockFD, LOCK_EX | LOCK_NB) == 0 else { try fail("Another installation is running") } + statusURL = output + state = ["id": id, "build": build, "destination": destination.path, "phase": "preparing"] + try status("preparing") + let parent = destination.deletingLastPathComponent() + guard destination.pathExtension == "app", fm.isWritableFile(atPath: parent.path), + !destination.path.contains("/AppTranslocation/"), + try bundleInfo(destination)["CFBundleIdentifier"] as? String == bundleID, + let old = NSRunningApplication(processIdentifier: oldPID), + old.bundleURL?.standardizedFileURL == destination else { + try fail("请先将应用移到可写的 Applications 文件夹后重试。") + } + // Read in chunks: a DMG must not be loaded entirely into the helper's memory. + let handle = try FileHandle(forReadingFrom: dmg) + var hash = SHA256() + while let bytes = try handle.read(upToCount: 1024 * 1024), !bytes.isEmpty { hash.update(data: bytes) } + try handle.close() + let actual = hash.finalize().map { String(format: "%02x", $0) }.joined() + guard expectedHash.count == 64, actual == expectedHash else { try fail("安装包 SHA-256 校验失败,旧应用未改动。") } + try command("/usr/bin/hdiutil", ["verify", dmg.path]) + let volume = work.appendingPathComponent("volume", isDirectory: true) + try fm.createDirectory(at: volume, withIntermediateDirectories: true) + mount = volume + try command("/usr/bin/hdiutil", ["attach", dmg.path, "-readonly", "-nobrowse", "-mountpoint", volume.path]) + let candidate = volume.appendingPathComponent("GPU Queue.app") + let info = try bundleInfo(candidate) + guard info["CFBundleIdentifier"] as? String == bundleID, + info["CFBundleVersion"] as? String == build, + let currentBuild = try bundleInfo(destination)["CFBundleVersion"] as? String, + let currentNumber = Int(currentBuild), let nextNumber = Int(build), nextNumber > currentNumber else { + try fail("安装包标识或版本不匹配,旧应用未改动。") + } + try command("/usr/bin/codesign", ["--verify", "--deep", "--strict", candidate.path]) + guard let executable = info["CFBundleExecutable"] as? String, !executable.contains("/"), !executable.isEmpty else { + try fail("安装包缺少应用程序。") + } + #if arch(arm64) + let architecture = "arm64" + #else + let architecture = "x86_64" + #endif + try command("/usr/bin/lipo", [candidate.appendingPathComponent("Contents/MacOS/\(executable)").path, + "-verify_arch", architecture]) + let staged = parent.appendingPathComponent(".GPUQueue-staged-\(id).app") + stage = staged + try command("/usr/bin/ditto", [candidate.path, staged.path]) + try command("/usr/bin/codesign", ["--verify", "--deep", "--strict", staged.path]) + try command("/usr/bin/hdiutil", ["detach", "-force", volume.path]) + mount = nil + let backup = parent.appendingPathComponent("GPU Queue.previous-\(currentBuild)-\(id).app") + state["backup"] = backup.path + guard !fm.fileExists(atPath: work.appendingPathComponent("cancel").path) else { try fail("更新已取消,旧应用未改动。") } + try status("ready") + let quitDeadline = Date().addingTimeInterval(quitTimeout) + while !old.isTerminated && Date() < quitDeadline { + guard !fm.fileExists(atPath: work.appendingPathComponent("cancel").path) else { try fail("更新已取消,旧应用未改动。") } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + guard !fm.fileExists(atPath: work.appendingPathComponent("cancel").path) else { try fail("更新已取消,旧应用未改动。") } + guard old.isTerminated else { try fail("等待应用退出超时,旧应用未改动。请退出后重试。") } + try fm.moveItem(at: destination, to: backup) + var installed = false + var running: NSRunningApplication? + do { + try fm.moveItem(at: staged, to: destination) + stage = nil + installed = true + try status("awaiting_launch") + running = try launch(destination) + let deadline = Date().addingTimeInterval(launchTimeout) + var confirmed = false + while Date() < deadline { + if let latest = try? read(output), latest["id"] as? String == id, + latest["phase"] as? String == "complete" { confirmed = true; break } + if running?.isTerminated == true { break } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + guard confirmed else { try fail("新版未能完成启动,已恢复上一版本。") } + // Keep the previous app as a manual fallback; never touch preferences or Keychain. + } catch { + if let app = running, !app.isTerminated { + app.terminate() + let deadline = Date().addingTimeInterval(5) + while !app.isTerminated && Date() < deadline { RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } + if !app.isTerminated { app.forceTerminate() } + let forceDeadline = Date().addingTimeInterval(5) + while !app.isTerminated && Date() < forceDeadline { RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } + guard app.isTerminated else { try fail("新版无法退出;备份保留在 \(backup.path),请手动恢复。") } + } + if installed { + try fm.moveItem(at: destination, to: parent.appendingPathComponent("GPU Queue.failed-\(id).app")) + } + try fm.moveItem(at: backup, to: destination) + try status("rolled_back", error.localizedDescription) + _ = try launch(destination) + } + try? fm.removeItem(at: work) +} catch { + try? status("failed", error.localizedDescription) + fputs("\(error.localizedDescription)\n", stderr) + if let volume = mount { + do { try command("/usr/bin/hdiutil", ["detach", "-force", volume.path]); mount = nil } + catch { fputs("Could not detach update volume: \(error)\n", stderr) } + } + if let staged = stage { try? fm.removeItem(at: staged) } + if mount == nil, let work = ownedWork { try? fm.removeItem(at: work) } + exit(1) +} +if lockFD >= 0 { flock(lockFD, LOCK_UN); close(lockFD) } diff --git a/serverbox/packages/fl_lib/lib/src/core/update.dart b/serverbox/packages/fl_lib/lib/src/core/update.dart index ded1aed..6673216 100644 --- a/serverbox/packages/fl_lib/lib/src/core/update.dart +++ b/serverbox/packages/fl_lib/lib/src/core/update.dart @@ -4,6 +4,8 @@ import 'package:material_ui/material_ui.dart'; abstract final class AppUpdateIface { static final newestBuild = ValueNotifier(null); + /// Optional host installer. Called only after the user presses Update. + static Future Function(BuildContext context, String url, int build)? installer; /// - [noticeBuilder] is shown above the release notes, for whatever this /// particular app has to say alongside an update — which build the user is @@ -97,9 +99,9 @@ abstract final class AppUpdateIface { TextButton( onPressed: () { if (!force) context.popDialog(); - _doUpdate(context, fileUrl); + _doUpdate(context, fileUrl, newest); }, - child: Text(l10n.update), + child: Text(installer != null ? '下载并安装' : l10n.update), ) ], ); @@ -171,7 +173,12 @@ abstract final class AppUpdateIface { ); } - static Future _doUpdate(BuildContext context, String url) async { + static Future _doUpdate(BuildContext context, String url, int build) async { + final install = installer; + if (install != null) { + await install(context, url, build); + return; + } switch (Pfs.type) { case Pfs.windows || Pfs.linux || Pfs.ios || Pfs.macos || Pfs.android: await url.launchUrl(); diff --git a/serverbox/pubspec.yaml b/serverbox/pubspec.yaml index 6dd37ef..cf002fd 100644 --- a/serverbox/pubspec.yaml +++ b/serverbox/pubspec.yaml @@ -1,7 +1,7 @@ name: server_box description: server status & toolbox app. publish_to: "none" -version: 2.0.2003+2003 +version: 2.0.2004+2004 environment: sdk: ">=3.11.0" diff --git a/serverbox/test/unit/app/mac_update_release_test.dart b/serverbox/test/unit/app/mac_update_release_test.dart new file mode 100644 index 0000000..d80e096 --- /dev/null +++ b/serverbox/test/unit/app/mac_update_release_test.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/update/release.dart'; + +void main() { + const url = 'https://github.com/Ferryyer/gpu-queue/releases/download/v2.0.2004/GPU-Queue-macos-arm64-2004-unsigned.dmg'; + final asset = {'browser_download_url': url, 'state': 'uploaded', 'size': 54000000, 'digest': 'sha256:${'a' * 64}'}; + Map release() => {'draft': false, 'prerelease': false, 'tag_name': 'v2.0.2004', 'assets': [Map.of(asset)]}; + test('uses immutable project tag and published SHA-256 asset', () { + final value = MacUpdateRelease.parse(jsonEncode(release()), url, 2004); + expect(value.sha256, 'a' * 64); + expect(value.bytes, 54000000); + expect(MacUpdateRelease.apiFor(url, 2004).toString(), 'https://api.github.com/repos/Ferryyer/gpu-queue/releases/tags/v2.0.2004'); + }); + test('rejects foreign, unversioned, or inconsistent asset URLs', () { + for (final invalid in [url.replaceFirst('https:', 'http:'), url.replaceFirst('github.com', 'evil.example'), + url.replaceFirst('Ferryyer', 'another'), '$url?raw=1', '$url#x', + url.replaceFirst('/v2.0.2004/', '/v2.0.2005/'), url.replaceFirst('-2004-', '-2005-'), + url.replaceFirst('github.com', 'user@github.com'), url.replaceFirst('github.com', 'github.com:443')]) { + expect(() => MacUpdateRelease.apiFor(invalid, 2004), throwsFormatException, reason: invalid); + } + }); + test('rejects draft, prerelease, wrong tag and duplicate assets', () { + for (final override in [{'draft': true}, {'prerelease': true}, {'tag_name': 'v2.0.2003'}, {'assets': [asset, asset]}, {'assets': []}]) { + expect(() => MacUpdateRelease.parse(jsonEncode(release()..addAll(override)), url, 2004), throwsFormatException); + } + }); + test('requires uploaded asset, bounded byte count and SHA-256', () { + for (final override in [{'digest': null}, {'digest': 'sha256:abc'}, {'state': 'new'}, {'size': 0}, {'size': 600 * 1024 * 1024}]) { + final data = release()..['assets'] = [Map.of(asset)..addAll(override)]; + expect(() => MacUpdateRelease.parse(jsonEncode(data), url, 2004), throwsFormatException); + } + }); +}