diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd0c7bb..932143c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,7 @@ jobs: - name: Pack (Windows) if: matrix.runtime == 'win-x64' shell: pwsh - run: ./build.ps1 Pack PackMsi --skip Test --Runtime ${{ matrix.runtime }} --Version ${{ needs.compile.outputs.version }} + run: ./build.ps1 Pack --skip Test --Runtime ${{ matrix.runtime }} --Version ${{ needs.compile.outputs.version }} - name: Pack (macOS) if: matrix.runtime == 'osx-arm64' diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 0365fd2..92b3968 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -28,7 +28,6 @@ "Clean", "Mutate", "Pack", - "PackMsi", "ProbeTranslation", "ProbeVoice", "Publish", diff --git a/CHANGELOG.md b/CHANGELOG.md index e36fde8..905185f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- The settings page showed the version `1.0.0` for every release: the build never stamped the release version into + the program. The version shown now is the release's (`--Version`, the tag); a build made any other way shows + `0.0.0-dev`. +- The update check no longer says "you are on the latest version" when it did not look. A copy that was not installed + by an installer of the release (a development build, or the `.msi` of 2.1.0 and earlier) cannot update itself, and + says so with a link to the download page. + +### Changed +- One installer technology on Windows: the `.msi` is now built by Velopack (`vpk pack --msi`) like the Setup.exe, so an + application installed from it updates itself the same way. It installs per user (no elevation, also for updates). The + hand-made WiX `.msi`, which could not update, is gone along with the WiX tool. +- On Windows the settings, models and logs moved from `%LocalAppData%\LiveLingo` to `%LocalAppData%\AGIBuild\LiveLingo`. + Velopack owns the first folder: it empties it when an installer runs over it and when the application is uninstalled, + which would have deleted the models and the settings. The first start of this version moves `settings.json`, + `preferences.json` and `models` over (a rename, no copy). + **If you installed the old `.msi` (its data is in the old folder), uninstall it, move those three entries into + `%LocalAppData%\AGIBuild\LiveLingo` yourself, and only then run the new Setup.exe or `.msi`**: the installer asks + before it replaces a folder that is not empty, and replacing it deletes what is inside. +- Updates report where they are: *Settings → Models → Updates* shows the checking, the download with its progress, the + result with the time of the check, and a failure with its reason. The tray's *Check for Updates* opens it and starts + a check, instead of a dialog that vanished. + ## [2.1.0] - 2026-11-02 Stopping a dictation now polishes the whole text (summary first), with editable polish requirements, a Clear button, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d488bf2..2abcb4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,8 +11,7 @@ Thanks for your interest in contributing! This guide covers everything you need | [.NET SDK](https://dotnet.microsoft.com/download) | 10.0+ | Required | | Git | Any recent | Required | | [Nuke](https://nuke.build/) | Via `dotnet tool restore` | Build automation | -| [Velopack](https://velopack.io/) | Via `dotnet tool restore` | Windows installer packaging | -| [WiX Toolset](https://wixtoolset.org/) | Via `dotnet tool restore` | Windows MSI packaging | +| [Velopack](https://velopack.io/) | Via `dotnet tool restore` | Windows (Setup.exe and .msi) and macOS packaging, updates | | [Stryker.NET](https://stryker-mutator.io/) | Via `dotnet tool restore` | Mutation testing | --- @@ -23,7 +22,7 @@ Thanks for your interest in contributing! This guide covers everything you need git clone https://github.com/AGIBuild/LiveLingo.git cd LiveLingo -# Restore CLI tools (Velopack, WiX, Stryker) +# Restore CLI tools (Velopack, Stryker) dotnet tool restore # Run in development mode @@ -70,7 +69,6 @@ LiveLingo/ │ ├── build/ # Nuke build automation │ ├── BuildTask.cs # All build targets -│ ├── windows/LiveLingo.wxs # WiX MSI definition │ └── macos/ # macOS packaging scripts │ ├── .github/workflows/ # CI/CD (compile → test → pack → release) @@ -126,8 +124,7 @@ LiveLingo uses [Nuke](https://nuke.build/) for build automation. All targets are ./build.sh Publish --Runtime osx-arm64 # Publish for macOS ./build.ps1 Publish --Runtime win-x64 # Publish for Windows ./build.sh Pack --Runtime osx-arm64 --Version 1.0.0 # macOS: .pkg installer + update package and feed -./build.ps1 Pack --Runtime win-x64 --Version 1.0.0 # Windows: Setup.exe installer + update package and feed -./build.ps1 PackMsi --Runtime win-x64 --Version 1.0.0 # WiX .msi installer (does not update itself) +./build.ps1 Pack --Runtime win-x64 --Version 1.0.0 # Windows: Setup.exe and .msi installers + update package and feed ``` End-to-end probes download the pinned models (SHA256-verified) and use real inference; they are not part of the unit-test gate: @@ -265,8 +262,12 @@ Installed applications update themselves from the GitHub releases of the reposit publish a release whose feed does not name a package that is in it. - The application checks at start and every `Update.CheckIntervalHours`, downloads a newer version in the background, and then offers *Restart to Update* in the tray menu; quitting also installs it. Pre-releases are not offered. -- Only an application installed by the Setup.exe or the .pkg can update itself. A development build and the .msi cannot; - they skip the check. + *Settings → Models → Updates* shows the version, the progress of a check or download, and the result; the tray's + *Check for Updates* opens it and starts a check. +- Only an application installed by an installer of the release (Setup.exe, the .msi or the .pkg — all Velopack) can + update itself; the .msi is built by `vpk pack --msi` and installs per user, so updates never need elevation. A + development build and a copy installed by the hand-built `.msi` of 2.1.0 and earlier cannot; they skip the check, and + *Updates* says so and links to the download page. - A release without a feed (the 1.x releases) is skipped. The application reads the latest 10 releases. - Installers are not signed, so Windows SmartScreen and macOS Gatekeeper warn on the first install; updates run from the installed application. Signing (`--signAppIdentity`, `--notaryProfile`) is not set up. diff --git a/README.md b/README.md index 8de830a..0cf4723 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ exported as SRT. endpoint must be `https`; the API key is kept in the system secret store, not in a file. A cloud failure is shown, never silently retried on the local model. - Models are downloaded only after you agree, from their original hosts, and verified against pinned SHA256 hashes. +- Settings, models and logs live in `%LocalAppData%\AGIBuild\LiveLingo` (Windows) or + `~/Library/Application Support/LiveLingo` (macOS), outside the installed program, so updating and uninstalling keep + them. Delete that folder to remove everything LiveLingo stored. - Recent drafts are kept in memory only and are gone when the app exits. - The installed app looks for updates on the GitHub releases of this repository (at start and every few hours) and downloads a newer version in the background. Nothing you wrote or said is part of that request. Set diff --git a/build/BuildTask.cs b/build/BuildTask.cs index facec36..2554240 100644 --- a/build/BuildTask.cs +++ b/build/BuildTask.cs @@ -258,6 +258,7 @@ private static void RunProcessLogger(OutputType type, string output) .SetConfiguration(Configuration.Release) .SetRuntime(Runtime) .SetSelfContained(true) + .SetVersion(Version) .SetOutput(PublishDir / Runtime) .SetNoLogo(true)); }); @@ -331,6 +332,11 @@ double RunMutationTesting() $"--outputDir {ReleasesDir} " + $"--noPortable"; + // The .msi is Velopack's too, so an application installed from it updates itself like the Setup.exe one. + // Per user, so that updates never need elevation. + if (windows) + arguments += " --msi --instLocation PerUser"; + if (!windows) { // The bundle version has no pre-release suffix; the update feed carries the full semantic version. @@ -349,34 +355,21 @@ double RunMutationTesting() DotNet(arguments, environmentVariables: vpkEnv.AsReadOnly()); - // The installer keeps the file name of earlier releases. + // The installers keep the file names of earlier releases. var installer = ReleasesDir.GlobFiles(windows ? "*Setup.exe" : "*Setup.pkg").Single(); var renamed = ReleasesDir / (windows ? $"LiveLingo-{Version}-{Runtime}-Setup.exe" : $"LiveLingo-{Version}-{Runtime}.pkg"); File.Move(installer, renamed, overwrite: true); Log.Information("Installer: {Path}", renamed); - }); - - Target PackMsi => _ => _ - .DependsOn(Publish) - .Requires(() => Runtime.StartsWith("win")) - .Executes(() => - { - DotNetToolRestore(); - ReleasesDir.CreateDirectory(); - - var wxsFile = RootDirectory / "build" / "windows" / "LiveLingo.wxs"; - var msiOutput = ReleasesDir / $"LiveLingo-{Version}-{Runtime}.msi"; - - DotNet( - $"wix build \"{wxsFile}\" " + - $"-o \"{msiOutput}\" " + - $"-d Version={Version} " + - $"-d PublishDir={PublishDir / Runtime}"); - - Log.Information("Windows MSI created: {Path}", msiOutput); + if (windows) + { + var msi = ReleasesDir.GlobFiles("*.msi").Single(); + var msiRenamed = ReleasesDir / $"LiveLingo-{Version}-{Runtime}.msi"; + File.Move(msi, msiRenamed, overwrite: true); + Log.Information("MSI installer: {Path}", msiRenamed); + } }); static bool IsCoverageClassIncluded(string packageName, string className) diff --git a/build/windows/LiveLingo.wxs b/build/windows/LiveLingo.wxs deleted file mode 100644 index 774df2c..0000000 --- a/build/windows/LiveLingo.wxs +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/superpowers/specs/2026-11-03-windows-packaging-design.md b/docs/superpowers/specs/2026-11-03-windows-packaging-design.md new file mode 100644 index 0000000..5ad30b9 --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-windows-packaging-design.md @@ -0,0 +1,34 @@ +# Windows 打包与更新设计(2026-11-03) + +## 问题 + +- Windows 发布了两种安装方式:Velopack `Setup.exe`(能自动更新)和自维护的 WiX `.msi`(不能)。用 `.msi` 的用户永远看不到更新, + 界面还把"没有检查"说成"已是最新"。同一件事(安装与更新)由两套机制实现,违反"同一功能只由一个组件实现"。 +- Windows 上用户数据(设置、模型、日志)放在 `%LocalAppData%\LiveLingo`,与 Velopack 的安装根目录(`packId` = `LiveLingo`)是同一个目录。 + +## 事实(已核对) + +- [VERIFIED] Velopack 1.2.158 起支持 `vpk pack --msi`:MSI 与 Setup.exe 安装同样的目录结构,装完后更新方式一致 + (docs.velopack.io/packaging/installer)。本仓库用 vpk 1.2.161。 +- [VERIFIED] Velopack 卸载会删除整个 `%LocalAppData%\{packId}`(docs.velopack.io/integrating/uninstalling)。 +- [VERIFIED] Velopack 安装器遇到非空目标目录时,用户确认后会把旧目录改名、重装、成功后**删除旧目录** + (velopack `src/bins/src/commands/install.rs`)。所以数据留在安装根目录里,首次安装和卸载都会丢模型与设置。 +- [VERIFIED] 更新只替换 `current` 目录,根目录其他内容不动。 + +## 决策 + +| # | 决策 | 备选项 | 取舍 | +|---|------|--------|------| +| 1 | Windows 只用 Velopack 一条打包链:`Setup.exe`(个人)+ `.msi`(`vpk pack --msi`,面向部署工具) | 自维护 WiX MSI;MSIX;ClickOnce;只留 Setup.exe | 自维护 MSI 不能自更新,且要维护第二套工程和 WiX 许可(v7 的 OSMF)。MSIX 未签名无法安装,要 Store 或证书;ClickOnce 无增量更新、不跨平台。Velopack 同时覆盖 Windows 与 macOS,有增量更新。MSI 保留是因为已有用户使用且部署工具需要 | +| 2 | MSI 固定 `--instLocation PerUser` | `PerMachine` / `Either` | 与 Setup.exe 一致:后台更新不需要提权。按机器安装的更新是否弹 UAC 未验证,不冒险 | +| 3 | 数据目录移出安装根目录:Windows `%LocalAppData%\AGIBuild\LiveLingo`,macOS 不变;由 `LiveLingo.Core.UserData` 一处定义 | 数据留在原处;改 `packId` | 留在原处会在安装和卸载时被清空。改 `packId` 会切断已安装版本的更新链,macOS 也受影响。卸载后保留数据与 macOS 行为一致 | +| 4 | 首次启动一次性把 `settings.json`、`preferences.json`、`models` 从旧目录改名搬走;失败只记日志,不阻断启动 | 复制;不迁移 | 同一磁盘卷内改名,GB 级模型瞬间完成。不迁移会让已装 Setup.exe 的用户丢设置、重新下载模型 | + +## 已知限制与未决项 + +- 旧 `.msi`(2.1.0 及以前)的用户在装新版前,必须自己把旧目录里的三项搬到新目录,否则安装器替换旧目录时会删掉它们 + (旧版没有代码可以替他们搬)。已写入 CHANGELOG。 +- 未签名:SmartScreen 会警告首次安装。微软推荐非 Store 分发使用 Azure Artifact Signing(约 10 美元/月,需组织或个人身份验证, + 信誉随发布逐步积累)。Velopack 支持 `--signTemplate` / Azure Trusted Signing。需要账号和密钥,不在本次范围。 +- `winget` 分发(`winget upgrade` 也能升级):需要先有签名与稳定的下载地址,之后再评估。 +- `.msi` 的产物文件名、`--msi` 在 CI 上的输出需要在首次 Windows 发布时核对(本地 macOS 无法运行 MSI 打包)。 diff --git a/dotnet-tools.json b/dotnet-tools.json index 4a1578a..0033f96 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -15,13 +15,6 @@ "dotnet-stryker" ], "rollForward": false - }, - "wix": { - "version": "6.0.2", - "commands": [ - "wix" - ], - "rollForward": false } } } \ No newline at end of file diff --git a/src/LiveLingo.Core/CoreOptions.cs b/src/LiveLingo.Core/CoreOptions.cs index 71a22a1..30cc44a 100644 --- a/src/LiveLingo.Core/CoreOptions.cs +++ b/src/LiveLingo.Core/CoreOptions.cs @@ -4,14 +4,10 @@ namespace LiveLingo.Core; public class CoreOptions { - public string ModelStoragePath { get; set; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", "models"); + public string ModelStoragePath { get; set; } = Path.Combine(UserData.Directory, "models"); /// Where short-lived runtime state (pid records of started servers) is kept. - public string RuntimeStatePath { get; set; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", "run"); + public string RuntimeStatePath { get; set; } = Path.Combine(UserData.Directory, "run"); public int InferenceThreads { get; set; } diff --git a/src/LiveLingo.Core/UserData.cs b/src/LiveLingo.Core/UserData.cs new file mode 100644 index 0000000..6a57fab --- /dev/null +++ b/src/LiveLingo.Core/UserData.cs @@ -0,0 +1,23 @@ +namespace LiveLingo.Core; + +/// +/// Where the user's data (settings, models, logs) lives: one place for the whole application. It is never inside a +/// folder an installer owns. Velopack empties %LocalAppData%\LiveLingo when an installer runs over it and when +/// the application is uninstalled, so on Windows the data sits under the publisher's folder next to it and survives +/// both, like the data under Application Support on macOS survives deleting the app. +/// +public static class UserData +{ + public static string Directory { get; } = Resolve( + OperatingSystem.IsMacOS(), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + + /// The folder earlier versions kept the data in on Windows; the installer owns it now. + public static string LegacyWindowsDirectory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LiveLingo"); + + internal static string Resolve(bool macOS, string home, string localAppData) => macOS + ? Path.Combine(home, "Library", "Application Support", "LiveLingo") + : Path.Combine(localAppData, "AGIBuild", "LiveLingo"); +} diff --git a/src/LiveLingo.Desktop/App.axaml.cs b/src/LiveLingo.Desktop/App.axaml.cs index c6f1647..233bbc2 100644 --- a/src/LiveLingo.Desktop/App.axaml.cs +++ b/src/LiveLingo.Desktop/App.axaml.cs @@ -53,11 +53,23 @@ public override async void OnFrameworkInitializationCompleted() if (OperatingSystem.IsMacOS()) SetMacActivationPolicyAccessory(); + // Before anything reads the data folder. + var moves = OperatingSystem.IsWindows() + ? UserDataMigration.Run(UserData.LegacyWindowsDirectory, UserData.Directory) + : []; + var settingsService = new JsonSettingsService(); await settingsService.LoadAsync(); var settings = settingsService.Current; ConfigureLogging(settings); + foreach (var move in moves) + { + if (move.Error is null) + Log.Information("Moved {Entry} to the data folder {Folder}", move.Name, UserData.Directory); + else + Log.Error(move.Error, "Could not move {Entry} to the data folder; it stays in the installer's folder", move.Name); + } var services = new ServiceCollection(); services.AddLogging(builder => @@ -90,7 +102,7 @@ public override async void OnFrameworkInitializationCompleted() } services.AddSingleton(sp => - new JsonPreferencesStore(Path.Combine(GetUserDataDirectory(), "preferences.json"), sp.GetService>())); + new JsonPreferencesStore(Path.Combine(UserData.Directory, "preferences.json"), sp.GetService>())); services.AddSingleton(); services.AddSingleton(); @@ -165,7 +177,7 @@ private static void ApplyCoreOptions(SettingsModel settings, CoreOptions options private static void ConfigureLogging(SettingsModel settings) { - var logDirectory = Path.Combine(GetUserDataDirectory(), "logs"); + var logDirectory = Path.Combine(UserData.Directory, "logs"); Directory.CreateDirectory(logDirectory); var logPath = Path.Combine(logDirectory, "livelingo-.log"); @@ -195,7 +207,7 @@ private void SetupTrayIcon(IClassicDesktopStyleApplicationLifetime desktop, IPla settingsItem.Click += (_, _) => Dispatcher.UIThread.Post(() => ShowSettings()); _updateItem = new NativeMenuItem(loc.T("tray.checkUpdates")); - _updateItem.Click += (_, _) => Dispatcher.UIThread.Post(async () => await UpdateFromMenuAsync()); + _updateItem.Click += (_, _) => Dispatcher.UIThread.Post(UpdateFromMenu); var quitItem = new NativeMenuItem(loc.T("tray.quit")); quitItem.Click += (_, _) => desktop.Shutdown(); @@ -333,6 +345,7 @@ private void ShowSettings(string section = "general") services.GetRequiredService()); _settingsWindow = new SettingsWindow( vm, new SettingsTexts(services.GetRequiredService()), section, + _updates, services.GetRequiredService().OpenUrl, services.GetRequiredService().CreateLogger()); _settingsWindow.Closed += (_, _) => _settingsWindow = null; _settingsWindow.Show(); @@ -409,40 +422,21 @@ private static Task OpenMacPrivacyPane(string pane) return Task.CompletedTask; } - /// The tray item: restarts into a downloaded update, or looks for one. - private async Task UpdateFromMenuAsync() + /// + /// The tray item: restarts into a downloaded update, or shows the settings where the check runs and reports its + /// progress and result. + /// + private void UpdateFromMenu() { - var loc = _serviceProvider!.GetRequiredService(); - try - { - if (_updates is null) - { - ShowInfoDialog(loc.T("dialog.update.title"), loc.T("dialog.update.notConfigured"), - primaryText: loc.T("dialog.ok")); - return; - } - - if (_updates.ReadyVersion is not null) - { - _updates.ApplyAndRestart(); - return; - } - - if (!await _updates.CheckNowAsync()) - { - ShowInfoDialog(loc.T("dialog.update.title"), loc.T("dialog.update.latest"), - primaryText: loc.T("dialog.ok")); - return; - } - - ShowUpdateReadyDialog(); - } - catch (Exception ex) + if (_updates?.ReadyVersion is not null) { - Log.Error(ex, "Manual update check failed from tray menu."); - ShowInfoDialog(loc.T("dialog.update.title"), loc.T("dialog.update.failed", ex.Message), - primaryText: loc.T("dialog.ok")); + _updates.ApplyAndRestart(); + return; } + + ShowSettings("models"); + if (_updates is not null) + _ = _updates.CheckNowAsync(); } /// A background download finished: the tray item now restarts into the new version. @@ -452,21 +446,6 @@ private void ShowUpdateReady() _updateItem!.Header = loc.T("tray.restartToUpdate", _updates!.ReadyVersion!); } - private void ShowUpdateReadyDialog() - { - var loc = _serviceProvider!.GetRequiredService(); - ShowInfoDialog( - loc.T("dialog.update.ready.title"), - loc.T("dialog.update.ready.body", _updates!.ReadyVersion!), - primaryText: loc.T("dialog.update.ready.restart"), - primaryAction: () => - { - _updates.ApplyAndRestart(); - return Task.CompletedTask; - }, - secondaryText: loc.T("dialog.update.ready.later")); - } - private static void ShowInfoDialog( string title, string message, @@ -535,22 +514,6 @@ private static void ShowInfoDialog( _ => Serilog.Events.LogEventLevel.Information, }; - private static string GetUserDataDirectory() - { - if (OperatingSystem.IsMacOS()) - { - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "LiveLingo"); - } - - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo"); - } - [System.Runtime.Versioning.SupportedOSPlatform("macos")] private static void SetMacActivationPolicyAccessory() { diff --git a/src/LiveLingo.Desktop/AppVersion.cs b/src/LiveLingo.Desktop/AppVersion.cs new file mode 100644 index 0000000..e9b0201 --- /dev/null +++ b/src/LiveLingo.Desktop/AppVersion.cs @@ -0,0 +1,20 @@ +using System.Reflection; + +namespace LiveLingo.Desktop; + +/// +/// The version this build shows. The release build stamps it from the release tag (--Version), so it is the same +/// text the installer and the update feed carry; a build made any other way says so. +/// +public static class AppVersion +{ + public static string Current { get; } = Read(typeof(AppVersion).Assembly); + + /// The informational version without the build metadata (the source revision) that follows a "+". + internal static string Read(Assembly assembly) + { + var text = assembly.GetCustomAttribute()?.InformationalVersion ?? ""; + var plus = text.IndexOf('+'); + return plus < 0 ? text : text[..plus]; + } +} diff --git a/src/LiveLingo.Desktop/LiveLingo.Desktop.csproj b/src/LiveLingo.Desktop/LiveLingo.Desktop.csproj index f5123d0..17c8dfe 100644 --- a/src/LiveLingo.Desktop/LiveLingo.Desktop.csproj +++ b/src/LiveLingo.Desktop/LiveLingo.Desktop.csproj @@ -9,6 +9,8 @@ app.manifest true Assets\app-icon.ico + + 0.0.0-dev diff --git a/src/LiveLingo.Desktop/Resources/i18n/en-US.json b/src/LiveLingo.Desktop/Resources/i18n/en-US.json index 2783bb6..4f9092b 100644 --- a/src/LiveLingo.Desktop/Resources/i18n/en-US.json +++ b/src/LiveLingo.Desktop/Resources/i18n/en-US.json @@ -11,14 +11,6 @@ "app.permission.openInputMonitoring": "Open Input Monitoring", "app.hotkey.registrationFailed.title": "Hotkey Registration Failed", "app.hotkey.registrationFailed.body": "Could not register \"{0}\".\n\nThe key combination may conflict with another application, or the required system permissions are not granted.", - "dialog.update.title": "Check for Updates", - "dialog.update.notConfigured": "Update URL is not configured.", - "dialog.update.latest": "You are on the latest version.", - "dialog.update.ready.title": "Update Ready", - "dialog.update.ready.body": "Version {0} has been downloaded.\nRestart now to finish updating?", - "dialog.update.ready.restart": "Restart Now", - "dialog.update.ready.later": "Later", - "dialog.update.failed": "Failed to check/apply updates.\n{0}", "dialog.ok": "OK", "composer.dest.aria": "Write-back target", @@ -273,6 +265,19 @@ "settings.legacy.remove": "Remove", "settings.version": "Version", "settings.version.desc": "Models and runtimes are downloaded only after you agree", + "settings.update": "Updates", + "settings.update.desc": "Looks for a new version at start and every few hours, and downloads it in the background", + "settings.update.check": "Check for updates", + "settings.update.restart": "Restart to update", + "settings.update.download": "Open download page", + "settings.update.idle": "Not checked yet.", + "settings.update.checking": "Checking for updates…", + "settings.update.downloading": "Downloading version {0}… {1}%", + "settings.update.ready": "Version {0} is downloaded. Restart to install it; quitting installs it too.", + "settings.update.latest": "You are on the latest version ({0}). Checked {1}.", + "settings.update.failed": "The check failed ({0}): {1}", + "settings.update.unsupported": "This copy was not installed with the LiveLingo installer (Setup.exe or .pkg), so it cannot update itself. Install the latest version from the download page.", + "settings.update.off": "Updates are turned off (Update.UpdateUrl is empty in settings.json).", "settings.diag": "Status", "settings.diag.desc": "Contains no text, terms, addresses or keys; handy when asking for help", "settings.diag.open": "View", diff --git a/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json b/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json index 684e9d4..899f0ae 100644 --- a/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json +++ b/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json @@ -11,14 +11,6 @@ "app.permission.openInputMonitoring": "打开输入监控", "app.hotkey.registrationFailed.title": "快捷键注册失败", "app.hotkey.registrationFailed.body": "无法注册 \"{0}\"。\n\n该按键组合可能与其他应用冲突,或系统权限尚未授予。", - "dialog.update.title": "检查更新", - "dialog.update.notConfigured": "未配置更新地址。", - "dialog.update.latest": "已是最新版本。", - "dialog.update.ready.title": "更新已就绪", - "dialog.update.ready.body": "新版本 {0} 已下载完成。\n现在重启以完成更新?", - "dialog.update.ready.restart": "立即重启", - "dialog.update.ready.later": "稍后", - "dialog.update.failed": "检查/应用更新失败。\n{0}", "dialog.ok": "确定", "composer.dest.aria": "回填目标", @@ -273,6 +265,19 @@ "settings.legacy.remove": "删除", "settings.version": "版本", "settings.version.desc": "模型和运行时只在你同意后下载", + "settings.update": "软件更新", + "settings.update.desc": "启动时和之后每隔几小时检查新版本,并在后台下载", + "settings.update.check": "检查更新", + "settings.update.restart": "重启以更新", + "settings.update.download": "打开下载页面", + "settings.update.idle": "尚未检查。", + "settings.update.checking": "正在检查更新…", + "settings.update.downloading": "正在下载 {0} 版… {1}%", + "settings.update.ready": "{0} 版已下载完成。重启即可安装;退出应用时也会自动安装。", + "settings.update.latest": "已是最新版本({0}),检查于 {1}。", + "settings.update.failed": "检查失败({0}):{1}", + "settings.update.unsupported": "当前不是通过 LiveLingo 安装程序(Setup.exe 或 .pkg)安装的,无法自动更新。请从下载页面安装最新版本。", + "settings.update.off": "已关闭自动更新(settings.json 中的 Update.UpdateUrl 为空)。", "settings.diag": "运行状态", "settings.diag.desc": "不含正文、术语、地址和密钥,便于排查", "settings.diag.open": "查看", diff --git a/src/LiveLingo.Desktop/Services/Configuration/JsonSettingsService.cs b/src/LiveLingo.Desktop/Services/Configuration/JsonSettingsService.cs index 2adce80..8b9a988 100644 --- a/src/LiveLingo.Desktop/Services/Configuration/JsonSettingsService.cs +++ b/src/LiveLingo.Desktop/Services/Configuration/JsonSettingsService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using LiveLingo.Core; namespace LiveLingo.Desktop.Services.Configuration; @@ -146,18 +147,7 @@ public void Replace(SettingsModel model) public bool SettingsFileExists() => File.Exists(_filePath); - private static string GetDefaultPath() - { - var folder = OperatingSystem.IsMacOS() - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", "Application Support", "LiveLingo") - : Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo"); - - return Path.Combine(folder, "settings.json"); - } + private static string GetDefaultPath() => Path.Combine(UserData.Directory, "settings.json"); private void SaveCurrentUnsafe() { diff --git a/src/LiveLingo.Desktop/Services/Configuration/UserDataMigration.cs b/src/LiveLingo.Desktop/Services/Configuration/UserDataMigration.cs new file mode 100644 index 0000000..c6965ad --- /dev/null +++ b/src/LiveLingo.Desktop/Services/Configuration/UserDataMigration.cs @@ -0,0 +1,46 @@ +namespace LiveLingo.Desktop.Services.Configuration; + +/// What happened to one entry of the data folder that earlier versions kept where the installer now lives. +/// Why it could not be moved (it is in use, or the disk refused); null when it was moved. +internal sealed record UserDataMove(string Name, Exception? Error); + +/// +/// Moves the settings and the downloaded models from the folder earlier Windows versions used +/// (%LocalAppData%\LiveLingo, which Velopack empties when it installs and uninstalls) to the data folder +/// (). A rename on one volume, so the models are not copied. An entry the new +/// folder already has is left alone, and one that cannot be moved is reported, not fatal: the application starts with +/// what it has and the old entry stays where it was. +/// +internal static class UserDataMigration +{ + private static readonly string[] Entries = ["settings.json", "preferences.json", "models"]; + + public static IReadOnlyList Run(string legacy, string current) + { + var moves = new List(); + foreach (var name in Entries) + { + var from = Path.Combine(legacy, name); + var to = Path.Combine(current, name); + var isDirectory = Directory.Exists(from); + if ((!isDirectory && !File.Exists(from)) || Directory.Exists(to) || File.Exists(to)) + continue; + + try + { + Directory.CreateDirectory(current); + if (isDirectory) + Directory.Move(from, to); + else + File.Move(from, to); + moves.Add(new UserDataMove(name, null)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + moves.Add(new UserDataMove(name, ex)); + } + } + + return moves; + } +} diff --git a/src/LiveLingo.Desktop/Services/Update/IUpdateService.cs b/src/LiveLingo.Desktop/Services/Update/IUpdateService.cs index 0eee8f5..bd8df25 100644 --- a/src/LiveLingo.Desktop/Services/Update/IUpdateService.cs +++ b/src/LiveLingo.Desktop/Services/Update/IUpdateService.cs @@ -6,11 +6,20 @@ namespace LiveLingo.Desktop.Services.Update; /// public interface IUpdateService { + /// False when this copy was not put in place by the installer (a development build, the MSI): it cannot update itself. + bool CanUpdate { get; } + + /// Where the installers of the latest version can be downloaded by hand; null when the feed is not a web page. + string? DownloadPage { get; } + /// The version that was downloaded and is ready to install; null until there is one. string? ReadyVersion { get; } - /// Looks for a newer version and downloads it. True when a version is ready to install. - Task CheckAndDownloadAsync(CancellationToken ct = default); + /// Asks the feed for a newer version. Null when this one is the latest. + Task FindAsync(CancellationToken ct = default); + + /// Downloads the version found, reporting 0-100 as it goes. + Task DownloadAsync(Action progress, CancellationToken ct = default); /// Installs now and starts the new version. void ApplyAndRestart(); diff --git a/src/LiveLingo.Desktop/Services/Update/UpdateChecks.cs b/src/LiveLingo.Desktop/Services/Update/UpdateChecks.cs index b3b771d..fc80640 100644 --- a/src/LiveLingo.Desktop/Services/Update/UpdateChecks.cs +++ b/src/LiveLingo.Desktop/Services/Update/UpdateChecks.cs @@ -3,9 +3,9 @@ namespace LiveLingo.Desktop.Services.Update; /// -/// The one place that decides when the application looks for updates: once at start and then every interval, until a -/// version is downloaded; and whenever the user asks. Background failures (no network, a feed that is not there yet) -/// only reach the log; a check the user asked for reports them. +/// The one place that decides when the application looks for updates — once at start and then every interval, until a +/// version is downloaded, and whenever the user asks — and the one place that knows how it is going. Every check, from +/// either source, reports through ; a failure never throws, it becomes the state (and the log). /// public sealed class UpdateChecks : IDisposable { @@ -15,6 +15,7 @@ public sealed class UpdateChecks : IDisposable private readonly TimeProvider _time; private readonly SemaphoreSlim _one = new(1, 1); private ITimer? _timer; + private UpdateState _state; public UpdateChecks(IUpdateService service, TimeSpan interval, ILogger logger, TimeProvider? time = null) { @@ -22,34 +23,68 @@ public UpdateChecks(IUpdateService service, TimeSpan interval, ILogger logger, T _interval = interval; _logger = logger; _time = time ?? TimeProvider.System; + _state = new UpdateState(service.CanUpdate ? UpdatePhase.Idle : UpdatePhase.Unsupported); } /// A version was downloaded and is ready to install. Raised once, on a background thread. public event Action? Ready; + /// changed. Raised on a background thread. + public event Action? StateChanged; + + public UpdateState State => Volatile.Read(ref _state); + public string? ReadyVersion => _service.ReadyVersion; + public string? DownloadPage => _service.DownloadPage; + /// Checks now and then every interval until a version is ready. public void Start() { - _timer = _time.CreateTimer(_ => _ = CheckInBackgroundAsync(), null, _interval, _interval); - _ = CheckInBackgroundAsync(); + if (!_service.CanUpdate) + { + _logger.LogInformation("Not checking for updates: the application was not installed by the installer"); + return; + } + + _timer = _time.CreateTimer(_ => _ = CheckNowAsync(), null, _interval, _interval); + _ = CheckNowAsync(); } - /// A check the user asked for. True when a version is ready to install. - public async Task CheckNowAsync(CancellationToken ct = default) + /// + /// Looks for a newer version and downloads it. Does nothing while a check is already running (it answers for both) + /// or when this copy cannot update itself. + /// + public async Task CheckNowAsync(CancellationToken ct = default) { - await _one.WaitAsync(ct).ConfigureAwait(false); + if (!_service.CanUpdate || _service.ReadyVersion is not null || !_one.Wait(0, CancellationToken.None)) + return; + try { - if (_service.ReadyVersion is not null) - return true; - if (!await _service.CheckAndDownloadAsync(ct).ConfigureAwait(false)) - return false; + Set(new UpdateState(UpdatePhase.Checking)); + var version = await _service.FindAsync(ct).ConfigureAwait(false); + if (version is null) + { + Set(new UpdateState(UpdatePhase.UpToDate, At: _time.GetUtcNow())); + return; + } + + Set(new UpdateState(UpdatePhase.Downloading, version)); + await _service.DownloadAsync(percent => SetPercent(version, percent), ct).ConfigureAwait(false); _timer?.Dispose(); - Ready?.Invoke(_service.ReadyVersion!); - return true; + Set(new UpdateState(UpdatePhase.Ready, version)); + Ready?.Invoke(version); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + Set(new UpdateState(UpdatePhase.Idle)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The update check failed"); + Set(new UpdateState(UpdatePhase.Failed, At: _time.GetUtcNow(), Error: ex.Message)); } finally { @@ -57,7 +92,11 @@ public async Task CheckNowAsync(CancellationToken ct = default) } } - public void ApplyAndRestart() => _service.ApplyAndRestart(); + public void ApplyAndRestart() + { + if (_service.ReadyVersion is not null) + _service.ApplyAndRestart(); + } /// Installs the downloaded version silently after this process exited; nothing when there is none. public void ApplyOnExit() @@ -72,15 +111,17 @@ public void Dispose() _one.Dispose(); } - private async Task CheckInBackgroundAsync() + private void SetPercent(string version, int percent) { - try - { - await CheckNowAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Background update check failed"); - } + var clamped = Math.Clamp(percent, 0, 100); + if (State is { Phase: UpdatePhase.Downloading } current && current.Percent != clamped) + Set(current with { Version = version, Percent = clamped }); + } + + private void Set(UpdateState state) + { + Volatile.Write(ref _state, state); + StateChanged?.Invoke(); } } + diff --git a/src/LiveLingo.Desktop/Services/Update/UpdateState.cs b/src/LiveLingo.Desktop/Services/Update/UpdateState.cs new file mode 100644 index 0000000..6416640 --- /dev/null +++ b/src/LiveLingo.Desktop/Services/Update/UpdateState.cs @@ -0,0 +1,30 @@ +namespace LiveLingo.Desktop.Services.Update; + +public enum UpdatePhase +{ + /// The user turned updates off (an empty update address). + Off, + + /// This copy was not put in place by the installer, so it cannot update itself. + Unsupported, + Idle, + Checking, + Downloading, + Ready, + UpToDate, + Failed +} + +/// What the update check is doing now and what its last result was; the one thing every window shows. +/// The version that is being downloaded or is ready to install. +/// Download progress, 0-100. +/// When the last check finished ( and ). +/// Why the last check failed. +public sealed record UpdateState( + UpdatePhase Phase, string? Version = null, int Percent = 0, DateTimeOffset? At = null, string? Error = null) +{ + public static UpdateState Off { get; } = new(UpdatePhase.Off); + + /// A check or a download is under way; another one cannot be started. + public bool Busy => Phase is UpdatePhase.Checking or UpdatePhase.Downloading; +} diff --git a/src/LiveLingo.Desktop/Services/Update/VelopackUpdateService.cs b/src/LiveLingo.Desktop/Services/Update/VelopackUpdateService.cs index c1d27d3..37781c3 100644 --- a/src/LiveLingo.Desktop/Services/Update/VelopackUpdateService.cs +++ b/src/LiveLingo.Desktop/Services/Update/VelopackUpdateService.cs @@ -12,38 +12,40 @@ public sealed class VelopackUpdateService : IUpdateService { private readonly ILogger _logger; private readonly UpdateManager _manager; + private UpdateInfo? _found; private UpdateInfo? _ready; public VelopackUpdateService(string updateUrl, ILogger logger) { _logger = logger; - IUpdateSource source = updateUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) + var github = updateUrl.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase); + IUpdateSource source = github ? new GithubSource(updateUrl, accessToken: null, prerelease: false) : new SimpleWebSource(updateUrl); + DownloadPage = github ? updateUrl.TrimEnd('/') + "/releases/latest" : null; _manager = new UpdateManager(source); } - public string? ReadyVersion => _ready?.TargetFullRelease.Version.ToString(); + public bool CanUpdate => _manager.IsInstalled; - public async Task CheckAndDownloadAsync(CancellationToken ct = default) - { - if (_ready is not null) - return true; + public string? DownloadPage { get; } - if (!_manager.IsInstalled) - { - _logger.LogDebug("Skipping the update check: the application was not installed by the installer"); - return false; - } + public string? ReadyVersion => _ready?.TargetFullRelease.Version.ToString(); - var update = await _manager.CheckForUpdatesAsync().ConfigureAwait(false); - if (update is null) - return false; + public async Task FindAsync(CancellationToken ct = default) + { + // The feed call has no cancellation of its own; a cancelled check is dropped when it returns. + _found = await _manager.CheckForUpdatesAsync().ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + return _found?.TargetFullRelease.Version.ToString(); + } + public async Task DownloadAsync(Action progress, CancellationToken ct = default) + { + var update = _found ?? throw new InvalidOperationException("No update was found."); _logger.LogInformation("Downloading update {Version}", update.TargetFullRelease.Version); - await _manager.DownloadUpdatesAsync(update, cancelToken: ct).ConfigureAwait(false); + await _manager.DownloadUpdatesAsync(update, progress, ct).ConfigureAwait(false); _ready = update; - return true; } public void ApplyAndRestart() => _manager.ApplyUpdatesAndRestart(Ready()); diff --git a/src/LiveLingo.Desktop/Settings/SettingsTexts.cs b/src/LiveLingo.Desktop/Settings/SettingsTexts.cs index e9806c9..b6a97b4 100644 --- a/src/LiveLingo.Desktop/Settings/SettingsTexts.cs +++ b/src/LiveLingo.Desktop/Settings/SettingsTexts.cs @@ -2,6 +2,7 @@ using LiveLingo.Core.Translation; using LiveLingo.Desktop.Composer; using LiveLingo.Desktop.Services.Localization; +using LiveLingo.Desktop.Services.Update; namespace LiveLingo.Desktop.Settings; @@ -36,6 +37,7 @@ public sealed class SettingsTexts(ILocalizationService loc) "model", "model.desc", "model.quality", "model.fast", "legacy", "legacy.remove", "version", "version.desc", + "update", "update.desc", "update.check", "update.restart", "update.download", "diag", "diag.desc", "diag.open", "diag.title" ]; @@ -69,4 +71,20 @@ public string LegacyDescription(int count, long bytes) => public string AppSummary(ComposerOutput output, TargetLanguage target) => _composer.OutputMenu(output, target); public string Date(DateTimeOffset when) => when.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + /// What the update check is doing or found, as one sentence; the user's installed version names the "latest". + public string UpdateStatus(UpdateState state, string current) => state.Phase switch + { + UpdatePhase.Off => loc.T("settings.update.off"), + UpdatePhase.Unsupported => loc.T("settings.update.unsupported"), + UpdatePhase.Idle => loc.T("settings.update.idle"), + UpdatePhase.Checking => loc.T("settings.update.checking"), + UpdatePhase.Downloading => loc.T("settings.update.downloading", state.Version!, state.Percent), + UpdatePhase.Ready => loc.T("settings.update.ready", state.Version!), + UpdatePhase.UpToDate => loc.T("settings.update.latest", current, Moment(state.At!.Value)), + UpdatePhase.Failed => loc.T("settings.update.failed", Moment(state.At!.Value), state.Error!), + _ => throw new ArgumentOutOfRangeException(nameof(state), state.Phase, "Not an update phase.") + }; + + private static string Moment(DateTimeOffset when) => when.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); } diff --git a/src/LiveLingo.Desktop/Settings/SettingsWindow.cs b/src/LiveLingo.Desktop/Settings/SettingsWindow.cs index 3c89fcb..c2e146b 100644 --- a/src/LiveLingo.Desktop/Settings/SettingsWindow.cs +++ b/src/LiveLingo.Desktop/Settings/SettingsWindow.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using Avalonia.Controls; using LiveLingo.Desktop.Web; +using LiveLingo.Desktop.Services.Update; using Microsoft.Extensions.Logging; namespace LiveLingo.Desktop.Settings; @@ -10,7 +11,7 @@ internal sealed class SettingsWindow : Window { private readonly SettingsWebSession _session; - public SettingsWindow(SettingsViewModel vm, SettingsTexts texts, string section, ILogger logger) + public SettingsWindow(SettingsViewModel vm, SettingsTexts texts, string section, UpdateChecks? updates, Action openUrl, ILogger logger) { var web = new WebSurface(WebPage.SettingsEntry, logger); Title = texts.Title; @@ -23,8 +24,9 @@ public SettingsWindow(SettingsViewModel vm, SettingsTexts texts, string section, _session = new SettingsWebSession( vm, texts, section, new AppInfo( - typeof(SettingsWindow).Assembly.GetName().Version?.ToString(3) ?? "", + AppVersion.Current, $"{RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}"), + updates, openUrl, web, SynchronizationContext.Current ?? throw new InvalidOperationException("The settings are shown from the UI thread."), TimeProvider.System, logger); diff --git a/src/LiveLingo.Desktop/Web/Assets/app/31-settings.js b/src/LiveLingo.Desktop/Web/Assets/app/31-settings.js index 7036bba..847ccdc 100644 --- a/src/LiveLingo.Desktop/Web/Assets/app/31-settings.js +++ b/src/LiveLingo.Desktop/Web/Assets/app/31-settings.js @@ -220,6 +220,18 @@ } } + // The check and download report here; the progress bar is the download's, and a check without a known length + // (looking at the feed) only shows that it is busy. + const Update = ({ u }) => html`<${Row} id="update-action" title=${LL.t('update')} desc=${LL.t('update.desc')} + extra=${html`

${u.status}

+ ${u.busy && html`
+
`}`}> + ${(u.canCheck || u.busy) && html``} + ${u.canRestart && html``} + ${u.canDownload && html``} + `; + const Models = ({ m }) => html`
${['models.speech', 'models.clean', 'models.translate'].map((key) => html`${LL.t(key)}`)}
<${Row} id="tier" title=${LL.t('model')} desc=${LL.t('model.desc')}> @@ -232,6 +244,7 @@ <${Row} id="version" title=${LL.t('version')} desc=${LL.t('version.desc')}> ${m.version} + <${Update} u=${m.update}/>
`; // Turning a cloud step on needs the user's consent every time. C# says when to ask; the safe answer is the first button. diff --git a/src/LiveLingo.Desktop/Web/Assets/styles/settings.css b/src/LiveLingo.Desktop/Web/Assets/styles/settings.css index c028da4..494c0b8 100644 --- a/src/LiveLingo.Desktop/Web/Assets/styles/settings.css +++ b/src/LiveLingo.Desktop/Web/Assets/styles/settings.css @@ -25,6 +25,11 @@ button.danger:hover { background: var(--hover); } .chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 16px 0; } +.progress { position: relative; height: 6px; margin-top: 8px; overflow: hidden; border-radius: 999px; background: var(--line-strong); } +.progress span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .2s; } +.progress.indeterminate span { width: 35%; animation: slide 1.1s ease-in-out infinite; } +@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(300%); } } + .polish { padding-top: 12px; border-top: 1px solid var(--line); } .polish-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .polish-head h2 { display: flex; align-items: center; gap: 8px; } diff --git a/src/LiveLingo.Desktop/Web/SettingsIntents.cs b/src/LiveLingo.Desktop/Web/SettingsIntents.cs index f933cdf..26cfd8f 100644 --- a/src/LiveLingo.Desktop/Web/SettingsIntents.cs +++ b/src/LiveLingo.Desktop/Web/SettingsIntents.cs @@ -1,3 +1,4 @@ +using LiveLingo.Desktop.Services.Update; using LiveLingo.Desktop.Settings; using Microsoft.Extensions.Logging; @@ -10,12 +11,16 @@ namespace LiveLingo.Desktop.Web; internal sealed class SettingsIntents : IIntentHandler { private readonly SettingsViewModel _vm; + private readonly UpdateChecks? _updates; + private readonly Action _openUrl; private readonly ILogger _logger; - public SettingsIntents(SettingsViewModel vm, string section, ILogger logger) + public SettingsIntents(SettingsViewModel vm, string section, UpdateChecks? updates, Action openUrl, ILogger logger) { _vm = vm; Section = section; + _updates = updates; + _openUrl = openUrl; _logger = logger; } @@ -51,6 +56,15 @@ public void Handle(WebIntent intent) case ForgetAppIntent forget: _vm.ForgetApp(forget.AppId); break; case ClearAppsIntent: _vm.ClearApps(); break; case RemoveLegacyModelsIntent: Run(() => _vm.RemoveLegacyModelsCommand.ExecuteAsync(null)); break; + case CheckUpdatesIntent: + if (_updates is not null) + Run(() => _updates.CheckNowAsync()); + break; + case RestartToUpdateIntent: _updates?.ApplyAndRestart(); break; + case OpenDownloadsIntent: + if (_updates?.DownloadPage is { } page) + _openUrl(page); + break; default: throw new ArgumentOutOfRangeException(nameof(intent), intent.GetType().Name, "Not a settings intent."); } diff --git a/src/LiveLingo.Desktop/Web/SettingsViewState.cs b/src/LiveLingo.Desktop/Web/SettingsViewState.cs index 4de2d0d..f29316f 100644 --- a/src/LiveLingo.Desktop/Web/SettingsViewState.cs +++ b/src/LiveLingo.Desktop/Web/SettingsViewState.cs @@ -1,5 +1,6 @@ using System.Globalization; using LiveLingo.Desktop.Settings; +using LiveLingo.Desktop.Services.Update; namespace LiveLingo.Desktop.Web; @@ -34,7 +35,22 @@ internal sealed record LegacyModelsState(int Count, string Text); internal sealed record DiagnosticLine(string Label, string Value); internal sealed record ModelsSettingsState( - string Tier, LegacyModelsState Legacy, string Version, IReadOnlyList Diagnostics); + string Tier, LegacyModelsState Legacy, string Version, UpdateViewState Update, IReadOnlyList Diagnostics); + +/// What the update row draws: a sentence, the download progress while there is one, and which buttons make sense. +/// The download progress; null when nothing is being downloaded. +/// A check or download is under way; the page shows it and offers nothing else. +/// The copy cannot update itself and the releases page is where to get a new installer. +internal sealed record UpdateViewState(string Status, int? Percent, bool Busy, bool CanCheck, bool CanRestart, bool CanDownload) +{ + public static UpdateViewState From(UpdateState state, bool hasDownloadPage, SettingsTexts texts, string current) => new( + texts.UpdateStatus(state, current), + state.Phase == UpdatePhase.Downloading ? state.Percent : null, + state.Busy, + state.Phase is UpdatePhase.Idle or UpdatePhase.UpToDate or UpdatePhase.Failed, + state.Phase == UpdatePhase.Ready, + state.Phase == UpdatePhase.Unsupported && hasDownloadPage); +} /// What the host knows about itself: the app version and the system it runs on. internal sealed record AppInfo(string Version, string Platform); @@ -55,7 +71,7 @@ internal sealed record SettingsViewState( ModelsSettingsState Models, SettingsOptionsState Options) { - public static SettingsViewState From(SettingsViewModel vm, SettingsTexts texts, string section, AppInfo app) + public static SettingsViewState From(SettingsViewModel vm, SettingsTexts texts, string section, AppInfo app, UpdateViewState update) { var theme = SettingsViewModel.Themes[vm.ThemeIndex]; return new SettingsViewState( @@ -88,6 +104,7 @@ public static SettingsViewState From(SettingsViewModel vm, SettingsTexts texts, SettingsViewModel.Tiers[vm.TierIndex], new LegacyModelsState(vm.LegacyCount, vm.HasLegacyModels ? texts.LegacyDescription(vm.LegacyCount, vm.LegacyBytes) : ""), app.Version, + update, Diagnostics(vm, texts, app)), new SettingsOptionsState(!vm.ReduceMotion, theme)); } diff --git a/src/LiveLingo.Desktop/Web/SettingsWebSession.cs b/src/LiveLingo.Desktop/Web/SettingsWebSession.cs index 606c5ed..7511f29 100644 --- a/src/LiveLingo.Desktop/Web/SettingsWebSession.cs +++ b/src/LiveLingo.Desktop/Web/SettingsWebSession.cs @@ -1,3 +1,4 @@ +using LiveLingo.Desktop.Services.Update; using LiveLingo.Desktop.Settings; using Microsoft.Extensions.Logging; @@ -12,6 +13,8 @@ internal sealed class SettingsWebSession : IDisposable private readonly WebBridge _bridge; private readonly SettingsIntents _intents; private readonly SettingsViewModel _vm; + private readonly UpdateChecks? _updates; + private readonly SynchronizationContext _ui; private readonly ILogger _logger; public SettingsWebSession( @@ -19,23 +22,29 @@ public SettingsWebSession( SettingsTexts texts, string section, AppInfo app, + UpdateChecks? updates, + Action openUrl, IWebHost host, SynchronizationContext ui, TimeProvider time, ILogger logger) { _vm = vm; + _updates = updates; + _ui = ui; _logger = logger; - _intents = new SettingsIntents(vm, section, logger); + _intents = new SettingsIntents(vm, section, updates, openUrl, logger); _bridge = new WebBridge( host, "settings", - rev => SettingsViewState.From(vm, texts, _intents.Section, app) with { Rev = rev }, + rev => SettingsViewState.From(vm, texts, _intents.Section, app, UpdateView(texts, app)) with { Rev = rev }, () => new StringsData(texts.Locale, texts.WebStrings()), _intents, ui, time, logger); vm.PropertyChanged += OnViewModelChanged; _intents.Changed += _bridge.Invalidate; _bridge.Started += ReadSystem; + if (updates is not null) + updates.StateChanged += OnUpdateChanged; } /// Shows another group of settings (the composer asked for one while the window is open). @@ -48,11 +57,19 @@ public void ShowSection(string section) public void Dispose() { _vm.PropertyChanged -= OnViewModelChanged; + if (_updates is not null) + _updates.StateChanged -= OnUpdateChanged; _bridge.Dispose(); } + private UpdateViewState UpdateView(SettingsTexts texts, AppInfo app) => + UpdateViewState.From(_updates?.State ?? UpdateState.Off, _updates?.DownloadPage is not null, texts, app.Version); + private void OnViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => _bridge.Invalidate(); + // The check runs on a background thread; the bridge is the UI thread's. + private void OnUpdateChanged() => _ui.Post(_ => _bridge.Invalidate(), null); + /// What the system provides (the stored key, the devices, the models on disk) is read once the page is up. private void ReadSystem() => Observed.Run(_vm.InitializeAsync, _logger, "Reading the system state for the settings"); } diff --git a/src/LiveLingo.Desktop/Web/WebIntent.cs b/src/LiveLingo.Desktop/Web/WebIntent.cs index 1beefae..04ee027 100644 --- a/src/LiveLingo.Desktop/Web/WebIntent.cs +++ b/src/LiveLingo.Desktop/Web/WebIntent.cs @@ -50,6 +50,9 @@ namespace LiveLingo.Desktop.Web; [JsonDerivedType(typeof(SaveKeyIntent), "saveKey")] [JsonDerivedType(typeof(ForgetKeyIntent), "forgetKey")] [JsonDerivedType(typeof(RemoveLegacyModelsIntent), "removeLegacyModels")] +[JsonDerivedType(typeof(CheckUpdatesIntent), "checkUpdates")] +[JsonDerivedType(typeof(RestartToUpdateIntent), "restartToUpdate")] +[JsonDerivedType(typeof(OpenDownloadsIntent), "openDownloads")] [JsonDerivedType(typeof(ForgetAppIntent), "forgetApp")] [JsonDerivedType(typeof(ClearAppsIntent), "clearApps")] internal abstract record WebIntent @@ -290,6 +293,15 @@ internal sealed record ForgetKeyIntent : WebIntent; internal sealed record RemoveLegacyModelsIntent : WebIntent; +/// "Check for updates" of the settings page. +internal sealed record CheckUpdatesIntent : WebIntent; + +/// "Restart to update": installs the downloaded version and starts it. +internal sealed record RestartToUpdateIntent : WebIntent; + +/// Opens the page the installers of the latest version are downloaded from. +internal sealed record OpenDownloadsIntent : WebIntent; + /// Forgets what was remembered for one application. internal sealed record ForgetAppIntent(string AppId) : WebIntent { diff --git a/tests/LiveLingo.Core.Tests/UserDataTests.cs b/tests/LiveLingo.Core.Tests/UserDataTests.cs new file mode 100644 index 0000000..c5f0b91 --- /dev/null +++ b/tests/LiveLingo.Core.Tests/UserDataTests.cs @@ -0,0 +1,30 @@ +namespace LiveLingo.Core.Tests; + +public sealed class UserDataTests +{ + [Fact] + public void OnMacOS_TheDataIsUnderApplicationSupport() => + Assert.Equal( + Path.Combine("/Users/a", "Library", "Application Support", "LiveLingo"), + UserData.Resolve(macOS: true, home: "/Users/a", localAppData: "/ignored")); + + [Fact] + public void OnWindows_TheDataIsNextToTheInstallersFolder_NotInIt() + { + var local = Path.Combine("C:", "Users", "a", "AppData", "Local"); + + var data = UserData.Resolve(macOS: false, home: "ignored", localAppData: local); + + Assert.Equal(Path.Combine(local, "AGIBuild", "LiveLingo"), data); + Assert.DoesNotContain(Path.Combine(local, "LiveLingo") + Path.DirectorySeparatorChar, data + Path.DirectorySeparatorChar); + } + + [Fact] + public void TheModelsAndTheRuntimeState_LiveInTheDataFolder() + { + var options = new CoreOptions(); + + Assert.Equal(Path.Combine(UserData.Directory, "models"), options.ModelStoragePath); + Assert.Equal(Path.Combine(UserData.Directory, "run"), options.RuntimeStatePath); + } +} diff --git a/tests/LiveLingo.Desktop.Tests/AppVersionTests.cs b/tests/LiveLingo.Desktop.Tests/AppVersionTests.cs new file mode 100644 index 0000000..f5d7eb7 --- /dev/null +++ b/tests/LiveLingo.Desktop.Tests/AppVersionTests.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using System.Reflection.Emit; + +namespace LiveLingo.Desktop.Tests; + +public sealed class AppVersionTests +{ + private static Assembly AssemblyVersioned(string informational) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("Versioned"), AssemblyBuilderAccess.Run); + var constructor = typeof(AssemblyInformationalVersionAttribute).GetConstructor([typeof(string)])!; + assembly.SetCustomAttribute(new CustomAttributeBuilder(constructor, [informational])); + return assembly; + } + + [Theory] + [InlineData("2.1.0+0123abc", "2.1.0")] + [InlineData("2.1.0-rc.1+0123abc", "2.1.0-rc.1")] + [InlineData("2.1.0", "2.1.0")] + public void TheVersion_IsTheReleaseText_WithoutTheSourceRevision(string stamped, string shown) => + Assert.Equal(shown, AppVersion.Read(AssemblyVersioned(stamped))); + + [Fact] + public void ABuildThatWasNotStamped_ShowsThatItIsNoRelease() => + Assert.Equal("0.0.0-dev", AppVersion.Current); +} diff --git a/tests/LiveLingo.Desktop.Tests/Services/Configuration/UserDataMigrationTests.cs b/tests/LiveLingo.Desktop.Tests/Services/Configuration/UserDataMigrationTests.cs new file mode 100644 index 0000000..dfd5b90 --- /dev/null +++ b/tests/LiveLingo.Desktop.Tests/Services/Configuration/UserDataMigrationTests.cs @@ -0,0 +1,89 @@ +using LiveLingo.Desktop.Services.Configuration; + +namespace LiveLingo.Desktop.Tests.Services.Configuration; + +public sealed class UserDataMigrationTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "livelingo-migration-" + Guid.NewGuid().ToString("N")); + private string Legacy => Path.Combine(_root, "LiveLingo"); + private string Current => Path.Combine(_root, "AGIBuild", "LiveLingo"); + + public void Dispose() + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + + private void LegacyFile(string relative, string text) + { + var path = Path.Combine(Legacy, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, text); + } + + [Fact] + public void TheSettingsAndTheModels_MoveWithoutCopying_AndTheInstallersFilesStay() + { + LegacyFile("settings.json", "{}"); + LegacyFile("preferences.json", "[]"); + LegacyFile(Path.Combine("models", "m1", "model.gguf"), "weights"); + LegacyFile(Path.Combine("current", "LiveLingo.Desktop.dll"), "app"); + LegacyFile("Update.exe", "updater"); + + var moves = UserDataMigration.Run(Legacy, Current); + + Assert.Equal(["settings.json", "preferences.json", "models"], moves.Select(move => move.Name)); + Assert.All(moves, move => Assert.Null(move.Error)); + Assert.Equal("weights", File.ReadAllText(Path.Combine(Current, "models", "m1", "model.gguf"))); + Assert.Equal("{}", File.ReadAllText(Path.Combine(Current, "settings.json"))); + Assert.False(File.Exists(Path.Combine(Legacy, "settings.json"))); + Assert.False(Directory.Exists(Path.Combine(Legacy, "models"))); + Assert.True(File.Exists(Path.Combine(Legacy, "current", "LiveLingo.Desktop.dll"))); + Assert.True(File.Exists(Path.Combine(Legacy, "Update.exe"))); + } + + [Fact] + public void WhereThereIsNothingToMove_NothingHappens() + { + Assert.Empty(UserDataMigration.Run(Legacy, Current)); + Assert.False(Directory.Exists(Current)); + } + + [Fact] + public void ItRunsOnce_TheSecondStartFindsNothing() + { + LegacyFile("settings.json", "{}"); + + Assert.Single(UserDataMigration.Run(Legacy, Current)); + Assert.Empty(UserDataMigration.Run(Legacy, Current)); + } + + [Fact] + public void WhatTheDataFolderAlreadyHas_IsNeverReplaced() + { + LegacyFile("settings.json", "old"); + Directory.CreateDirectory(Current); + File.WriteAllText(Path.Combine(Current, "settings.json"), "new"); + + Assert.Empty(UserDataMigration.Run(Legacy, Current)); + + Assert.Equal("new", File.ReadAllText(Path.Combine(Current, "settings.json"))); + Assert.Equal("old", File.ReadAllText(Path.Combine(Legacy, "settings.json"))); + } + + [Fact] + public void AnEntryThatCannotBeMoved_IsReported_AndStaysWhereItWas() + { + LegacyFile("settings.json", "{}"); + // A file where the data folder should be makes every move fail. + Directory.CreateDirectory(Path.GetDirectoryName(Current)!); + File.WriteAllText(Current, "in the way"); + + var moves = UserDataMigration.Run(Legacy, Current); + + var move = Assert.Single(moves); + Assert.Equal("settings.json", move.Name); + Assert.NotNull(move.Error); + Assert.Equal("{}", File.ReadAllText(Path.Combine(Legacy, "settings.json"))); + } +} diff --git a/tests/LiveLingo.Desktop.Tests/Services/Update/UpdateChecksTests.cs b/tests/LiveLingo.Desktop.Tests/Services/Update/UpdateChecksTests.cs index c2a6d9c..bda866c 100644 --- a/tests/LiveLingo.Desktop.Tests/Services/Update/UpdateChecksTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Services/Update/UpdateChecksTests.cs @@ -1,7 +1,6 @@ using LiveLingo.Desktop.Services.Update; using LiveLingo.Desktop.Tests.Composer; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace LiveLingo.Desktop.Tests.Services.Update; @@ -9,20 +8,51 @@ public sealed class UpdateChecksTests { private static readonly TimeSpan Interval = TimeSpan.FromHours(4); - private readonly IUpdateService _service = Substitute.For(); - private readonly FakeClock _time = new(); - - public UpdateChecksTests() + private sealed class FakeService : IUpdateService { - // A substitute answers a string property with "", which would read as a downloaded version. - _service.ReadyVersion.Returns((string?)null); + public bool CanUpdate { get; set; } = true; + public string? DownloadPage { get; set; } + public string? ReadyVersion { get; private set; } + public Func> Find { get; set; } = () => Task.FromResult(null); + public Func, Task> Download { get; set; } = _ => Task.CompletedTask; + public int Finds { get; private set; } + public int Restarts { get; private set; } + public int ExitInstalls { get; private set; } + private string? _found; + + public async Task FindAsync(CancellationToken ct = default) + { + Finds++; + ct.ThrowIfCancellationRequested(); + return _found = await Find(); + } + + public async Task DownloadAsync(Action progress, CancellationToken ct = default) + { + await Download(progress); + ReadyVersion = _found; + } + + public void ApplyAndRestart() => Restarts++; + + public void ApplyOnExit() => ExitInstalls++; } - private UpdateChecks Create() => new(_service, Interval, NullLogger.Instance, _time); + private readonly FakeService _service = new(); + private readonly FakeClock _time = new(); - private async Task Settled() => await Task.Delay(50, TestContext.Current.CancellationToken); + private UpdateChecks Create() => new(_service, Interval, NullLogger.Instance, _time); - private int Checks => _service.ReceivedCalls().Count(call => call.GetMethodInfo().Name == nameof(IUpdateService.CheckAndDownloadAsync)); + private static List Record(UpdateChecks checks) + { + var seen = new List(); + checks.StateChanged += () => + { + lock (seen) + seen.Add(checks.State); + }; + return seen; + } [Fact] public async Task Start_ChecksAtOnce_AndThenEveryInterval() @@ -30,89 +60,165 @@ public async Task Start_ChecksAtOnce_AndThenEveryInterval() using var checks = Create(); checks.Start(); - await Wait.Until(() => Checks == 1); + await Wait.Until(() => _service.Finds == 1); _time.Advance(Interval); - await Wait.Until(() => Checks == 2); + await Wait.Until(() => _service.Finds == 2); + } + + [Fact] + public async Task ACopyThatCannotUpdate_SaysSo_AndNeverLooks() + { + _service.CanUpdate = false; + using var checks = Create(); + var seen = Record(checks); + + checks.Start(); + await checks.CheckNowAsync(TestContext.Current.CancellationToken); + _time.Advance(Interval); + + Assert.Equal(UpdatePhase.Unsupported, checks.State.Phase); + Assert.Equal(0, _service.Finds); + Assert.Empty(seen); + } + + [Fact] + public async Task TheLatestVersion_IsReportedWithTheTimeOfTheCheck() + { + using var checks = Create(); + var seen = Record(checks); + + await checks.CheckNowAsync(TestContext.Current.CancellationToken); + + Assert.Equal([UpdatePhase.Checking, UpdatePhase.UpToDate], seen.Select(state => state.Phase)); + Assert.Equal(_time.GetUtcNow(), checks.State.At); } [Fact] - public async Task ADownloadedVersion_IsAnnouncedOnce_AndEndsTheChecks() + public async Task ANewerVersion_IsDownloadedWithItsProgress_AndAnnouncedOnce() { - _service.CheckAndDownloadAsync(Arg.Any()).Returns(true); - _service.ReadyVersion.Returns(null, "2.0.1"); + _service.Find = () => Task.FromResult("2.2.0"); + _service.Download = progress => + { + progress(0); + progress(40); + progress(40); + progress(250); + return Task.CompletedTask; + }; using var checks = Create(); + var seen = Record(checks); var ready = new List(); checks.Ready += ready.Add; + await checks.CheckNowAsync(TestContext.Current.CancellationToken); + + Assert.Equal( + [UpdatePhase.Checking, UpdatePhase.Downloading, UpdatePhase.Downloading, UpdatePhase.Downloading, UpdatePhase.Ready], + seen.Select(state => state.Phase)); + Assert.Equal([0, 40, 100], seen.Where(state => state.Phase == UpdatePhase.Downloading).Select(state => state.Percent)); + Assert.Equal(new UpdateState(UpdatePhase.Ready, "2.2.0"), checks.State); + Assert.Equal(["2.2.0"], ready); + Assert.Equal("2.2.0", checks.ReadyVersion); + } + + [Fact] + public async Task AfterADownload_TheChecksEnd() + { + _service.Find = () => Task.FromResult("2.2.0"); + using var checks = Create(); + checks.Start(); - await Wait.Until(() => ready.Count == 1); + await Wait.Until(() => checks.State.Phase == UpdatePhase.Ready); _time.Advance(Interval); - await Settled(); + await checks.CheckNowAsync(TestContext.Current.CancellationToken); - Assert.Equal(["2.0.1"], ready); - await _service.Received(1).CheckAndDownloadAsync(Arg.Any()); + Assert.Equal(1, _service.Finds); } [Fact] - public async Task ABackgroundFailure_IsNotFatal_AndTheNextIntervalTriesAgain() + public async Task AFailure_BecomesTheState_AndTheNextIntervalTriesAgain() { - _service.CheckAndDownloadAsync(Arg.Any()) - .Returns(Task.FromException(new HttpRequestException("offline")), Task.FromResult(false)); + var attempt = 0; + _service.Find = () => ++attempt == 1 + ? Task.FromException(new HttpRequestException("offline")) + : Task.FromResult(null); using var checks = Create(); checks.Start(); - await Wait.Until(() => Checks == 1); - _time.Advance(Interval); - await Wait.Until(() => Checks == 2); + await Wait.Until(() => checks.State.Phase == UpdatePhase.Failed); + + Assert.Equal("offline", checks.State.Error); + Assert.Equal(_time.GetUtcNow(), checks.State.At); - await _service.Received(2).CheckAndDownloadAsync(Arg.Any()); + _time.Advance(Interval); + await Wait.Until(() => checks.State.Phase == UpdatePhase.UpToDate); } [Fact] - public async Task ACheckTheUserAsksFor_ReportsTheFailure() + public async Task ACheckWhileOneRuns_DoesNotStartAnother() { - _service.CheckAndDownloadAsync(Arg.Any()) - .Returns(Task.FromException(new HttpRequestException("offline"))); + var gate = new TaskCompletionSource(); + _service.Find = () => gate.Task; using var checks = Create(); - await Assert.ThrowsAsync(() => checks.CheckNowAsync(TestContext.Current.CancellationToken)); + var first = checks.CheckNowAsync(TestContext.Current.CancellationToken); + await Wait.Until(() => checks.State.Phase == UpdatePhase.Checking); + await checks.CheckNowAsync(TestContext.Current.CancellationToken); + gate.SetResult(null); + await first; + + Assert.Equal(1, _service.Finds); } + [Theory] + [InlineData(UpdatePhase.Checking, true)] + [InlineData(UpdatePhase.Downloading, true)] + [InlineData(UpdatePhase.Idle, false)] + [InlineData(UpdatePhase.Ready, false)] + [InlineData(UpdatePhase.Failed, false)] + public void OnlyACheckAndADownload_AreBusy(UpdatePhase phase, bool busy) => + Assert.Equal(busy, new UpdateState(phase).Busy); + [Fact] - public async Task ACheckTheUserAsksFor_DoesNotLookAgain_WhenAVersionIsAlreadyReady() + public async Task ACancelledCheck_LeavesNoResult() { - _service.ReadyVersion.Returns("2.0.1"); using var checks = Create(); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); - Assert.True(await checks.CheckNowAsync(TestContext.Current.CancellationToken)); + await checks.CheckNowAsync(cancelled.Token); - await _service.DidNotReceive().CheckAndDownloadAsync(Arg.Any()); + Assert.Equal(UpdatePhase.Idle, checks.State.Phase); } [Fact] - public async Task NoNewerVersion_IsNotAnnounced() + public async Task ADownloadedVersion_IsNotLookedForAgain() { - _service.CheckAndDownloadAsync(Arg.Any()).Returns(false); + _service.Find = () => Task.FromResult("2.2.0"); using var checks = Create(); - var ready = new List(); - checks.Ready += ready.Add; + await checks.CheckNowAsync(TestContext.Current.CancellationToken); - Assert.False(await checks.CheckNowAsync(TestContext.Current.CancellationToken)); + await checks.CheckNowAsync(TestContext.Current.CancellationToken); - Assert.Empty(ready); + Assert.Equal(1, _service.Finds); } [Fact] - public void QuittingInstallsTheDownloadedVersion_AndDoesNothingWithout() + public async Task RestartingAndQuitting_InstallOnlyTheDownloadedVersion() { using var checks = Create(); + checks.ApplyAndRestart(); checks.ApplyOnExit(); - _service.DidNotReceive().ApplyOnExit(); + Assert.Equal(0, _service.Restarts + _service.ExitInstalls); - _service.ReadyVersion.Returns("2.0.1"); + _service.Find = () => Task.FromResult("2.2.0"); + await checks.CheckNowAsync(TestContext.Current.CancellationToken); + checks.ApplyAndRestart(); checks.ApplyOnExit(); - _service.Received(1).ApplyOnExit(); + + Assert.Equal(1, _service.Restarts); + Assert.Equal(1, _service.ExitInstalls); } } diff --git a/tests/LiveLingo.Desktop.Tests/Web/SettingsIntentsTests.cs b/tests/LiveLingo.Desktop.Tests/Web/SettingsIntentsTests.cs index 4f70503..4959a74 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/SettingsIntentsTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/SettingsIntentsTests.cs @@ -3,10 +3,12 @@ using LiveLingo.Desktop.Composer; using LiveLingo.Desktop.Services.Configuration; using LiveLingo.Desktop.Services.Preferences; +using LiveLingo.Desktop.Services.Update; using LiveLingo.Desktop.Settings; using LiveLingo.Desktop.Tests.Composer; using LiveLingo.Desktop.Tests.Settings; using LiveLingo.Desktop.Web; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; namespace LiveLingo.Desktop.Tests.Web; @@ -17,12 +19,15 @@ private sealed class Rig { public SettingsViewModelTests.Fixture F { get; } public ListLogger Log { get; } = new(); + public List Opened { get; } = []; public SettingsIntents Sut { get; } - public Rig(SettingsModel? model = null, string? storedKey = null, string section = "general", PreferencesData? remembered = null) + public Rig( + SettingsModel? model = null, string? storedKey = null, string section = "general", PreferencesData? remembered = null, + UpdateChecks? updates = null) { F = new SettingsViewModelTests.Fixture(model, storedKey, remembered); - Sut = new SettingsIntents(F.Vm, section, Log); + Sut = new SettingsIntents(F.Vm, section, updates, Opened.Add, Log); } public SettingsViewModel Vm => F.Vm; @@ -265,6 +270,65 @@ public async Task ModelsFromEarlierVersions_AreRemovedOnRequest() await rig.F.Models.Received(1).CleanObsoleteModelsAsync(Arg.Any>(), Arg.Any()); } + private static (UpdateChecks Checks, IUpdateService Service) Updates(string? ready = null, string? page = null) + { + var service = Substitute.For(); + service.CanUpdate.Returns(true); + service.ReadyVersion.Returns(ready); + service.DownloadPage.Returns(page); + service.FindAsync(Arg.Any()).Returns(Task.FromResult(null)); + return (new UpdateChecks(service, TimeSpan.FromHours(4), NullLogger.Instance), service); + } + + [Fact] + public async Task CheckingForUpdates_AsksTheFeed() + { + var (checks, service) = Updates(); + var rig = new Rig(updates: checks); + + rig.Do(new CheckUpdatesIntent { V = 1 }); + + await Wait.Until(() => checks.State.Phase == UpdatePhase.UpToDate); + await service.Received(1).FindAsync(Arg.Any()); + } + + [Fact] + public void RestartToUpdate_InstallsTheDownloadedVersion_AndNothingWithout() + { + var (idle, notReady) = Updates(); + new Rig(updates: idle).Do(new RestartToUpdateIntent { V = 1 }); + notReady.DidNotReceive().ApplyAndRestart(); + + var (checks, service) = Updates(ready: "2.2.0"); + new Rig(updates: checks).Do(new RestartToUpdateIntent { V = 1 }); + + service.Received(1).ApplyAndRestart(); + } + + [Fact] + public void TheDownloadPage_IsOpenedWhenThereIsOne() + { + var rig = new Rig(updates: Updates(page: "https://github.com/o/r/releases/latest").Checks); + rig.Do(new OpenDownloadsIntent { V = 1 }); + Assert.Equal(["https://github.com/o/r/releases/latest"], rig.Opened); + + var without = new Rig(updates: Updates().Checks); + without.Do(new OpenDownloadsIntent { V = 1 }); + Assert.Empty(without.Opened); + } + + [Fact] + public void WithUpdatesOff_TheUpdateIntentsDoNothing() + { + var rig = new Rig(); + + rig.Do(new CheckUpdatesIntent { V = 1 }); + rig.Do(new RestartToUpdateIntent { V = 1 }); + rig.Do(new OpenDownloadsIntent { V = 1 }); + + Assert.Empty(rig.Opened); + } + [Fact] public void TheSection_IsOwnedHere_AndChangesWithTheNavigation() { diff --git a/tests/LiveLingo.Desktop.Tests/Web/SettingsScenarios.cs b/tests/LiveLingo.Desktop.Tests/Web/SettingsScenarios.cs index c0e40f2..66be46e 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/SettingsScenarios.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/SettingsScenarios.cs @@ -3,6 +3,7 @@ using LiveLingo.Desktop.Composer; using LiveLingo.Desktop.Services.Configuration; using LiveLingo.Desktop.Services.Preferences; +using LiveLingo.Desktop.Services.Update; using LiveLingo.Desktop.Settings; using LiveLingo.Desktop.Tests.Settings; using LiveLingo.Desktop.Web; @@ -59,12 +60,24 @@ internal static class SettingsScenarios m.Composer.Microphone = "USB Mic"; }, key: "sk-stored"), ["settings-models"] = () => Open("models", before: f => f.Models.ListInstalled().Returns([Legacy("old-a", 5_500_000_000), Legacy("old-b", 3_000_000_000)])), + ["settings-update-checking"] = () => Open("models", update: new UpdateState(UpdatePhase.Checking)), + ["settings-update-downloading"] = () => Open("models", update: new UpdateState(UpdatePhase.Downloading, "2.2.0", 42)), + ["settings-update-ready"] = () => Open("models", update: new UpdateState(UpdatePhase.Ready, "2.2.0")), + ["settings-update-latest"] = () => Open("models", update: new UpdateState(UpdatePhase.UpToDate, At: CheckedAt)), + ["settings-update-failed"] = () => Open("models", update: new UpdateState(UpdatePhase.Failed, At: CheckedAt, Error: "Unable to connect to github.com")), + ["settings-update-unsupported"] = () => Open("models", update: new UpdateState(UpdatePhase.Unsupported), downloadPage: true), + ["settings-update-off"] = () => Open("models", update: UpdateState.Off), }; public static AppInfo App { get; } = new("2.0.0", "macOS 15.0 Arm64"); - public static SettingsViewState State(SettingsViewModel vm, string section = "general") => - SettingsViewState.From(vm, Texts.ChineseSettings(), section, App) with { Rev = 1 }; + // Built from a local time so the text does not depend on the time zone the tests run in. + private static DateTimeOffset CheckedAt { get; } = new(new DateTime(2026, 11, 3, 19, 5, 0, DateTimeKind.Local)); + + public static SettingsViewState State(SettingsViewModel vm, string section = "general", UpdateState? update = null, bool downloadPage = false) => + SettingsViewState.From( + vm, Texts.ChineseSettings(), section, App, + UpdateViewState.From(update ?? new UpdateState(UpdatePhase.Idle), downloadPage, Texts.ChineseSettings(), App.Version)) with { Rev = 1 }; private static void Cloud(SettingsModel model) { @@ -84,7 +97,9 @@ private static async Task Open( string? key = null, PreferencesData? remembered = null, Action? before = null, - Action? then = null) + Action? then = null, + UpdateState? update = null, + bool downloadPage = false) { var current = new SettingsModel(); model?.Invoke(current); @@ -92,6 +107,6 @@ private static async Task Open( before?.Invoke(f); await f.Vm.InitializeAsync(); then?.Invoke(f); - return State(f.Vm, section); + return State(f.Vm, section, update, downloadPage); } } diff --git a/tests/LiveLingo.Desktop.Tests/Web/SettingsViewStateTests.cs b/tests/LiveLingo.Desktop.Tests/Web/SettingsViewStateTests.cs index 504c094..2b38b43 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/SettingsViewStateTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/SettingsViewStateTests.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using LiveLingo.Desktop.Services.Localization; +using LiveLingo.Desktop.Services.Update; using LiveLingo.Desktop.Settings; using LiveLingo.Desktop.Web; @@ -73,6 +74,47 @@ public async Task TheGroupsStartOnTheCurrentSettings() Assert.Equal(new SettingsOptionsState(Animations: false, Theme: "Dark"), general.Options); } + [Fact] + public void EveryUpdatePhase_HasATextInBothLanguages() + { + var done = new DateTimeOffset(2026, 11, 3, 11, 5, 0, TimeSpan.Zero); + foreach (var culture in new[] { "zh-CN", "en-US" }) + { + var loc = new LocalizationService(); + loc.SetCulture(culture); + var texts = new SettingsTexts(loc); + + foreach (var phase in Enum.GetValues()) + { + var state = new UpdateState(phase, "2.2.0", 7, done, "offline"); + var text = texts.UpdateStatus(state, "2.1.0"); + + Assert.False(string.IsNullOrWhiteSpace(text), $"{culture} {phase}"); + Assert.DoesNotContain("settings.update", text, StringComparison.Ordinal); + } + } + } + + [Theory] + [InlineData(UpdatePhase.Idle, false, "check")] + [InlineData(UpdatePhase.UpToDate, false, "check")] + [InlineData(UpdatePhase.Failed, false, "check")] + [InlineData(UpdatePhase.Checking, false, "busy")] + [InlineData(UpdatePhase.Downloading, false, "busy")] + [InlineData(UpdatePhase.Ready, false, "restart")] + [InlineData(UpdatePhase.Unsupported, true, "download")] + [InlineData(UpdatePhase.Unsupported, false, "")] + [InlineData(UpdatePhase.Off, true, "")] + public void TheUpdateRow_OffersWhatTheStateAllows(UpdatePhase phase, bool page, string offered) + { + var view = UpdateViewState.From(new UpdateState(phase, "2.2.0", 5, DateTimeOffset.UnixEpoch, "x"), page, Texts.ChineseSettings(), "2.1.0"); + + var actual = new[] { (view.CanCheck, "check"), (view.Busy, "busy"), (view.CanRestart, "restart"), (view.CanDownload, "download") } + .Where(flag => flag.Item1).Select(flag => flag.Item2); + Assert.Equal(offered == "" ? [] : [offered], actual); + Assert.Equal(phase == UpdatePhase.Downloading ? 5 : null, view.Percent); + } + [Fact] public async Task AnInvalidHotkey_IsFlagged_AndKeepsWhatWasTyped() { diff --git a/tests/LiveLingo.Desktop.Tests/Web/SettingsWebSessionTests.cs b/tests/LiveLingo.Desktop.Tests/Web/SettingsWebSessionTests.cs index 4987ced..1bebefd 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/SettingsWebSessionTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/SettingsWebSessionTests.cs @@ -1,9 +1,12 @@ using System.Text.Json; using LiveLingo.Desktop.Services.Configuration; +using LiveLingo.Desktop.Services.Update; using LiveLingo.Desktop.Settings; using LiveLingo.Desktop.Tests.Composer; using LiveLingo.Desktop.Tests.Settings; using LiveLingo.Desktop.Web; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; namespace LiveLingo.Desktop.Tests.Web; @@ -16,10 +19,10 @@ private sealed class Rig : IDisposable public FakeClock Time { get; } = new(); public SettingsWebSession Session { get; } - public Rig(SettingsModel? model = null, string? storedKey = null, string section = "general") + public Rig(SettingsModel? model = null, string? storedKey = null, string section = "general", UpdateChecks? updates = null) { F = new SettingsViewModelTests.Fixture(model, storedKey); - Session = new SettingsWebSession(F.Vm, Texts.ChineseSettings(), section, SettingsScenarios.App, Host, new InlineContext(), Time, new ListLogger()); + Session = new SettingsWebSession(F.Vm, Texts.ChineseSettings(), section, SettingsScenarios.App, updates, _ => { }, Host, new InlineContext(), Time, new ListLogger()); Host.FromPage("""{"v":1,"name":"ready"}"""); } @@ -114,6 +117,46 @@ public async Task TheConsentQuestion_TravelsInTheSnapshot() Assert.True(privacy.GetProperty("cloudTranslate").GetBoolean()); } + [Fact] + public async Task TheUpdateProgress_TravelsInTheSnapshots() + { + var gate = new TaskCompletionSource(); + var service = Substitute.For(); + service.CanUpdate.Returns(true); + service.ReadyVersion.Returns((string?)null); + service.FindAsync(Arg.Any()).Returns(gate.Task); + using var checks = new UpdateChecks(service, TimeSpan.FromHours(4), NullLogger.Instance); + using var rig = new Rig(section: "models", updates: checks); + await rig.DevicesRead(); + rig.Host.Posted.Clear(); + + rig.Host.FromPage("""{"v":1,"name":"checkUpdates"}"""); + await Wait.Until(() => checks.State.Phase == UpdatePhase.Checking); + rig.Time.Advance(WebBridge.Frame); + var checking = rig.Last.GetProperty("data").GetProperty("models").GetProperty("update"); + Assert.True(checking.GetProperty("busy").GetBoolean()); + Assert.Equal("正在检查更新…", checking.GetProperty("status").GetString()); + + gate.SetResult(null); + await Wait.Until(() => checks.State.Phase == UpdatePhase.UpToDate); + rig.Time.Advance(WebBridge.Frame); + var latest = rig.Last.GetProperty("data").GetProperty("models").GetProperty("update"); + Assert.False(latest.GetProperty("busy").GetBoolean()); + Assert.True(latest.GetProperty("canCheck").GetBoolean()); + Assert.StartsWith("已是最新版本(2.0.0)", latest.GetProperty("status").GetString()); + } + + [Fact] + public void WithUpdatesOff_ThePageSaysSo() + { + using var rig = new Rig(section: "models"); + + var update = rig.Last.GetProperty("data").GetProperty("models").GetProperty("update"); + + Assert.Contains("已关闭自动更新", update.GetProperty("status").GetString()); + Assert.False(update.GetProperty("canCheck").GetBoolean()); + } + [Fact] public void AskingForAnotherGroup_ShowsItWithoutTheUserClicking() { diff --git a/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs b/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs index 5a90235..62bb321 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs @@ -46,6 +46,9 @@ public sealed class WebIntentTests [InlineData("""{"v":1,"name":"saveKey","value":"sk-1"}""", typeof(SaveKeyIntent))] [InlineData("""{"v":1,"name":"forgetKey"}""", typeof(ForgetKeyIntent))] [InlineData("""{"v":1,"name":"removeLegacyModels"}""", typeof(RemoveLegacyModelsIntent))] + [InlineData("""{"v":1,"name":"checkUpdates"}""", typeof(CheckUpdatesIntent))] + [InlineData("""{"v":1,"name":"restartToUpdate"}""", typeof(RestartToUpdateIntent))] + [InlineData("""{"v":1,"name":"openDownloads"}""", typeof(OpenDownloadsIntent))] [InlineData("""{"v":1,"name":"restoreDefaults"}""", typeof(RestoreDefaultsIntent))] [InlineData("""{"v":1,"name":"forgetApp","appId":"com.tinyspeck.slackmacgap"}""", typeof(ForgetAppIntent))] [InlineData("""{"v":1,"name":"clearApps"}""", typeof(ClearAppsIntent))] diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-apps.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-apps.json index e2c8d0e..cf5bcee 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-apps.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-apps.json @@ -63,6 +63,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud-invalid.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud-invalid.json index edeace8..2db66e8 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud-invalid.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud-invalid.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud.json index 741fcc1..6c188be 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-cloud.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-consent.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-consent.json index 53276ac..58c9ec1 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-consent.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-consent.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-dark.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-dark.json index d7726af..6c986b6 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-dark.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-dark.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-diagnostics.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-diagnostics.json index 2ea1bf2..beede08 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-diagnostics.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-diagnostics.json @@ -55,6 +55,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-general.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-general.json index 09aa967..34821f1 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-general.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-general.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-hotkey-invalid.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-hotkey-invalid.json index 01a4743..aec36ed 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-hotkey-invalid.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-hotkey-invalid.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-models.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-models.json index 2b7843b..8847ea5 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-models.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-models.json @@ -44,6 +44,14 @@ "text": "旧版本下载的 2 个模型(8.5 GB)已不再使用;不点击就不会删除" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-custom.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-custom.json index 358712f..3c3c13d 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-custom.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-custom.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-invalid.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-invalid.json index 10a8999..60279bb 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-invalid.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-polish-invalid.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-privacy.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-privacy.json index b741a48..d6e60fb 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-privacy.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-privacy.json @@ -44,6 +44,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-strings.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-strings.json index 5507ed5..19c8357 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-strings.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-strings.json @@ -95,6 +95,11 @@ "legacy.remove": "删除", "version": "版本", "version.desc": "模型和运行时只在你同意后下载", + "update": "软件更新", + "update.desc": "启动时和之后每隔几小时检查新版本,并在后台下载", + "update.check": "检查更新", + "update.restart": "重启以更新", + "update.download": "打开下载页面", "diag": "运行状态", "diag.desc": "不含正文、术语、地址和密钥,便于排查", "diag.open": "查看", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-update-checking.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-checking.json new file mode 100644 index 0000000..7bca7fc --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-checking.json @@ -0,0 +1,98 @@ +{ + "rev": 1, + "section": "models", + "general": { + "hotkey": "Ctrl+Alt+T", + "hotkeyInvalid": false, + "commitKey": "Enter", + "language": "en-US", + "theme": "Auto", + "reduceMotion": false, + "rememberChoices": true + }, + "voice": { + "microphones": [ + "", + "USB Mic", + "Built-in" + ], + "microphone": "", + "direction": "English", + "cleanup": true, + "defaultOutput": "Clean", + "autoDictate": false, + "preloadSpeech": true, + "terms": [], + "polishPrompt": "润色要求:\n1. 把被停顿切碎的半句话接成完整的句子,按意思重新断句,理顺前后的衔接。\n2. 必须删去所有口头语和填充词(呃、嗯、啊、那个、这个、就是、然后呢、对吧、你看),以及犹豫、重复和说到一半就放弃的半句;说话人把同一个意思又完整地说了一遍时,只保留完整的那一遍;自我纠正时,只保留纠正后的内容。\n3. 根据全文主题和上下文改正同音错字和技术名词的误识别;词表里的写法优先;拿不准的词保留原样,不要删除。\n4. 保持说话人自己的口吻,写成通顺、可以直接发送的文字,不要改成公文腔。\n\n示例:\n原文:呃,我觉得这个,就是那个接口的超时。设成十秒。不对,设成三十秒吧。嗯,对,就这样。\n润色:我觉得这个接口的超时设成三十秒吧。", + "polishPromptIsDefault": true, + "polishPromptInvalid": false + }, + "privacy": { + "cloudTranslate": false, + "cloudClean": false, + "baseUrl": "", + "model": "", + "hasKey": false, + "invalid": false, + "consent": null, + "apps": [] + }, + "models": { + "tier": "Fast", + "legacy": { + "count": 0, + "text": "" + }, + "version": "2.0.0", + "update": { + "status": "正在检查更新…", + "percent": null, + "busy": true, + "canCheck": false, + "canRestart": false, + "canDownload": false + }, + "diagnostics": [ + { + "label": "版本", + "value": "2.0.0" + }, + { + "label": "系统", + "value": "macOS 15.0 Arm64" + }, + { + "label": "界面语言", + "value": "en-US" + }, + { + "label": "翻译模型", + "value": "Fast" + }, + { + "label": "整理句子", + "value": "开" + }, + { + "label": "云端翻译", + "value": "关" + }, + { + "label": "云端整理", + "value": "关" + }, + { + "label": "API 密钥", + "value": "未保存" + }, + { + "label": "旧版本模型", + "value": "0" + } + ] + }, + "options": { + "animations": true, + "theme": "Auto" + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-update-downloading.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-downloading.json new file mode 100644 index 0000000..92d9f2b --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-downloading.json @@ -0,0 +1,98 @@ +{ + "rev": 1, + "section": "models", + "general": { + "hotkey": "Ctrl+Alt+T", + "hotkeyInvalid": false, + "commitKey": "Enter", + "language": "en-US", + "theme": "Auto", + "reduceMotion": false, + "rememberChoices": true + }, + "voice": { + "microphones": [ + "", + "USB Mic", + "Built-in" + ], + "microphone": "", + "direction": "English", + "cleanup": true, + "defaultOutput": "Clean", + "autoDictate": false, + "preloadSpeech": true, + "terms": [], + "polishPrompt": "润色要求:\n1. 把被停顿切碎的半句话接成完整的句子,按意思重新断句,理顺前后的衔接。\n2. 必须删去所有口头语和填充词(呃、嗯、啊、那个、这个、就是、然后呢、对吧、你看),以及犹豫、重复和说到一半就放弃的半句;说话人把同一个意思又完整地说了一遍时,只保留完整的那一遍;自我纠正时,只保留纠正后的内容。\n3. 根据全文主题和上下文改正同音错字和技术名词的误识别;词表里的写法优先;拿不准的词保留原样,不要删除。\n4. 保持说话人自己的口吻,写成通顺、可以直接发送的文字,不要改成公文腔。\n\n示例:\n原文:呃,我觉得这个,就是那个接口的超时。设成十秒。不对,设成三十秒吧。嗯,对,就这样。\n润色:我觉得这个接口的超时设成三十秒吧。", + "polishPromptIsDefault": true, + "polishPromptInvalid": false + }, + "privacy": { + "cloudTranslate": false, + "cloudClean": false, + "baseUrl": "", + "model": "", + "hasKey": false, + "invalid": false, + "consent": null, + "apps": [] + }, + "models": { + "tier": "Fast", + "legacy": { + "count": 0, + "text": "" + }, + "version": "2.0.0", + "update": { + "status": "正在下载 2.2.0 版… 42%", + "percent": 42, + "busy": true, + "canCheck": false, + "canRestart": false, + "canDownload": false + }, + "diagnostics": [ + { + "label": "版本", + "value": "2.0.0" + }, + { + "label": "系统", + "value": "macOS 15.0 Arm64" + }, + { + "label": "界面语言", + "value": "en-US" + }, + { + "label": "翻译模型", + "value": "Fast" + }, + { + "label": "整理句子", + "value": "开" + }, + { + "label": "云端翻译", + "value": "关" + }, + { + "label": "云端整理", + "value": "关" + }, + { + "label": "API 密钥", + "value": "未保存" + }, + { + "label": "旧版本模型", + "value": "0" + } + ] + }, + "options": { + "animations": true, + "theme": "Auto" + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-update-failed.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-failed.json new file mode 100644 index 0000000..b1112c8 --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-failed.json @@ -0,0 +1,98 @@ +{ + "rev": 1, + "section": "models", + "general": { + "hotkey": "Ctrl+Alt+T", + "hotkeyInvalid": false, + "commitKey": "Enter", + "language": "en-US", + "theme": "Auto", + "reduceMotion": false, + "rememberChoices": true + }, + "voice": { + "microphones": [ + "", + "USB Mic", + "Built-in" + ], + "microphone": "", + "direction": "English", + "cleanup": true, + "defaultOutput": "Clean", + "autoDictate": false, + "preloadSpeech": true, + "terms": [], + "polishPrompt": "润色要求:\n1. 把被停顿切碎的半句话接成完整的句子,按意思重新断句,理顺前后的衔接。\n2. 必须删去所有口头语和填充词(呃、嗯、啊、那个、这个、就是、然后呢、对吧、你看),以及犹豫、重复和说到一半就放弃的半句;说话人把同一个意思又完整地说了一遍时,只保留完整的那一遍;自我纠正时,只保留纠正后的内容。\n3. 根据全文主题和上下文改正同音错字和技术名词的误识别;词表里的写法优先;拿不准的词保留原样,不要删除。\n4. 保持说话人自己的口吻,写成通顺、可以直接发送的文字,不要改成公文腔。\n\n示例:\n原文:呃,我觉得这个,就是那个接口的超时。设成十秒。不对,设成三十秒吧。嗯,对,就这样。\n润色:我觉得这个接口的超时设成三十秒吧。", + "polishPromptIsDefault": true, + "polishPromptInvalid": false + }, + "privacy": { + "cloudTranslate": false, + "cloudClean": false, + "baseUrl": "", + "model": "", + "hasKey": false, + "invalid": false, + "consent": null, + "apps": [] + }, + "models": { + "tier": "Fast", + "legacy": { + "count": 0, + "text": "" + }, + "version": "2.0.0", + "update": { + "status": "检查失败(2026-11-03 19:05):Unable to connect to github.com", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, + "diagnostics": [ + { + "label": "版本", + "value": "2.0.0" + }, + { + "label": "系统", + "value": "macOS 15.0 Arm64" + }, + { + "label": "界面语言", + "value": "en-US" + }, + { + "label": "翻译模型", + "value": "Fast" + }, + { + "label": "整理句子", + "value": "开" + }, + { + "label": "云端翻译", + "value": "关" + }, + { + "label": "云端整理", + "value": "关" + }, + { + "label": "API 密钥", + "value": "未保存" + }, + { + "label": "旧版本模型", + "value": "0" + } + ] + }, + "options": { + "animations": true, + "theme": "Auto" + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-update-latest.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-latest.json new file mode 100644 index 0000000..2a729e8 --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-latest.json @@ -0,0 +1,98 @@ +{ + "rev": 1, + "section": "models", + "general": { + "hotkey": "Ctrl+Alt+T", + "hotkeyInvalid": false, + "commitKey": "Enter", + "language": "en-US", + "theme": "Auto", + "reduceMotion": false, + "rememberChoices": true + }, + "voice": { + "microphones": [ + "", + "USB Mic", + "Built-in" + ], + "microphone": "", + "direction": "English", + "cleanup": true, + "defaultOutput": "Clean", + "autoDictate": false, + "preloadSpeech": true, + "terms": [], + "polishPrompt": "润色要求:\n1. 把被停顿切碎的半句话接成完整的句子,按意思重新断句,理顺前后的衔接。\n2. 必须删去所有口头语和填充词(呃、嗯、啊、那个、这个、就是、然后呢、对吧、你看),以及犹豫、重复和说到一半就放弃的半句;说话人把同一个意思又完整地说了一遍时,只保留完整的那一遍;自我纠正时,只保留纠正后的内容。\n3. 根据全文主题和上下文改正同音错字和技术名词的误识别;词表里的写法优先;拿不准的词保留原样,不要删除。\n4. 保持说话人自己的口吻,写成通顺、可以直接发送的文字,不要改成公文腔。\n\n示例:\n原文:呃,我觉得这个,就是那个接口的超时。设成十秒。不对,设成三十秒吧。嗯,对,就这样。\n润色:我觉得这个接口的超时设成三十秒吧。", + "polishPromptIsDefault": true, + "polishPromptInvalid": false + }, + "privacy": { + "cloudTranslate": false, + "cloudClean": false, + "baseUrl": "", + "model": "", + "hasKey": false, + "invalid": false, + "consent": null, + "apps": [] + }, + "models": { + "tier": "Fast", + "legacy": { + "count": 0, + "text": "" + }, + "version": "2.0.0", + "update": { + "status": "已是最新版本(2.0.0),检查于 2026-11-03 19:05。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, + "diagnostics": [ + { + "label": "版本", + "value": "2.0.0" + }, + { + "label": "系统", + "value": "macOS 15.0 Arm64" + }, + { + "label": "界面语言", + "value": "en-US" + }, + { + "label": "翻译模型", + "value": "Fast" + }, + { + "label": "整理句子", + "value": "开" + }, + { + "label": "云端翻译", + "value": "关" + }, + { + "label": "云端整理", + "value": "关" + }, + { + "label": "API 密钥", + "value": "未保存" + }, + { + "label": "旧版本模型", + "value": "0" + } + ] + }, + "options": { + "animations": true, + "theme": "Auto" + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-update-off.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-off.json new file mode 100644 index 0000000..f4d87c2 --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-off.json @@ -0,0 +1,98 @@ +{ + "rev": 1, + "section": "models", + "general": { + "hotkey": "Ctrl+Alt+T", + "hotkeyInvalid": false, + "commitKey": "Enter", + "language": "en-US", + "theme": "Auto", + "reduceMotion": false, + "rememberChoices": true + }, + "voice": { + "microphones": [ + "", + "USB Mic", + "Built-in" + ], + "microphone": "", + "direction": "English", + "cleanup": true, + "defaultOutput": "Clean", + "autoDictate": false, + "preloadSpeech": true, + "terms": [], + "polishPrompt": "润色要求:\n1. 把被停顿切碎的半句话接成完整的句子,按意思重新断句,理顺前后的衔接。\n2. 必须删去所有口头语和填充词(呃、嗯、啊、那个、这个、就是、然后呢、对吧、你看),以及犹豫、重复和说到一半就放弃的半句;说话人把同一个意思又完整地说了一遍时,只保留完整的那一遍;自我纠正时,只保留纠正后的内容。\n3. 根据全文主题和上下文改正同音错字和技术名词的误识别;词表里的写法优先;拿不准的词保留原样,不要删除。\n4. 保持说话人自己的口吻,写成通顺、可以直接发送的文字,不要改成公文腔。\n\n示例:\n原文:呃,我觉得这个,就是那个接口的超时。设成十秒。不对,设成三十秒吧。嗯,对,就这样。\n润色:我觉得这个接口的超时设成三十秒吧。", + "polishPromptIsDefault": true, + "polishPromptInvalid": false + }, + "privacy": { + "cloudTranslate": false, + "cloudClean": false, + "baseUrl": "", + "model": "", + "hasKey": false, + "invalid": false, + "consent": null, + "apps": [] + }, + "models": { + "tier": "Fast", + "legacy": { + "count": 0, + "text": "" + }, + "version": "2.0.0", + "update": { + "status": "已关闭自动更新(settings.json 中的 Update.UpdateUrl 为空)。", + "percent": null, + "busy": false, + "canCheck": false, + "canRestart": false, + "canDownload": false + }, + "diagnostics": [ + { + "label": "版本", + "value": "2.0.0" + }, + { + "label": "系统", + "value": "macOS 15.0 Arm64" + }, + { + "label": "界面语言", + "value": "en-US" + }, + { + "label": "翻译模型", + "value": "Fast" + }, + { + "label": "整理句子", + "value": "开" + }, + { + "label": "云端翻译", + "value": "关" + }, + { + "label": "云端整理", + "value": "关" + }, + { + "label": "API 密钥", + "value": "未保存" + }, + { + "label": "旧版本模型", + "value": "0" + } + ] + }, + "options": { + "animations": true, + "theme": "Auto" + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-update-ready.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-ready.json new file mode 100644 index 0000000..7832dab --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-ready.json @@ -0,0 +1,98 @@ +{ + "rev": 1, + "section": "models", + "general": { + "hotkey": "Ctrl+Alt+T", + "hotkeyInvalid": false, + "commitKey": "Enter", + "language": "en-US", + "theme": "Auto", + "reduceMotion": false, + "rememberChoices": true + }, + "voice": { + "microphones": [ + "", + "USB Mic", + "Built-in" + ], + "microphone": "", + "direction": "English", + "cleanup": true, + "defaultOutput": "Clean", + "autoDictate": false, + "preloadSpeech": true, + "terms": [], + "polishPrompt": "润色要求:\n1. 把被停顿切碎的半句话接成完整的句子,按意思重新断句,理顺前后的衔接。\n2. 必须删去所有口头语和填充词(呃、嗯、啊、那个、这个、就是、然后呢、对吧、你看),以及犹豫、重复和说到一半就放弃的半句;说话人把同一个意思又完整地说了一遍时,只保留完整的那一遍;自我纠正时,只保留纠正后的内容。\n3. 根据全文主题和上下文改正同音错字和技术名词的误识别;词表里的写法优先;拿不准的词保留原样,不要删除。\n4. 保持说话人自己的口吻,写成通顺、可以直接发送的文字,不要改成公文腔。\n\n示例:\n原文:呃,我觉得这个,就是那个接口的超时。设成十秒。不对,设成三十秒吧。嗯,对,就这样。\n润色:我觉得这个接口的超时设成三十秒吧。", + "polishPromptIsDefault": true, + "polishPromptInvalid": false + }, + "privacy": { + "cloudTranslate": false, + "cloudClean": false, + "baseUrl": "", + "model": "", + "hasKey": false, + "invalid": false, + "consent": null, + "apps": [] + }, + "models": { + "tier": "Fast", + "legacy": { + "count": 0, + "text": "" + }, + "version": "2.0.0", + "update": { + "status": "2.2.0 版已下载完成。重启即可安装;退出应用时也会自动安装。", + "percent": null, + "busy": false, + "canCheck": false, + "canRestart": true, + "canDownload": false + }, + "diagnostics": [ + { + "label": "版本", + "value": "2.0.0" + }, + { + "label": "系统", + "value": "macOS 15.0 Arm64" + }, + { + "label": "界面语言", + "value": "en-US" + }, + { + "label": "翻译模型", + "value": "Fast" + }, + { + "label": "整理句子", + "value": "开" + }, + { + "label": "云端翻译", + "value": "关" + }, + { + "label": "云端整理", + "value": "关" + }, + { + "label": "API 密钥", + "value": "未保存" + }, + { + "label": "旧版本模型", + "value": "0" + } + ] + }, + "options": { + "animations": true, + "theme": "Auto" + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-update-unsupported.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-unsupported.json new file mode 100644 index 0000000..1913ed4 --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-update-unsupported.json @@ -0,0 +1,98 @@ +{ + "rev": 1, + "section": "models", + "general": { + "hotkey": "Ctrl+Alt+T", + "hotkeyInvalid": false, + "commitKey": "Enter", + "language": "en-US", + "theme": "Auto", + "reduceMotion": false, + "rememberChoices": true + }, + "voice": { + "microphones": [ + "", + "USB Mic", + "Built-in" + ], + "microphone": "", + "direction": "English", + "cleanup": true, + "defaultOutput": "Clean", + "autoDictate": false, + "preloadSpeech": true, + "terms": [], + "polishPrompt": "润色要求:\n1. 把被停顿切碎的半句话接成完整的句子,按意思重新断句,理顺前后的衔接。\n2. 必须删去所有口头语和填充词(呃、嗯、啊、那个、这个、就是、然后呢、对吧、你看),以及犹豫、重复和说到一半就放弃的半句;说话人把同一个意思又完整地说了一遍时,只保留完整的那一遍;自我纠正时,只保留纠正后的内容。\n3. 根据全文主题和上下文改正同音错字和技术名词的误识别;词表里的写法优先;拿不准的词保留原样,不要删除。\n4. 保持说话人自己的口吻,写成通顺、可以直接发送的文字,不要改成公文腔。\n\n示例:\n原文:呃,我觉得这个,就是那个接口的超时。设成十秒。不对,设成三十秒吧。嗯,对,就这样。\n润色:我觉得这个接口的超时设成三十秒吧。", + "polishPromptIsDefault": true, + "polishPromptInvalid": false + }, + "privacy": { + "cloudTranslate": false, + "cloudClean": false, + "baseUrl": "", + "model": "", + "hasKey": false, + "invalid": false, + "consent": null, + "apps": [] + }, + "models": { + "tier": "Fast", + "legacy": { + "count": 0, + "text": "" + }, + "version": "2.0.0", + "update": { + "status": "当前不是通过 LiveLingo 安装程序(Setup.exe 或 .pkg)安装的,无法自动更新。请从下载页面安装最新版本。", + "percent": null, + "busy": false, + "canCheck": false, + "canRestart": false, + "canDownload": true + }, + "diagnostics": [ + { + "label": "版本", + "value": "2.0.0" + }, + { + "label": "系统", + "value": "macOS 15.0 Arm64" + }, + { + "label": "界面语言", + "value": "en-US" + }, + { + "label": "翻译模型", + "value": "Fast" + }, + { + "label": "整理句子", + "value": "开" + }, + { + "label": "云端翻译", + "value": "关" + }, + { + "label": "云端整理", + "value": "关" + }, + { + "label": "API 密钥", + "value": "未保存" + }, + { + "label": "旧版本模型", + "value": "0" + } + ] + }, + "options": { + "animations": true, + "theme": "Auto" + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/settings-voice.json b/tests/LiveLingo.Web.Tests/Fixtures/settings-voice.json index e450e5f..35eeba0 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/settings-voice.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/settings-voice.json @@ -55,6 +55,14 @@ "text": "" }, "version": "2.0.0", + "update": { + "status": "尚未检查。", + "percent": null, + "busy": false, + "canCheck": true, + "canRestart": false, + "canDownload": false + }, "diagnostics": [ { "label": "版本", diff --git a/tests/LiveLingo.Web.Tests/SettingsPageTests.cs b/tests/LiveLingo.Web.Tests/SettingsPageTests.cs index 71a4443..75d76b0 100644 --- a/tests/LiveLingo.Web.Tests/SettingsPageTests.cs +++ b/tests/LiveLingo.Web.Tests/SettingsPageTests.cs @@ -653,6 +653,51 @@ public async Task ForgettingTheKey_RecentDraftsAndLegacyModels_AreOneClickIntent Assert.Equal(1, await models.CountAsync()); } + [Theory] + [MemberData(nameof(Engines))] + public async Task TheUpdateRow_ChecksOnRequest_AndShowsTheDownloadsProgress(string engine) + { + await using var idle = await browsers.OpenSettingsAsync(engine, "settings-models"); + await idle.Page.Locator("#update-action").ClickAsync(); + Assert.Equal(1, await idle.CountAsync()); + + await using var downloading = await browsers.OpenSettingsAsync(engine, "settings-update-downloading"); + Assert.Contains("42%", await downloading.Page.Locator("#update-status").InnerTextAsync()); + Assert.Equal("42", await downloading.Page.Locator("#update-progress").GetAttributeAsync("aria-valuenow")); + Assert.True(await downloading.Page.Locator("#update-action").IsDisabledAsync()); + + await using var checking = await browsers.OpenSettingsAsync(engine, "settings-update-checking"); + Assert.Contains("indeterminate", await checking.Page.Locator("#update-progress").GetAttributeAsync("class")); + Assert.True(await checking.Page.Locator("#update-action").IsDisabledAsync()); + } + + [Theory] + [MemberData(nameof(Engines))] + public async Task TheUpdateRow_RestartsIntoADownloadedVersion_OrOpensTheDownloadPage(string engine) + { + await using var ready = await browsers.OpenSettingsAsync(engine, "settings-update-ready"); + await ready.Page.Locator("#update-action").ClickAsync(); + Assert.Equal(1, await ready.CountAsync()); + + await using var unsupported = await browsers.OpenSettingsAsync(engine, "settings-update-unsupported"); + Assert.Contains("无法自动更新", await unsupported.Page.Locator("#update-status").InnerTextAsync()); + await unsupported.Page.Locator("#update-action").ClickAsync(); + Assert.Equal(1, await unsupported.CountAsync()); + } + + [Theory] + [MemberData(nameof(Engines))] + public async Task TheUpdateRow_SaysWhenAnUpdateCheckFailedOrUpdatesAreOff(string engine) + { + await using var failed = await browsers.OpenSettingsAsync(engine, "settings-update-failed"); + Assert.Contains("Unable to connect", await failed.Page.Locator("#update-status").InnerTextAsync()); + Assert.False(await failed.Page.Locator("#update-action").IsDisabledAsync()); + + await using var off = await browsers.OpenSettingsAsync(engine, "settings-update-off"); + Assert.Contains("已关闭自动更新", await off.Page.Locator("#update-status").InnerTextAsync()); + Assert.Equal(0, await off.Page.Locator("#update-action").CountAsync()); + } + [Theory] [MemberData(nameof(Engines))] public async Task TheNavigation_AsksTheHost_AndTheGroupChangesWithTheNextSnapshot(string engine)