From 603bca171e531090f8d709401a4c88cba71eb860 Mon Sep 17 00:00:00 2001 From: ARNO Date: Wed, 5 Aug 2026 10:27:37 +0800 Subject: [PATCH 1/6] feat(updater): add windows updates and cross-platform nightly support (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(updater): add windows online updates * feat(updater): support online updates for windows portable zip builds f * feat(updater): support online updates for nightly build * fix(updater): strengthen post-download update verification * feat(updater): support explicit stable and nightly channel switching * fix(i18n): localize update settings ui * fix(settings): prevent slider value labels from wrapping * feat(updater): drop the nightly channel, refuse all-users Windows installs Follow-up to the Windows updater work on this branch, applying maintainer review. Nightly is a build channel, not an update channel. The updater consults `/releases/latest` again and nothing else, so it behaves on Windows exactly as it already does on macOS: a Nightly build is offered the stable release that supersedes it and graduates out of the prerelease, and no rolling prerelease can become a source of code that gets executed on a user's machine. Removed with it: the `UpdateChannel` enum and its version-string inference, the `tags/nightly` query, the cross-channel version-ordering bypass, the Settings → About channel row, the rolling-tag `update-manifest.json` and the i18n keys that only served them. `parse_version` and `is_update_available` are byte-identical to main again. Nightly builds are untouched, and still carry tty7-updater plus the macOS update archive — a Nightly user needs a working helper to reach the stable release that replaces their build. An all-users Windows installation is no longer updated in place. Running the release Setup silently as the signed-in user cannot replace `C:\Program Files\tty7`: Inno resolves `{autopf}` to `%LocalAppData%\Programs` and installs a second copy beside the real one, or re-launches itself elevated and puts a bare UAC prompt for an unsigned executable in `%TEMP%` in front of a user whose GUI just vanished. tty7 declines both and points at the release page. Detection reads Inno's own `HKLM` state for the frozen AppId and independently probes whether the directory accepts writes, so a relocated or pruned installation is caught too; the decision is a pure function with unit tests, and it is re-checked before the download as well as during it. Release and Nightly now verify the Windows packages they just built, mirroring the macOS update-archive step: the install marker, tty7-updater.exe, the ZIP layout the updater will accept and the PE versions it will demand. Every fact the updater checks on the user's machine after downloading is checked here instead, so a packaging mistake fails the build. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .github/scripts/bundle-macos.sh | 18 +- .github/scripts/bundle-windows.ps1 | 30 +- .github/scripts/verify-windows-package.ps1 | 169 +++ .github/scripts/windows-installer.iss | 13 +- .github/workflows/ci.yml | 4 +- .github/workflows/nightly.yml | 39 +- .github/workflows/release.yml | 11 +- Cargo.lock | 1 + Cargo.toml | 8 +- src/bin/tty7-updater.rs | 1534 +++++++++++++++++++- src/core/update.rs | 693 +++++++-- src/ui/i18n.rs | 89 +- src/ui/settings.rs | 142 +- 13 files changed, 2598 insertions(+), 153 deletions(-) create mode 100644 .github/scripts/verify-windows-package.ps1 diff --git a/.github/scripts/bundle-macos.sh b/.github/scripts/bundle-macos.sh index e9e648cf..5f6aa63d 100755 --- a/.github/scripts/bundle-macos.sh +++ b/.github/scripts/bundle-macos.sh @@ -23,9 +23,6 @@ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then exit 1 fi PACKAGE_UPDATE_ZIP="${TTY7_PACKAGE_UPDATE_ZIP:-1}" -if [[ "$VERSION" == *-nightly.* ]]; then - PACKAGE_UPDATE_ZIP=0 -fi APP="dist/tty7.app" rm -rf dist @@ -42,9 +39,10 @@ chmod +x "$APP/Contents/MacOS/tty7" if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then # A focused out-of-process updater can replace the bundle after the GUI # exits, then relaunch or roll back without teaching the GUI to mutate - # itself. Stable macOS builds carry it beside the app/CLI so its signature - # is covered by the outer bundle; Nightly remains byte-for-byte on its old - # packaging path for the first updater release. + # itself. Every macOS build carries it beside the app/CLI so its signature + # is covered by the outer bundle — including Nightly, whose users are + # offered the stable release that supersedes their prerelease and need a + # working helper to get there. cp "target/${TARGET}/release/tty7-updater" "$APP/Contents/MacOS/tty7-updater" chmod +x "$APP/Contents/MacOS/tty7-updater" fi @@ -155,10 +153,10 @@ else codesign --force --deep --sign - "$APP" fi -# The stable-channel in-app updater needs the signed, notarized .app itself -# rather than a disk image that requires Finder interaction. Nightly versions -# skip this path above: their rolling release remains unchanged until the stable -# updater has shipped and been exercised. +# The in-app updater needs the signed, notarized .app itself rather than a disk +# image that requires Finder interaction. The helper re-reads the full embedded +# version out of the staged bundle and refuses anything that is not the release +# it was told to install. ZIP="" if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then ZIP="dist/tty7-${VERSION}-macos-${ARCH}.zip" diff --git a/.github/scripts/bundle-windows.ps1 b/.github/scripts/bundle-windows.ps1 index 92a566c9..43632b6f 100644 --- a/.github/scripts/bundle-windows.ps1 +++ b/.github/scripts/bundle-windows.ps1 @@ -7,15 +7,22 @@ # Fonts are embedded via include_bytes! and the app icon is compiled into the # executable as a resource (see build.rs). So the payload is tty7-app.exe plus a # sibling completions\ dir (loaded at runtime — see terminal::signature) and the -# license/readme. Both artifacts are unsigned builds — SmartScreen will -# warn on first launch. +# license/readme. Windows release artifacts are intentionally unsigned. The +# in-app updater verifies the published SHA-256 checksum and PE file version +# before and after waiting for the GUI to exit. $ErrorActionPreference = 'Stop' $Target = $args[0] $Arch = $args[1] $Version = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value +# Inno accepts the full semantic version for AppVersion, but the PE version +# resource only accepts numeric components. Keep both values so Nightly and +# other prerelease builds retain their display version without breaking ISCC. +$VersionCore = ($Version -split '[-+]', 2)[0] +$VersionInfoVersion = "${VersionCore}.0" $Name = "tty7-$Version-windows-$Arch" $Stage = "dist/$Name" +$PackageUpdater = $env:TTY7_PACKAGE_UPDATE_HELPER -ne '0' Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null @@ -25,6 +32,12 @@ Copy-Item "target/$Target/release/tty7-app.exe" "$Stage/tty7-app.exe" # `core::cli_install` resolves it relative to tty7-app.exe and puts that # directory on the user's PATH. Copy-Item "target/$Target/release/tty7.exe" "$Stage/tty7.exe" +if ($PackageUpdater) { + # The installed copy is never executed in place during an update. The GUI + # first copies it to a private staging directory so Inno can replace every + # installed executable without colliding with Windows image locks. + Copy-Item "target/$Target/release/tty7-updater.exe" "$Stage/tty7-updater.exe" +} New-Item -ItemType Directory -Force -Path "$Stage/completions" | Out-Null Copy-Item "assets/completions/*.json" "$Stage/completions/" Copy-Item LICENSE "$Stage/LICENSE.txt" @@ -51,7 +64,19 @@ if (Test-Path $ServerSrc) { Write-Warning "no $ServerAsset to bundle - this build cannot serve WSL distros" } +# The marker tells the in-app updater which of the two Windows layouts it is +# running from, and therefore which release asset can replace it. It says +# nothing about where updates come from: that is always the latest stable +# release. The Inno payload gets the mutually exclusive marker below. +if ($PackageUpdater) { + Set-Content -Path "$Stage/.tty7-portable" -Value 'portable-v1' -NoNewline -Encoding ascii +} Compress-Archive -Path "$Stage/*" -DestinationPath "dist/$Name.zip" -Force +if ($PackageUpdater) { + # The Inno payload must never retain the mutually exclusive portable marker. + Remove-Item -LiteralPath "$Stage/.tty7-portable" -Force + Set-Content -Path "$Stage/.tty7-inno-install" -Value 'inno-v1' -NoNewline -Encoding ascii +} # Installer, built from the same staged payload. ISCC is on PATH on GitHub's # windows-latest image; fall back to the default install location. @@ -59,6 +84,7 @@ $Iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source if (-not $Iscc) { $Iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" } & $Iscc ` "/DAppVersion=$Version" ` + "/DVersionInfoVersion=$VersionInfoVersion" ` "/DStageDir=$((Resolve-Path $Stage).Path)" ` "/DOutputDir=$((Resolve-Path dist).Path)" ` "/DOutputName=$Name-setup" ` diff --git a/.github/scripts/verify-windows-package.ps1 b/.github/scripts/verify-windows-package.ps1 new file mode 100644 index 00000000..17b9bbd1 --- /dev/null +++ b/.github/scripts/verify-windows-package.ps1 @@ -0,0 +1,169 @@ +# Verifies that the Windows release artifacts carry everything the in-app +# updater requires, immediately after bundle-windows.ps1 produces them. +# +# The updater refuses to install a package it cannot recognise, and it does so +# on the user's machine, after the download, after the GUI has exited. Every +# fact it checks there is checked here instead, so a packaging mistake fails +# the release build rather than every user's next update. +# +# Mirrors, in order: +# core::update::windows_update_layout_for — the install marker +# core::update::package_for_current_install — tty7-updater.exe beside the app +# tty7-updater `windows::verify_portable_payload` — portable layout + versions +# tty7-updater `windows::extract_portable_archive` — ZIP entry rules +# tty7-updater `windows::verify_file_version` — setup.exe PE version +# +# Usage: verify-windows-package.ps1 [version] +# `version` defaults to the version in Cargo.toml, which is what the bundle +# script stamped into the artifact names. +$ErrorActionPreference = 'Stop' + +$Arch = $args[0] +if (-not $Arch) { throw "usage: verify-windows-package.ps1 [version]" } +$Version = $args[1] +if (-not $Version) { + $Version = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value +} +# The PE fixed-version resource carries only numeric components, so the updater +# compares the release version's numeric core against it. Keep the same split. +$VersionCore = ($Version -split '[-+]', 2)[0] + +$Name = "tty7-$Version-windows-$Arch" +$Zip = "dist/$Name.zip" +$Setup = "dist/$Name-setup.exe" +$Stage = "dist/$Name" + +$failures = New-Object System.Collections.Generic.List[string] +function Fail([string]$message) { $failures.Add($message) } + +function Get-ProductVersion([string]$path) { + # The same string the updater reads back with VerQueryValueW + # (\StringFileInfo\\ProductVersion). + (Get-Item -LiteralPath $path).VersionInfo.ProductVersion +} + +function Assert-BinaryVersion([string]$path, [string]$label) { + if (-not (Test-Path -LiteralPath $path)) { Fail "$label is missing: $path"; return } + $actual = Get-ProductVersion $path + if ($actual -ne $Version) { + Fail "$label reports ProductVersion '$actual', expected '$Version'" + } +} + +# ---- Portable ZIP -------------------------------------------------------- +# Update rules live in the updater's extractor; the ones that can be broken by +# packaging alone are re-stated here. +if (-not (Test-Path -LiteralPath $Zip)) { + Fail "the portable archive is missing: $Zip" +} else { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Zip).Path) + try { + $entries = @($archive.Entries | ForEach-Object { $_.FullName }) + } finally { + $archive.Dispose() + } + + # `extract_portable_archive` rejects a backslash outright: the ZIP spec + # names '/' as the separator, and a mixed archive is one the updater will + # not unpack. PowerShell's archive writer has emitted both over the years. + $backslashed = @($entries | Where-Object { $_.Contains('\') }) + if ($backslashed.Count -gt 0) { + Fail ("the portable archive uses backslash separators the updater rejects: " + + ($backslashed -join ', ')) + } + + # `validate_portable_relative_path` allows only these top-level names. + $managed = @( + 'tty7-app.exe', 'tty7.exe', 'tty7-updater.exe', '.tty7-portable', + 'completions', 'server', 'LICENSE.txt', 'README.md' + ) + $roots = @($entries | + ForEach-Object { ($_ -split '[\\/]', 2)[0] } | + Sort-Object -Unique) + foreach ($root in $roots) { + if ($managed -notcontains $root) { + Fail "the portable archive has a top-level entry the updater rejects: $root" + } + } + + # The Inno marker and the portable marker are mutually exclusive: whichever + # one is present decides how the updater replaces this installation. + if ($entries -contains '.tty7-inno-install') { + Fail "the portable archive carries the Inno install marker" + } + + $unzipped = Join-Path ([System.IO.Path]::GetTempPath()) "tty7-verify-portable-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force -Path $unzipped | Out-Null + try { + [System.IO.Compression.ZipFile]::ExtractToDirectory( + (Resolve-Path $Zip).Path, $unzipped) + + # `verify_portable_payload`: every required member, then the marker + # content, then the complete version of both executables. + foreach ($required in @('tty7-app.exe', 'tty7.exe', 'tty7-updater.exe', + '.tty7-portable', 'LICENSE.txt', 'README.md')) { + if (-not (Test-Path -LiteralPath (Join-Path $unzipped $required) -PathType Leaf)) { + Fail "the portable archive is missing the required file $required" + } + } + if (-not (Test-Path -LiteralPath (Join-Path $unzipped 'completions') -PathType Container)) { + Fail "the portable archive is missing the required directory completions" + } + + $markerPath = Join-Path $unzipped '.tty7-portable' + if (Test-Path -LiteralPath $markerPath) { + $marker = [System.IO.File]::ReadAllBytes($markerPath) + $expected = [System.Text.Encoding]::ASCII.GetBytes('portable-v1') + if (@(Compare-Object $marker $expected -SyncWindow 0).Count -ne 0) { + Fail "the portable marker does not contain exactly 'portable-v1'" + } + } + + Assert-BinaryVersion (Join-Path $unzipped 'tty7-app.exe') 'the portable tty7-app.exe' + Assert-BinaryVersion (Join-Path $unzipped 'tty7-updater.exe') 'the portable tty7-updater.exe' + } finally { + Remove-Item -Recurse -Force $unzipped -ErrorAction SilentlyContinue + } +} + +# ---- Inno payload -------------------------------------------------------- +# ISCC compiled the installer from this staging directory, so what it holds is +# what lands in {app}. Reading the compiled setup.exe back would need +# innoextract, which the runners do not carry. +if (-not (Test-Path -LiteralPath $Stage -PathType Container)) { + Fail "the Inno staging directory is missing: $Stage" +} else { + if (-not (Test-Path -LiteralPath (Join-Path $Stage '.tty7-inno-install') -PathType Leaf)) { + Fail "the Inno payload is missing the .tty7-inno-install marker; installed copies would never be offered an in-app update" + } + if (Test-Path -LiteralPath (Join-Path $Stage '.tty7-portable')) { + Fail "the Inno payload carries the portable marker, which would misroute the updater" + } + if (-not (Test-Path -LiteralPath (Join-Path $Stage 'tty7-updater.exe') -PathType Leaf)) { + Fail "the Inno payload is missing tty7-updater.exe" + } + Assert-BinaryVersion (Join-Path $Stage 'tty7-app.exe') 'the installed tty7-app.exe' + Assert-BinaryVersion (Join-Path $Stage 'tty7-updater.exe') 'the installed tty7-updater.exe' +} + +# ---- Setup executable ---------------------------------------------------- +# `verify_update` re-reads this numeric version after the GUI exits and before +# it runs the installer, so a mis-stamped VersionInfoVersion is an update that +# aborts on the user's machine. +if (-not (Test-Path -LiteralPath $Setup -PathType Leaf)) { + Fail "the Windows installer is missing: $Setup" +} else { + $info = (Get-Item -LiteralPath $Setup).VersionInfo + $actual = "$($info.FileMajorPart).$($info.FileMinorPart).$($info.FileBuildPart)" + if ($actual -ne $VersionCore) { + Fail "$Setup reports file version '$actual', expected '$VersionCore'" + } +} + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Output "::error::$failure" } + throw "the Windows release package would not be updatable in place ($($failures.Count) problem(s))" +} + +Write-Output "Windows package verified: markers, tty7-updater.exe and versions match $Version" diff --git a/.github/scripts/windows-installer.iss b/.github/scripts/windows-installer.iss index 38b61076..6c94843b 100644 --- a/.github/scripts/windows-installer.iss +++ b/.github/scripts/windows-installer.iss @@ -2,8 +2,9 @@ ; windows-latest runners). Compiled by bundle-windows.ps1, which stages the ; payload and passes every path in via /D defines: ; -; /DAppVersion= version parsed from Cargo.toml -; /DStageDir= staged payload (tty7-app.exe, completions\, LICENSE.txt, README.md) +; /DAppVersion= display version parsed from Cargo.toml +; /DVersionInfoVersion= PE-compatible file version +; /DStageDir= staged payload (app, CLI, updater, marker, resources) ; /DOutputDir= where the setup exe is written ; /DOutputName= setup exe filename, without ".exe" ; @@ -15,6 +16,9 @@ #ifndef AppVersion #error Missing /DAppVersion — this script is meant to be compiled via bundle-windows.ps1 #endif +#ifndef VersionInfoVersion + #error Missing /DVersionInfoVersion — this script is meant to be compiled via bundle-windows.ps1 +#endif [Setup] ; Never change AppId: it is how Windows ties upgrades + the uninstall entry @@ -22,6 +26,7 @@ AppId={{9A3F6C1E-4B7D-4E2A-8C5F-D01B92E64A37} AppName=tty7 AppVersion={#AppVersion} +VersionInfoVersion={#VersionInfoVersion} AppPublisher=tty7 contributors AppPublisherURL=https://github.com/l0ng-ai/tty7 AppSupportURL=https://github.com/l0ng-ai/tty7/issues @@ -76,6 +81,10 @@ Source: "{#StageDir}\tty7-app.exe"; DestDir: "{app}"; Flags: ignoreversion ; installer to do it, and one code path serving both is one behaviour to debug. ; The uninstaller takes that entry back out; see RemoveAppDirFromUserPath below. Source: "{#StageDir}\tty7.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#StageDir}\tty7-updater.exe"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist +; This installer-only marker is the authority for enabling automatic Windows +; updates. The portable archive is created before the marker enters the stage. +Source: "{#StageDir}\.tty7-inno-install"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs Source: "{#StageDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2123db7e..29491501 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,8 +127,8 @@ jobs: timeout-minutes: 20 run: cargo test --locked --target ${{ matrix.target }} - - name: Test macOS updater - if: runner.os == 'macOS' + - name: Test desktop updater + if: runner.os == 'macOS' || runner.os == 'Windows' timeout-minutes: 10 run: cargo test --locked --features updater --bin tty7-updater --target ${{ matrix.target }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e152ae33..38af39cc 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -112,7 +112,13 @@ jobs: - name: Build working-directory: tty7 - run: cargo build --release --target ${{ matrix.target }} + shell: bash + run: | + cargo build --release --target "${{ matrix.target }}" + if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then + cargo build --release --features updater \ + --bin tty7-updater --target "${{ matrix.target }}" + fi - name: Bundle macOS DMG if: matrix.os == 'macos' @@ -127,6 +133,25 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: bash .github/scripts/bundle-macos.sh "${{ matrix.target }}" "${{ matrix.arch }}" + - name: Verify macOS Nightly update archive + if: matrix.os == 'macos' + working-directory: tty7 + shell: bash + run: | + set -euo pipefail + VERSION="${{ needs.plan.outputs.version }}" + ZIP="dist/tty7-${VERSION}-macos-${{ matrix.arch }}.zip" + VERIFY_ROOT="$RUNNER_TEMP/tty7-nightly-update-verify" + rm -rf "$VERIFY_ROOT" + mkdir -p "$VERIFY_ROOT" + /usr/bin/ditto -x -k "$ZIP" "$VERIFY_ROOT" + APP="$VERIFY_ROOT/tty7.app" + test -x "$APP/Contents/MacOS/tty7-updater" + ACTUAL_VERSION="$(/usr/libexec/PlistBuddy \ + -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" + test "$ACTUAL_VERSION" = "$VERSION" + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" + - name: Package Linux tarball if: matrix.os == 'linux' working-directory: tty7 @@ -152,6 +177,18 @@ jobs: shell: pwsh run: '& ./.github/scripts/bundle-windows.ps1 "${{ matrix.target }}" "${{ matrix.arch }}"' + # Nightly builds the same packages as release.yml, so it gets the same + # check. Nightly is not an update channel — nobody updates *into* these + # artifacts — but a marker or version regression shows up here a night + # before it would reach a stable release. + - name: Verify Windows update package + if: matrix.os == 'windows' + working-directory: tty7 + shell: pwsh + run: >- + & ./.github/scripts/verify-windows-package.ps1 + "${{ matrix.arch }}" "${{ needs.plan.outputs.version }}" + # Same glob list as release.yml's Release step: the bundle scripts leave # intermediates in dist/ (tty7.app, entitlements.plist, the Windows # staging dir) that must not reach the release assets. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2ed9ed7..41a11a1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: shell: bash run: | cargo build --release --locked --target "${{ matrix.target }}" - if [[ "${{ matrix.os }}" == "macos" ]]; then + if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then cargo build --release --locked --features updater \ --bin tty7-updater --target "${{ matrix.target }}" fi @@ -136,6 +136,15 @@ jobs: shell: pwsh run: '& ./.github/scripts/bundle-windows.ps1 "${{ matrix.target }}" "${{ matrix.arch }}"' + # The in-app updater refuses a package whose marker, helper or stamped + # version is wrong — on the user's machine, after the download. Check the + # same facts here so a packaging mistake fails the release instead. + - name: Verify Windows update package + if: matrix.os == 'windows' + working-directory: tty7 + shell: pwsh + run: '& ./.github/scripts/verify-windows-package.ps1 "${{ matrix.arch }}"' + # Hand the artifacts to the assemble job rather than uploading them to the # release here. Four parallel jobs each publishing their own slice would # make the release "latest" the moment the *first* platform finished — the diff --git a/Cargo.lock b/Cargo.lock index 28719411..d1bde944 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9656,6 +9656,7 @@ version = "26.8.1" dependencies = [ "alacritty_terminal", "anyhow", + "async_zip", "core-foundation 0.10.0", "gpui", "gpui-component", diff --git a/Cargo.toml b/Cargo.toml index dfaf00da..a4f88659 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,10 +139,16 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_Foundation", + "Win32_Storage_FileSystem", "Win32_System_Registry", + "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } +# The standalone updater extracts only Windows release ZIPs. Reuse the async +# reader already present in Cargo.lock and enable only the Deflate codec emitted +# by PowerShell's Compress-Archive. +async_zip = { version = "0.0.18", default-features = false, features = ["deflate"], optional = true } # COM for toast branding (`core::aumid`): IShellLinkW + IPropertyStore stamp # System.AppUserModel.ID onto the Start Menu shortcut, which Windows requires @@ -215,7 +221,7 @@ workspace = true [features] default = [] -updater = [] +updater = ["dep:async_zip"] # ---- Standalone workspace mirroring gpui-component's pins so the git/source # ---- caches are shared and versions stay aligned. ---- diff --git a/src/bin/tty7-updater.rs b/src/bin/tty7-updater.rs index 00e04189..27f9831d 100644 --- a/src/bin/tty7-updater.rs +++ b/src/bin/tty7-updater.rs @@ -1,4 +1,8 @@ -#![cfg_attr(not(target_os = "macos"), allow(dead_code))] +#![cfg_attr( + all(target_os = "windows", not(debug_assertions)), + windows_subsystem = "windows" +)] +#![cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] #[cfg(target_os = "macos")] mod macos { @@ -36,6 +40,9 @@ mod macos { .parse::() .map_err(|_| "parent pid is not an unsigned integer".to_string())?; let current = next_path(&mut args)?; + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; let stage = next_path(&mut args)?; let expected_version = next_string(&mut args)?; let log = next_path(&mut args)?; @@ -43,6 +50,9 @@ mod macos { install(InstallPlan { parent_pid, current, + archive, + checksums, + asset_name, stage, expected_version, log, @@ -55,7 +65,8 @@ mod macos { fn usage() -> String { "usage: tty7-updater verify \ \n\ - or: tty7-updater install " + or: tty7-updater install \ + " .to_string() } @@ -80,16 +91,21 @@ mod macos { struct InstallPlan { parent_pid: u32, current: PathBuf, + archive: PathBuf, + checksums: PathBuf, + asset_name: String, stage: PathBuf, expected_version: String, log: PathBuf, } fn install(plan: InstallPlan) -> Result<(), String> { - log_line(&plan.log, "re-verifying staged tty7 update"); let replacement = plan.stage.join("unpacked/tty7.app"); wait_for_exit(plan.parent_pid); - if let Err(error) = verify_update(&plan.current, &replacement, &plan.expected_version) { + log_line(&plan.log, "re-verifying staged tty7 update"); + let verification = verify_archive(&plan.archive, &plan.checksums, &plan.asset_name) + .and_then(|()| verify_update(&plan.current, &replacement, &plan.expected_version)); + if let Err(error) = verification { log_line(&plan.log, &error); let _ = fs::remove_dir_all(&plan.stage); let _ = launch_app(&plan.current); @@ -415,6 +431,1504 @@ mod macos { let error = verify_archive(&archive, &manifest, "tty7.zip").unwrap_err(); assert!(error.contains("failed sha256 verification"), "{error}"); } + + #[test] + fn bundle_version_preserves_the_complete_nightly_identity() { + let root = tempfile::tempdir().unwrap(); + let app = root.path().join("tty7.app"); + let contents = app.join("Contents"); + fs::create_dir_all(&contents).unwrap(); + fs::write( + contents.join("Info.plist"), + r#" + + + CFBundleShortVersionString + 26.8.2-nightly.20260803 + + +"#, + ) + .unwrap(); + + assert_eq!(bundle_version(&app).unwrap(), "26.8.2-nightly.20260803"); + } + } +} + +#[cfg(target_os = "windows")] +mod windows { + use std::collections::HashSet; + use std::ffi::{OsStr, OsString, c_void}; + use std::fs::{self, OpenOptions}; + use std::io::Write as _; + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt as _; + use std::path::{Component, Path, PathBuf}; + use std::process::{Child, Command, ExitStatus, Stdio}; + use std::ptr::null_mut; + use std::thread; + use std::time::Duration; + + use smol::io::AsyncReadExt as _; + + use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_INVALID_PARAMETER, GetLastError, HANDLE, WAIT_FAILED, + }; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, + }; + use windows_sys::Win32::System::Threading::{ + INFINITE, OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + + const LAUNCH_GRACE: Duration = Duration::from_secs(1); + const PORTABLE_PAYLOAD_DIR: &str = "portable-payload"; + const PORTABLE_MARKER: &str = ".tty7-portable"; + const PORTABLE_MARKER_CONTENT: &[u8] = b"portable-v1"; + const MAX_PORTABLE_ENTRIES: usize = 4096; + const MAX_PORTABLE_EXPANDED_BYTES: u64 = 1024 * 1024 * 1024; + const PORTABLE_MANAGED_ROOTS: [&str; 8] = [ + "tty7-app.exe", + "tty7.exe", + "tty7-updater.exe", + PORTABLE_MARKER, + "completions", + "server", + "LICENSE.txt", + "README.md", + ]; + + pub fn run() -> Result<(), String> { + let mut args = std::env::args_os().skip(1); + let command = args + .next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage)?; + match command.as_str() { + "verify" => { + let installer = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let expected_version = next_string(&mut args)?; + reject_extra(args)?; + verify_update(&installer, &checksums, &asset_name, &expected_version) + } + "verify-portable" => { + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let expected_version = next_string(&mut args)?; + let stage = next_path(&mut args)?; + reject_extra(args)?; + verify_portable_update( + &archive, + &checksums, + &asset_name, + &expected_version, + &stage.join(PORTABLE_PAYLOAD_DIR), + ) + } + "install" => { + let parent_pid = next_string(&mut args)? + .parse::() + .map_err(|_| "parent pid is not an unsigned integer".to_string())?; + let installer = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let install_dir = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + let log = next_path(&mut args)?; + let stage = next_path(&mut args)?; + reject_extra(args)?; + install(InstallPlan { + parent_pid, + installer, + checksums, + asset_name, + install_dir, + expected_version, + log, + stage, + }) + } + "install-portable" => { + let parent_pid = next_string(&mut args)? + .parse::() + .map_err(|_| "parent pid is not an unsigned integer".to_string())?; + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let install_dir = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + let log = next_path(&mut args)?; + let stage = next_path(&mut args)?; + reject_extra(args)?; + install_portable(PortableInstallPlan { + parent_pid, + archive, + checksums, + asset_name, + install_dir, + expected_version, + log, + stage, + }) + } + "cleanup" => { + let parent_pid = next_string(&mut args)? + .parse::() + .map_err(|_| "parent pid is not an unsigned integer".to_string())?; + let stage = next_path(&mut args)?; + reject_extra(args)?; + wait_for_exit(parent_pid)?; + fs::remove_dir_all(&stage) + .map_err(|error| format!("removing {}: {error}", stage.display())) + } + _ => Err(usage()), + } + } + + fn usage() -> String { + "usage: tty7-updater verify \n\ + or: tty7-updater install \ + \n\ + or: tty7-updater verify-portable \ + \n\ + or: tty7-updater install-portable \ + \n\ + or: tty7-updater cleanup " + .to_string() + } + + fn next_path(args: &mut impl Iterator) -> Result { + args.next().map(PathBuf::from).ok_or_else(usage) + } + + fn next_string(args: &mut impl Iterator) -> Result { + args.next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage) + } + + fn reject_extra(mut args: impl Iterator) -> Result<(), String> { + if args.next().is_some() { + Err(usage()) + } else { + Ok(()) + } + } + + struct InstallPlan { + parent_pid: u32, + installer: PathBuf, + checksums: PathBuf, + asset_name: String, + install_dir: PathBuf, + expected_version: String, + log: PathBuf, + stage: PathBuf, + } + + struct PortableInstallPlan { + parent_pid: u32, + archive: PathBuf, + checksums: PathBuf, + asset_name: String, + install_dir: PathBuf, + expected_version: String, + log: PathBuf, + stage: PathBuf, + } + + fn install(plan: InstallPlan) -> Result<(), String> { + log_line(&plan.log, "waiting for the tty7 GUI to exit"); + if let Err(error) = wait_for_exit(plan.parent_pid) { + return recover_from_failed_update(&plan, error); + } + log_line(&plan.log, "re-verifying the staged Windows installer"); + if let Err(error) = verify_update( + &plan.installer, + &plan.checksums, + &plan.asset_name, + &plan.expected_version, + ) { + return recover_from_failed_update(&plan, error); + } + + log_line(&plan.log, "running the tty7 Windows installer"); + let status = match run_installer(&plan.installer, &plan.log) { + Ok(status) => status, + Err(error) => { + return recover_from_failed_update(&plan, error); + } + }; + if !status.success() { + let error = format!("the Windows installer exited with {status}"); + return recover_from_failed_update(&plan, error); + } + + if let Err(error) = verify_installed_payload(&plan.install_dir, &plan.expected_version) { + return recover_from_failed_update(&plan, error); + } + log_line(&plan.log, "the Windows update completed; relaunching tty7"); + let result = launch_app(&plan.install_dir); + if let Err(error) = &result { + log_line(&plan.log, error); + } + queue_cleanup(&plan.install_dir, &plan.stage); + result + } + + /// Records one terminal update failure and restores the same recovery + /// behavior for every step that can fail after the GUI starts shutting down. + fn recover_from_failed_update(plan: &InstallPlan, error: String) -> Result<(), String> { + recover_without_replacement(&plan.log, &plan.install_dir, &plan.stage, error) + } + + fn install_portable(plan: PortableInstallPlan) -> Result<(), String> { + log_line(&plan.log, "waiting for the tty7 GUI to exit"); + if let Err(error) = wait_for_exit(plan.parent_pid) { + return recover_without_replacement(&plan.log, &plan.install_dir, &plan.stage, error); + } + + let payload = plan.stage.join(PORTABLE_PAYLOAD_DIR); + if let Err(error) = remove_path(&payload) { + return recover_without_replacement(&plan.log, &plan.install_dir, &plan.stage, error); + } + log_line( + &plan.log, + "re-verifying the staged Windows portable archive", + ); + if let Err(error) = verify_portable_update( + &plan.archive, + &plan.checksums, + &plan.asset_name, + &plan.expected_version, + &payload, + ) { + return recover_without_replacement(&plan.log, &plan.install_dir, &plan.stage, error); + } + + log_line( + &plan.log, + "stopping the tty7 daemon before replacing portable files", + ); + if let Err(error) = stop_daemon_from_payload(&payload) { + return recover_without_replacement(&plan.log, &plan.install_dir, &plan.stage, error); + } + + log_line(&plan.log, "replacing the tty7 Windows portable files"); + let result = replace_portable_and_relaunch( + &plan.install_dir, + &payload, + |directory| { + verify_installed_payload(directory, &plan.expected_version)?; + launch_app(directory) + }, + launch_app, + ); + if let Err(error) = &result { + log_line(&plan.log, error); + } + queue_cleanup(&plan.install_dir, &plan.stage); + result + } + + /// Restores GUI availability when the portable files have not been moved + /// yet, then delegates stage removal to the installed helper copy. + fn recover_without_replacement( + log: &Path, + install_dir: &Path, + stage: &Path, + error: String, + ) -> Result<(), String> { + log_line(log, &error); + let _ = launch_app(install_dir); + queue_cleanup(install_dir, stage); + Err(error) + } + + fn verify_update( + installer: &Path, + checksums: &Path, + asset_name: &str, + expected_version: &str, + ) -> Result<(), String> { + if installer.file_name() != Some(OsStr::new(asset_name)) { + return Err(format!( + "the staged installer filename does not match the release asset {asset_name:?}" + )); + } + verify_archive(installer, checksums, asset_name)?; + // The release manifest and installer are published together. Repeating + // this digest check after the GUI exits catches corruption or local + // replacement while the helper waits to acquire the installed files. + verify_file_version(installer, expected_version, "staged Windows installer") + } + + fn verify_portable_update( + archive: &Path, + checksums: &Path, + asset_name: &str, + expected_version: &str, + payload: &Path, + ) -> Result<(), String> { + if archive.file_name() != Some(OsStr::new(asset_name)) { + return Err(format!( + "the staged portable archive filename does not match the release asset \ + {asset_name:?}" + )); + } + verify_archive(archive, checksums, asset_name)?; + extract_portable_archive(archive, payload)?; + verify_portable_payload(payload, expected_version) + } + + fn extract_portable_archive(archive: &Path, payload: &Path) -> Result<(), String> { + if payload.exists() { + return Err(format!( + "the portable payload directory already exists: {}", + payload.display() + )); + } + let bytes = + fs::read(archive).map_err(|error| format!("reading {}: {error}", archive.display()))?; + let archive = smol::block_on(async_zip::base::read::mem::ZipFileReader::new(bytes)) + .map_err(|error| format!("opening the portable ZIP: {error}"))?; + let entries = archive.file().entries(); + if entries.len() > MAX_PORTABLE_ENTRIES { + return Err(format!( + "the portable ZIP has {} entries; the limit is {MAX_PORTABLE_ENTRIES}", + entries.len() + )); + } + fs::create_dir(payload) + .map_err(|error| format!("creating {}: {error}", payload.display()))?; + + let mut seen = HashSet::new(); + let mut expanded_bytes = 0u64; + for (index, entry) in entries.iter().enumerate() { + let name = entry + .filename() + .as_str() + .map_err(|error| format!("reading portable ZIP entry {index} name: {error}"))?; + if name.contains('\\') { + return Err(format!( + "the portable ZIP path uses a non-canonical separator: {name}" + )); + } + if entry + .unix_permissions() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err(format!("the portable ZIP contains a symbolic link: {name}")); + } + let relative = PathBuf::from(name); + let key = portable_path_key(&relative)?; + if !seen.insert(key) { + return Err(format!( + "the portable ZIP contains a duplicate path: {}", + relative.display() + )); + } + validate_portable_relative_path(&relative)?; + expanded_bytes = expanded_bytes + .checked_add(entry.uncompressed_size()) + .ok_or_else(|| "the portable ZIP expanded-size total overflowed".to_string())?; + if expanded_bytes > MAX_PORTABLE_EXPANDED_BYTES { + return Err(format!( + "the portable ZIP expands past the {} byte limit", + MAX_PORTABLE_EXPANDED_BYTES + )); + } + + let output = payload.join(&relative); + let is_directory = entry + .dir() + .map_err(|error| format!("reading portable ZIP entry {name}: {error}"))?; + if is_directory { + if entry.uncompressed_size() != 0 { + return Err(format!( + "the portable ZIP directory entry has file data: {name}" + )); + } + fs::create_dir_all(&output) + .map_err(|error| format!("creating {}: {error}", output.display()))?; + continue; + } + if let Some(parent) = output.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("creating {}: {error}", parent.display()))?; + } + let mut destination = OpenOptions::new() + .write(true) + .create_new(true) + .open(&output) + .map_err(|error| format!("creating {}: {error}", output.display()))?; + let mut entry_reader = smol::block_on(archive.reader_with_entry(index)) + .map_err(|error| format!("opening portable ZIP entry {name}: {error}"))?; + let expected_size = entry.uncompressed_size(); + let expected_crc = entry.crc32(); + smol::block_on(async { + let mut buffer = [0u8; 64 * 1024]; + let mut written = 0u64; + loop { + let read = entry_reader + .read(&mut buffer) + .await + .map_err(|error| format!("extracting {}: {error}", output.display()))?; + if read == 0 { + break; + } + destination + .write_all(&buffer[..read]) + .map_err(|error| format!("writing {}: {error}", output.display()))?; + written = written.checked_add(read as u64).ok_or_else(|| { + format!("the extracted size overflowed for {}", output.display()) + })?; + if written > expected_size { + return Err(format!( + "the portable ZIP entry expands past its declared size: {name}" + )); + } + } + if written != expected_size { + return Err(format!( + "the portable ZIP entry size is {written}, expected {expected_size}: {name}" + )); + } + let actual_crc = entry_reader.compute_hash(); + if actual_crc != expected_crc { + return Err(format!( + "the portable ZIP entry failed CRC32 verification: {name}" + )); + } + Ok(()) + })?; + } + Ok(()) + } + + fn validate_portable_relative_path(path: &Path) -> Result<(), String> { + let mut components = path.components(); + let Some(Component::Normal(root)) = components.next() else { + return Err(format!( + "the portable ZIP contains an unsafe path that is not relative: {}", + path.display() + )); + }; + let root = root + .to_str() + .ok_or_else(|| format!("the portable ZIP path is not UTF-8: {}", path.display()))?; + if !PORTABLE_MANAGED_ROOTS.contains(&root) { + return Err(format!( + "the portable ZIP contains an unknown top-level entry: {root}" + )); + } + validate_windows_component(root)?; + for component in components { + let Component::Normal(component) = component else { + return Err(format!( + "the portable ZIP contains an unsafe path with a non-normal component: {}", + path.display() + )); + }; + let component = component + .to_str() + .ok_or_else(|| format!("the portable ZIP path is not UTF-8: {}", path.display()))?; + validate_windows_component(component)?; + } + Ok(()) + } + + fn validate_windows_component(component: &str) -> Result<(), String> { + const INVALID: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + if component.is_empty() + || component.ends_with(' ') + || component.ends_with('.') + || component.chars().any(|character| { + character == '\0' || character < ' ' || INVALID.contains(&character) + }) + { + return Err(format!( + "the portable ZIP contains an invalid Windows path component: {component:?}" + )); + } + let stem = component.split('.').next().unwrap_or(component); + let reserved = matches!( + stem.to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ); + if reserved { + return Err(format!( + "the portable ZIP contains a reserved Windows path component: {component:?}" + )); + } + Ok(()) + } + + fn portable_path_key(path: &Path) -> Result { + path.components() + .map(|component| match component { + Component::Normal(component) => { + component.to_str().map(str::to_lowercase).ok_or_else(|| { + format!("the portable ZIP path is not UTF-8: {}", path.display()) + }) + } + _ => Err(format!( + "the portable ZIP contains an unsafe path with a non-normal component: {}", + path.display() + )), + }) + .collect::, _>>() + .map(|components| components.join("/")) + } + + fn verify_portable_payload(payload: &Path, expected_version: &str) -> Result<(), String> { + for required in [ + "tty7-app.exe", + "tty7.exe", + "tty7-updater.exe", + PORTABLE_MARKER, + "LICENSE.txt", + "README.md", + ] { + let path = payload.join(required); + if !path.is_file() { + return Err(format!( + "the portable ZIP is missing the required file {}", + path.display() + )); + } + } + let completions = payload.join("completions"); + if !completions.is_dir() { + return Err(format!( + "the portable ZIP is missing the required directory {}", + completions.display() + )); + } + let marker = fs::read(payload.join(PORTABLE_MARKER)) + .map_err(|error| format!("reading the portable marker: {error}"))?; + if marker != PORTABLE_MARKER_CONTENT { + return Err("the portable ZIP has an invalid .tty7-portable marker".to_string()); + } + verify_binary_version( + &payload.join("tty7-app.exe"), + expected_version, + "staged portable tty7-app.exe", + )?; + verify_binary_version( + &payload.join("tty7-updater.exe"), + expected_version, + "staged portable tty7-updater.exe", + ) + } + + fn verify_installed_payload(install_dir: &Path, expected_version: &str) -> Result<(), String> { + // Validate the files at their final destination rather than trusting the + // installer or copy operation to preserve the already-verified payload. + for (name, label) in [ + ("tty7-app.exe", "installed tty7-app.exe"), + ("tty7-updater.exe", "installed tty7-updater.exe"), + ] { + let binary = install_dir.join(name); + if !binary.is_file() { + return Err(format!( + "the Windows update did not create {}", + binary.display() + )); + } + verify_binary_version(&binary, expected_version, label)?; + } + Ok(()) + } + + fn verify_archive(archive: &Path, checksums: &Path, asset_name: &str) -> Result<(), String> { + let bytes = + fs::read(archive).map_err(|error| format!("reading {}: {error}", archive.display()))?; + let manifest = fs::read_to_string(checksums) + .map_err(|error| format!("reading {}: {error}", checksums.display()))?; + tty7_core::daemon::install::checksums::verify(&manifest, asset_name, &bytes) + .map_err(|error| error.to_string()) + } + + fn run_installer(installer: &Path, log: &Path) -> Result { + Command::new(installer) + .args(installer_arguments(log)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|error| format!("starting {}: {error}", installer.display())) + } + + fn stop_daemon_from_payload(payload: &Path) -> Result<(), String> { + let executable = payload.join("tty7-app.exe"); + let status = Command::new(&executable) + .arg("--stop-daemon") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|error| { + format!( + "stopping the tty7 daemon with {}: {error}", + executable.display() + ) + })?; + if status.success() { + Ok(()) + } else { + Err(format!("stopping the tty7 daemon exited with {status}")) + } + } + + fn replace_portable_and_relaunch( + install_dir: &Path, + payload: &Path, + activate_replacement: impl Fn(&Path) -> Result<(), String>, + relaunch_previous: impl Fn(&Path) -> Result<(), String>, + ) -> Result<(), String> { + // A unique backup inside the portable directory is on the same volume + // as every managed path, so moving old files aside does not degrade to + // a cross-volume copy. Keep it explicitly: if rollback itself fails, + // dropping a TempDir must never delete the only remaining old binary. + let backup = match tempfile::Builder::new() + .prefix(".tty7-update-backup-") + .tempdir_in(install_dir) + { + Ok(backup) => backup.keep(), + Err(error) => { + let cause = format!( + "creating a portable update backup in {}: {error}", + install_dir.display() + ); + // The daemon has already stopped, but no installed files have + // moved yet. Restore GUI availability before returning the + // backup error so every post-shutdown failure recovers alike. + return Err(with_relaunch_failure(cause, relaunch_previous(install_dir))); + } + }; + + let mut moved = Vec::new(); + for root in PORTABLE_MANAGED_ROOTS { + let current = install_dir.join(root); + if !current.exists() { + continue; + } + let previous = backup.join(root); + if let Err(error) = fs::rename(¤t, &previous) { + let cause = format!( + "moving {} into the update backup: {error}", + current.display() + ); + let restore = restore_moved_roots(install_dir, &backup, &moved); + let relaunch = relaunch_previous(install_dir); + if restore.is_ok() { + let _ = remove_path(&backup); + } + return Err(recovery_error(cause, restore, relaunch, &backup)); + } + moved.push(root); + } + + let copy_result = PORTABLE_MANAGED_ROOTS + .iter() + .map(|root| (payload.join(root), install_dir.join(root))) + .filter(|(source, _)| source.exists()) + .try_for_each(|(source, destination)| copy_path(&source, &destination)); + if let Err(error) = copy_result { + return rollback_portable_failure(install_dir, &backup, error, &relaunch_previous); + } + + if let Err(error) = activate_replacement(install_dir) { + return rollback_portable_failure(install_dir, &backup, error, &relaunch_previous); + } + + // The replacement survived its launch grace period. Old managed files + // are no longer needed; an antivirus-held backup is harmless and can be + // removed manually rather than turning a successful update into rollback. + let _ = remove_path(&backup); + Ok(()) + } + + fn rollback_portable_failure( + install_dir: &Path, + backup: &Path, + cause: String, + relaunch_previous: &impl Fn(&Path) -> Result<(), String>, + ) -> Result<(), String> { + let restore = restore_portable_backup(install_dir, backup); + let relaunch = relaunch_previous(install_dir); + if restore.is_ok() { + let _ = remove_path(backup); + } + Err(recovery_error(cause, restore, relaunch, backup)) + } + + fn restore_moved_roots( + install_dir: &Path, + backup: &Path, + moved: &[&str], + ) -> Result<(), String> { + let mut errors = Vec::new(); + for root in moved.iter().rev() { + let previous = backup.join(root); + let destination = install_dir.join(root); + if let Err(error) = fs::rename(&previous, &destination) { + errors.push(format!( + "restoring {} from the update backup: {error}", + destination.display() + )); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + fn restore_portable_backup(install_dir: &Path, backup: &Path) -> Result<(), String> { + let mut errors = Vec::new(); + for root in PORTABLE_MANAGED_ROOTS { + let destination = install_dir.join(root); + if let Err(error) = remove_path(&destination) { + errors.push(error); + continue; + } + let previous = backup.join(root); + if previous.exists() + && let Err(error) = fs::rename(&previous, &destination) + { + errors.push(format!( + "restoring {} from the update backup: {error}", + destination.display() + )); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + fn recovery_error( + cause: String, + restore: Result<(), String>, + relaunch: Result<(), String>, + backup: &Path, + ) -> String { + let mut message = cause; + if let Err(error) = restore { + message.push_str(&format!( + "; restoring the previous portable files failed: {error}; backup preserved at {}", + backup.display() + )); + } + with_relaunch_failure(message, relaunch) + } + + fn with_relaunch_failure(mut message: String, relaunch: Result<(), String>) -> String { + if let Err(error) = relaunch { + message.push_str(&format!("; relaunching the previous tty7 failed: {error}")); + } + message + } + + fn copy_path(source: &Path, destination: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(source) + .map_err(|error| format!("reading {}: {error}", source.display()))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "refusing to copy a symbolic link from the portable payload: {}", + source.display() + )); + } + if metadata.is_dir() { + fs::create_dir(destination) + .map_err(|error| format!("creating {}: {error}", destination.display()))?; + for entry in fs::read_dir(source) + .map_err(|error| format!("reading {}: {error}", source.display()))? + { + let entry = entry.map_err(|error| { + format!("reading an entry in {}: {error}", source.display()) + })?; + copy_path(&entry.path(), &destination.join(entry.file_name()))?; + } + return Ok(()); + } + if metadata.is_file() { + fs::copy(source, destination).map_err(|error| { + format!( + "copying {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + return Ok(()); + } + Err(format!( + "the portable payload contains an unsupported filesystem entry: {}", + source.display() + )) + } + + fn installer_arguments(log: &Path) -> Vec { + let mut log_argument = OsString::from("/LOG="); + log_argument.push(log); + vec![ + OsString::from("/SP-"), + OsString::from("/VERYSILENT"), + OsString::from("/SUPPRESSMSGBOXES"), + OsString::from("/NORESTART"), + OsString::from("/CLOSEAPPLICATIONS"), + log_argument, + ] + } + + fn launch_app(install_dir: &Path) -> Result<(), String> { + let executable = install_dir.join("tty7-app.exe"); + let mut child = Command::new(&executable) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("launching {}: {error}", executable.display()))?; + healthy_after_grace(&mut child) + } + + fn healthy_after_grace(child: &mut Child) -> Result<(), String> { + thread::sleep(LAUNCH_GRACE); + match child + .try_wait() + .map_err(|error| format!("checking the relaunched app: {error}"))? + { + None => Ok(()), + Some(status) => Err(format!( + "the relaunched app exited immediately with {status}" + )), + } + } + + fn wait_for_exit(pid: u32) -> Result<(), String> { + // Opening the handle before the GUI exits makes PID reuse irrelevant: + // the kernel handle continues to name the original process object. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + if handle.is_null() { + let error = unsafe { GetLastError() }; + if error == ERROR_INVALID_PARAMETER { + return Ok(()); + } + return Err(format!("opening parent process {pid}: OS error {error}")); + } + let handle = OwnedHandle(handle); + let result = unsafe { WaitForSingleObject(handle.0, INFINITE) }; + if result == WAIT_FAILED { + return Err(format!( + "waiting for parent process {pid}: OS error {}", + unsafe { GetLastError() } + )); + } + Ok(()) + } + + struct OwnedHandle(HANDLE); + + impl Drop for OwnedHandle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + + fn verify_file_version(path: &Path, expected: &str, label: &str) -> Result<(), String> { + let expected = parse_version(expected) + .ok_or_else(|| format!("the expected update version {expected:?} is invalid"))?; + let actual = file_version(path)?; + if actual != expected { + return Err(format!( + "the {label} reports version {}.{}.{} but the release expects {}.{}.{}", + actual.0, actual.1, actual.2, expected.0, expected.1, expected.2 + )); + } + Ok(()) + } + + fn verify_binary_version(path: &Path, expected: &str, label: &str) -> Result<(), String> { + verify_file_version(path, expected, label)?; + let expected = expected.trim().trim_start_matches('v'); + let actual = product_version(path)?; + if actual != expected { + return Err(format!( + "the {label} reports product version {actual:?} but the release expects {expected:?}" + )); + } + Ok(()) + } + + fn file_version(path: &Path) -> Result<(u16, u16, u16), String> { + let data = version_resource(path)?; + let root = wide_string("\\"); + let mut value: *mut c_void = null_mut(); + let mut value_len = 0u32; + if unsafe { + VerQueryValueW( + data.as_ptr() as *const c_void, + root.as_ptr(), + &mut value, + &mut value_len, + ) + } == 0 + || value.is_null() + || value_len < size_of::() as u32 + { + return Err(format!( + "the version resource in {} has no fixed file information", + path.display() + )); + } + let info = unsafe { &*(value as *const VS_FIXEDFILEINFO) }; + Ok(( + (info.dwFileVersionMS >> 16) as u16, + info.dwFileVersionMS as u16, + (info.dwFileVersionLS >> 16) as u16, + )) + } + + fn product_version(path: &Path) -> Result { + let data = version_resource(path)?; + let translation_path = wide_string("\\VarFileInfo\\Translation"); + let mut translations: *mut c_void = null_mut(); + let mut translations_len = 0u32; + if unsafe { + VerQueryValueW( + data.as_ptr() as *const c_void, + translation_path.as_ptr(), + &mut translations, + &mut translations_len, + ) + } == 0 + || translations.is_null() + || translations_len < 4 + { + return Err(format!( + "the version resource in {} has no language translation", + path.display() + )); + } + + // Translation entries are two little-endian u16 values: language and + // code page. Try every advertised string table instead of assuming the + // common en-US/Unicode pair. + for offset in (0..translations_len as usize).step_by(4) { + if offset + 4 > translations_len as usize { + break; + } + let entry = unsafe { (translations as *const u8).add(offset) }; + let language = u16::from_le_bytes(unsafe { [*entry, *entry.add(1)] }); + let code_page = u16::from_le_bytes(unsafe { [*entry.add(2), *entry.add(3)] }); + let query = wide_string(&format!( + "\\StringFileInfo\\{language:04x}{code_page:04x}\\ProductVersion" + )); + let mut value: *mut c_void = null_mut(); + let mut value_len = 0u32; + if unsafe { + VerQueryValueW( + data.as_ptr() as *const c_void, + query.as_ptr(), + &mut value, + &mut value_len, + ) + } == 0 + || value.is_null() + || value_len == 0 + { + continue; + } + let value = + unsafe { std::slice::from_raw_parts(value as *const u16, value_len as usize) }; + let value = value.strip_suffix(&[0]).unwrap_or(value); + return String::from_utf16(value).map_err(|error| { + format!( + "the ProductVersion string in {} is invalid UTF-16: {error}", + path.display() + ) + }); + } + + Err(format!( + "the version resource in {} has no ProductVersion string", + path.display() + )) + } + + fn version_resource(path: &Path) -> Result, String> { + let wide = wide_path(path); + let mut ignored = 0u32; + let size = unsafe { GetFileVersionInfoSizeW(wide.as_ptr(), &mut ignored) }; + if size == 0 { + return Err(format!( + "reading the version resource from {}: OS error {}", + path.display(), + unsafe { GetLastError() } + )); + } + let mut data = vec![0u8; size as usize]; + if unsafe { GetFileVersionInfoW(wide.as_ptr(), 0, size, data.as_mut_ptr() as *mut c_void) } + == 0 + { + return Err(format!( + "reading the version resource from {}", + path.display() + )); + } + Ok(data) + } + + fn parse_version(version: &str) -> Option<(u16, u16, u16)> { + let core = version + .trim() + .trim_start_matches('v') + .split(['-', '+']) + .next()?; + let mut parts = core.split('.'); + let result = ( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + ); + parts.next().is_none().then_some(result) + } + + fn wide_path(path: &Path) -> Vec { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + fn wide_string(value: &str) -> Vec { + OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + fn queue_cleanup(install_dir: &Path, stage: &Path) { + // The helper cannot remove its own running image. A short-lived copy + // from the installation waits for this process, then removes the whole + // private stage. This needs no administrator-only delayed-delete state. + let cleaner = install_dir.join("tty7-updater.exe"); + if Command::new(&cleaner) + .arg("cleanup") + .arg(std::process::id().to_string()) + .arg(stage) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .is_err() + { + // Preserve only the running helper when the installed cleanup copy + // is unavailable. The small residual directory is safer than using + // a shell command whose quoting could target the wrong path. + let current = std::env::current_exe().ok(); + if let Ok(entries) = fs::read_dir(stage) { + for entry in entries.flatten() { + let path = entry.path(); + if current.as_deref() == Some(path.as_path()) { + continue; + } + let _ = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + } + } + } + } + + fn remove_path(path: &Path) -> Result<(), String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("reading {}: {error}", path.display())), + }; + let result = if metadata.is_dir() && !metadata.file_type().is_symlink() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + result.map_err(|error| format!("removing {}: {error}", path.display())) + } + + fn log_line(path: &Path, message: &str) { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{message}"); + } + } + + #[cfg(test)] + mod tests { + use super::*; + use std::cell::Cell; + use std::os::windows::ffi::OsStringExt as _; + + #[test] + fn parses_release_versions_for_windows_resources() { + assert_eq!(parse_version("27.1.2"), Some((27, 1, 2))); + assert_eq!(parse_version("v27.1.2+build.4"), Some((27, 1, 2))); + assert_eq!(parse_version("27.1.3-nightly.20260803"), Some((27, 1, 3))); + assert_eq!(parse_version("27.1"), None); + assert_eq!(parse_version("27.1.2.3"), None); + } + + #[test] + fn reads_the_complete_product_version_from_the_current_binary() { + let executable = std::env::current_exe().unwrap(); + assert_eq!( + product_version(&executable).unwrap(), + env!("CARGO_PKG_VERSION") + ); + } + + #[test] + fn installed_payload_verification_requires_matching_app_and_updater() { + let root = tempfile::tempdir().unwrap(); + let executable = std::env::current_exe().unwrap(); + fs::copy(&executable, root.path().join("tty7-app.exe")).unwrap(); + + let error = + verify_installed_payload(root.path(), env!("CARGO_PKG_VERSION")).unwrap_err(); + assert!(error.contains("tty7-updater.exe"), "{error}"); + + fs::copy(&executable, root.path().join("tty7-updater.exe")).unwrap(); + verify_installed_payload(root.path(), env!("CARGO_PKG_VERSION")).unwrap(); + } + + #[test] + fn silent_installer_arguments_keep_the_log_path_native() { + let log = Path::new(r"C:\Users\测试 User\tty7 update.log"); + let arguments = installer_arguments(log); + assert!(arguments.contains(&OsString::from("/VERYSILENT"))); + let expected: OsString = OsString::from_wide( + &OsStr::new(r"/LOG=C:\Users\测试 User\tty7 update.log") + .encode_wide() + .collect::>(), + ); + assert!(arguments.contains(&expected)); + } + + #[test] + fn archive_verification_rejects_tampered_installer_bytes() { + let root = tempfile::tempdir().unwrap(); + let installer = root.path().join("tty7-1.0.0-windows-x86_64-setup.exe"); + let manifest = root.path().join("checksums.txt"); + fs::write(&installer, b"tampered bytes").unwrap(); + fs::write( + &manifest, + format!( + "{} {}\n", + tty7_core::daemon::install::checksums::hex( + &tty7_core::daemon::install::checksums::sha256(b"published bytes") + ), + installer.file_name().unwrap().to_string_lossy() + ), + ) + .unwrap(); + + let error = verify_archive( + &installer, + &manifest, + installer.file_name().unwrap().to_str().unwrap(), + ) + .unwrap_err(); + assert!(error.contains("failed sha256 verification"), "{error}"); + } + + #[test] + fn update_verification_accepts_an_unsigned_matching_windows_binary() { + let root = tempfile::tempdir().unwrap(); + let asset_name = format!( + "tty7-{}-windows-x86_64-setup.exe", + env!("CARGO_PKG_VERSION") + ); + let installer = root.path().join(&asset_name); + let manifest = root.path().join("checksums.txt"); + + // Cargo test binaries carry the package version resource but are + // not Authenticode-signed, making this a direct regression fixture + // for the checksum-and-version-only update policy. + let bytes = fs::read(std::env::current_exe().unwrap()).unwrap(); + fs::write(&installer, &bytes).unwrap(); + fs::write( + &manifest, + format!( + "{} {asset_name}\n", + tty7_core::daemon::install::checksums::hex( + &tty7_core::daemon::install::checksums::sha256(&bytes) + ) + ), + ) + .unwrap(); + + verify_update( + &installer, + &manifest, + &asset_name, + env!("CARGO_PKG_VERSION"), + ) + .unwrap(); + } + + #[test] + fn portable_archive_verification_extracts_a_complete_release_payload() { + let root = tempfile::tempdir().unwrap(); + let asset_name = format!("tty7-{}-windows-x86_64.zip", env!("CARGO_PKG_VERSION")); + let archive = root.path().join(&asset_name); + let manifest = root.path().join("checksums.txt"); + let payload = root.path().join("payload"); + let executable = fs::read(std::env::current_exe().unwrap()).unwrap(); + write_test_zip( + &archive, + &[ + ("tty7-app.exe", executable.clone()), + ("tty7.exe", executable.clone()), + ("tty7-updater.exe", executable), + (PORTABLE_MARKER, PORTABLE_MARKER_CONTENT.to_vec()), + ("completions/powershell.json", b"{}".to_vec()), + ("LICENSE.txt", b"license".to_vec()), + ("README.md", b"readme".to_vec()), + ], + ); + write_manifest(&archive, &manifest, &asset_name); + + verify_portable_update( + &archive, + &manifest, + &asset_name, + env!("CARGO_PKG_VERSION"), + &payload, + ) + .unwrap(); + assert_eq!( + fs::read(payload.join(PORTABLE_MARKER)).unwrap(), + PORTABLE_MARKER_CONTENT + ); + assert!(payload.join("completions/powershell.json").is_file()); + } + + #[test] + fn portable_archive_rejects_paths_that_escape_the_payload() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("unsafe.zip"); + let payload = root.path().join("payload"); + write_test_zip(&archive, &[("../outside.txt", b"escape".to_vec())]); + + let error = extract_portable_archive(&archive, &payload).unwrap_err(); + assert!(error.contains("unsafe path"), "{error}"); + assert!(!root.path().join("outside.txt").exists()); + } + + #[test] + fn portable_archive_rejects_unknown_and_case_duplicate_paths() { + let root = tempfile::tempdir().unwrap(); + let unknown = root.path().join("unknown.zip"); + write_test_zip(&unknown, &[("notes.txt", b"user data".to_vec())]); + let error = + extract_portable_archive(&unknown, &root.path().join("unknown")).unwrap_err(); + assert!(error.contains("unknown top-level entry"), "{error}"); + + let duplicate = root.path().join("duplicate.zip"); + write_test_zip( + &duplicate, + &[ + ("README.md", b"one".to_vec()), + ("readme.md", b"two".to_vec()), + ], + ); + let error = + extract_portable_archive(&duplicate, &root.path().join("duplicate")).unwrap_err(); + assert!(error.contains("duplicate path"), "{error}"); + } + + #[test] + fn portable_replacement_preserves_unmanaged_user_files() { + let install = tempfile::tempdir().unwrap(); + let payload = tempfile::tempdir().unwrap(); + fs::write(install.path().join("tty7-app.exe"), b"old app").unwrap(); + fs::create_dir(install.path().join("completions")).unwrap(); + fs::write( + install.path().join("completions/old.json"), + b"old completion", + ) + .unwrap(); + fs::write(install.path().join("my-script.ps1"), b"user file").unwrap(); + fs::write(payload.path().join("tty7-app.exe"), b"new app").unwrap(); + fs::create_dir(payload.path().join("completions")).unwrap(); + fs::write( + payload.path().join("completions/new.json"), + b"new completion", + ) + .unwrap(); + + replace_portable_and_relaunch( + install.path(), + payload.path(), + |directory| { + assert_eq!( + fs::read(directory.join("tty7-app.exe")).unwrap(), + b"new app" + ); + Ok(()) + }, + |_| panic!("the previous version must not relaunch after success"), + ) + .unwrap(); + + assert_eq!( + fs::read(install.path().join("tty7-app.exe")).unwrap(), + b"new app" + ); + assert!(install.path().join("completions/new.json").is_file()); + assert!(!install.path().join("completions/old.json").exists()); + assert_eq!( + fs::read(install.path().join("my-script.ps1")).unwrap(), + b"user file" + ); + } + + #[test] + fn portable_replacement_rolls_back_when_the_new_app_does_not_start() { + let install = tempfile::tempdir().unwrap(); + let payload = tempfile::tempdir().unwrap(); + fs::write(install.path().join("tty7-app.exe"), b"old app").unwrap(); + fs::write(install.path().join("tty7.exe"), b"old cli").unwrap(); + fs::write(install.path().join("notes.txt"), b"user file").unwrap(); + fs::write(payload.path().join("tty7-app.exe"), b"new app").unwrap(); + fs::write(payload.path().join("tty7.exe"), b"new cli").unwrap(); + let relaunched = Cell::new(0usize); + + let error = replace_portable_and_relaunch( + install.path(), + payload.path(), + |_| Err("the new app exited immediately".to_string()), + |_| { + relaunched.set(relaunched.get() + 1); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error.contains("new app exited immediately"), "{error}"); + assert_eq!(relaunched.get(), 1); + assert_eq!( + fs::read(install.path().join("tty7-app.exe")).unwrap(), + b"old app" + ); + assert_eq!( + fs::read(install.path().join("tty7.exe")).unwrap(), + b"old cli" + ); + assert_eq!( + fs::read(install.path().join("notes.txt")).unwrap(), + b"user file" + ); + } + + #[test] + fn portable_replacement_relaunches_when_backup_creation_fails() { + let root = tempfile::tempdir().unwrap(); + let install = root.path().join("not-a-directory"); + let payload = tempfile::tempdir().unwrap(); + fs::write(&install, b"unchanged installation sentinel").unwrap(); + let relaunched = Cell::new(0usize); + + let error = replace_portable_and_relaunch( + &install, + payload.path(), + |_| panic!("replacement activation must not run without a backup"), + |directory| { + assert_eq!(directory, install); + relaunched.set(relaunched.get() + 1); + Ok(()) + }, + ) + .unwrap_err(); + + assert!( + error.contains("creating a portable update backup"), + "{error}" + ); + assert_eq!(relaunched.get(), 1); + assert_eq!( + fs::read(&install).unwrap(), + b"unchanged installation sentinel" + ); + } + + fn write_test_zip(path: &Path, entries: &[(&str, Vec)]) { + let bytes = smol::block_on(async { + let mut output = Vec::new(); + { + let mut writer = async_zip::base::write::ZipFileWriter::new(&mut output); + for (name, bytes) in entries { + let options = async_zip::ZipEntryBuilder::new( + (*name).into(), + async_zip::Compression::Stored, + ); + writer.write_entry_whole(options, bytes).await.unwrap(); + } + writer.close().await.unwrap(); + } + output + }); + fs::write(path, bytes).unwrap(); + } + + fn write_manifest(archive: &Path, manifest: &Path, asset_name: &str) { + let bytes = fs::read(archive).unwrap(); + fs::write( + manifest, + format!( + "{} {asset_name}\n", + tty7_core::daemon::install::checksums::hex( + &tty7_core::daemon::install::checksums::sha256(&bytes) + ) + ), + ) + .unwrap(); + } } } @@ -426,8 +1940,16 @@ fn main() { } } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] fn main() { - eprintln!("tty7-updater is only available on macOS"); + if let Err(error) = windows::run() { + eprintln!("tty7-updater: {error}"); + std::process::exit(1); + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn main() { + eprintln!("tty7-updater is only available on macOS and Windows"); std::process::exit(1); } diff --git a/src/core/update.rs b/src/core/update.rs index 2ee09982..d0b93c33 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -17,14 +17,58 @@ pub const RELEASES_URL: &str = "https://github.com/l0ng-ai/tty7/releases/latest" const CHECK_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(target_os = "windows")] +const WINDOWS_INNO_INSTALL_MARKER: &str = ".tty7-inno-install"; +#[cfg(target_os = "windows")] +const WINDOWS_PORTABLE_MARKER: &str = ".tty7-portable"; +#[cfg(target_os = "windows")] +const WINDOWS_PORTABLE_MARKER_CONTENT: &[u8] = b"portable-v1"; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct AvailableUpdate { pub version: String, pub installable: bool, - pub install_hint: Option, + pub install_hint: Option, asset: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpdateInstallHint { + #[cfg(target_os = "macos")] + UnsupportedMacos, + #[cfg(target_os = "linux")] + UnsupportedLinux, + #[cfg(target_os = "windows")] + UnsupportedWindows, + #[cfg(target_os = "windows")] + WindowsAllUsersInstall, + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + UnsupportedPlatform, + MissingPackage(String), + MissingChecksums, +} + +impl UpdateInstallHint { + fn english(&self) -> String { + match self { + #[cfg(target_os = "macos")] + Self::UnsupportedMacos => "This copy is not running from a writable tty7.app bundle, so replacing it would be unsafe. Move tty7 to Applications or another writable folder, or open the release page to install the update.".to_string(), + #[cfg(target_os = "linux")] + Self::UnsupportedLinux => "The first in-app updater supports packaged macOS app bundles. Use the release page or your package manager to update this Linux installation.".to_string(), + #[cfg(target_os = "windows")] + Self::UnsupportedWindows => "Automatic Windows updates are available for recognized Inno Setup and portable ZIP installations. This copy is missing a valid installation marker, updater, or writable portable directory, so open the release page to update it manually.".to_string(), + #[cfg(target_os = "windows")] + Self::WindowsAllUsersInstall => "tty7 is installed for all users, which needs administrator rights to replace. tty7 will not raise an elevation prompt on its own behalf, so open the release page and run the installer yourself to update it.".to_string(), + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + Self::UnsupportedPlatform => "Automatic installation is not available on this platform. Open the release page.".to_string(), + Self::MissingPackage(name) => format!( + "The release has no {name} package for this installation. Open the release page to choose another package." + ), + Self::MissingChecksums => "The release has no checksums.txt, so tty7 refuses to install it automatically.".to_string(), + } + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum UpdatePhase { #[default] @@ -33,7 +77,14 @@ pub enum UpdatePhase { UpToDate, Downloading, Installing, - Failed(String), + Failed(UpdateFailure), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpdateFailure { + Check(String), + Prepare(String), + Launch(String), } #[derive(Clone, Debug, Default)] @@ -88,12 +139,12 @@ fn spawn_check_inner(report_failure: bool, cx: &mut App) { Err(e) => { log::debug!("update check skipped: {e:#}"); if report_failure { - let message = format!("Could not check for updates: {e:#}"); + let detail = format!("{e:#}"); cx.update(|cx| { set_status( UpdateStatus { available: previous_available, - phase: UpdatePhase::Failed(message), + phase: UpdatePhase::Failed(UpdateFailure::Check(detail)), }, cx, ) @@ -185,9 +236,9 @@ async fn wait_for_window(cx: &mut AsyncApp) -> Option { } fn prompt_update(update: &AvailableUpdate, window: &mut Window, cx: &mut App) { + let install_hint = update.install_hint.as_ref().map(UpdateInstallHint::english); let detail = if update.installable { - let note = update - .install_hint + let note = install_hint .as_deref() .map(|note| format!(" {note}")) .unwrap_or_default(); @@ -202,8 +253,7 @@ fn prompt_update(update: &AvailableUpdate, window: &mut Window, cx: &mut App) { "tty7 {} is available — you're on {}. {}", update.version, env!("CARGO_PKG_VERSION"), - update - .install_hint + install_hint .as_deref() .unwrap_or("This installation cannot update itself.") ) @@ -276,13 +326,13 @@ fn install(update: AvailableUpdate, cx: &mut App) { let prepared = match task.await { Ok(prepared) => prepared, Err(error) => { - let message = format!("Update failed: {error:#}"); - log::error!("{message}"); + let detail = format!("{error:#}"); + log::error!("update failed: {detail}"); cx.update(|cx| { set_status( UpdateStatus { available: Some(update), - phase: UpdatePhase::Failed(message), + phase: UpdatePhase::Failed(UpdateFailure::Prepare(detail)), }, cx, ) @@ -305,13 +355,13 @@ fn install(update: AvailableUpdate, cx: &mut App) { let _ = cx.update(|cx| cx.quit()); } Err(error) => { - let message = format!("Could not start the installer: {error:#}"); - log::error!("{message}"); + let detail = format!("{error:#}"); + log::error!("could not start the installer: {detail}"); cx.update(|cx| { set_status( UpdateStatus { available: Some(update), - phase: UpdatePhase::Failed(message), + phase: UpdatePhase::Failed(UpdateFailure::Launch(detail)), }, cx, ) @@ -392,6 +442,9 @@ async fn fetch_latest_release() -> Result { let client = ReqwestClient::user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION"))) .context("building HTTP client")?; + // `/releases/latest` intentionally excludes prereleases, so Nightly builds + // are offered the Stable release that supersedes them and no rolling + // prerelease can ever become an update source. let url = format!("https://api.github.com/repos/{REPO}/releases/latest"); let request = http_client::Request::get(&url) .header("Accept", "application/vnd.github+json") @@ -429,36 +482,36 @@ struct ReleaseAsset { struct AssetSelection { asset: Option, - reason: Option, + reason: Option, } fn select_release_asset(version: &str, assets: &[GitHubAsset]) -> AssetSelection { select_release_asset_for(package_for_current_install(version), assets) } -fn select_release_asset_for(package: Option, assets: &[GitHubAsset]) -> AssetSelection { - let Some(name) = package else { - return AssetSelection { - asset: None, - reason: Some(unsupported_install_reason()), - }; +fn select_release_asset_for( + package: Result, + assets: &[GitHubAsset], +) -> AssetSelection { + let name = match package { + Ok(name) => name, + Err(reason) => { + return AssetSelection { + asset: None, + reason: Some(reason), + }; + } }; let Some(asset) = assets.iter().find(|asset| asset.name == name) else { return AssetSelection { asset: None, - reason: Some(format!( - "The release has no {name} package for this installation. Open the release page \ - to choose another package." - )), + reason: Some(UpdateInstallHint::MissingPackage(name)), }; }; let Some(checksums) = assets.iter().find(|asset| asset.name == "checksums.txt") else { return AssetSelection { asset: None, - reason: Some( - "The release has no checksums.txt, so tty7 refuses to install it automatically." - .to_string(), - ), + reason: Some(UpdateInstallHint::MissingChecksums), }; }; AssetSelection { @@ -471,48 +524,48 @@ fn select_release_asset_for(package: Option, assets: &[GitHubAsset]) -> } } -fn package_for_current_install(version: &str) -> Option { +/// The release package this installation can replace itself with, or the +/// reason it cannot. +fn package_for_current_install(version: &str) -> Result { #[cfg(target_os = "macos")] { - let app = current_macos_app_bundle()?; + let Some(app) = current_macos_app_bundle() else { + return Err(UpdateInstallHint::UnsupportedMacos); + }; if !is_macos_update_writable(&app) || bundled_updater().is_none() { - return None; + return Err(UpdateInstallHint::UnsupportedMacos); } let arch = if cfg!(target_arch = "aarch64") { "arm64" } else if cfg!(target_arch = "x86_64") { "x86_64" } else { - return None; + return Err(UpdateInstallHint::UnsupportedMacos); }; - return Some(format!("tty7-{version}-macos-{arch}.zip")); - } - #[allow(unreachable_code)] - None -} - -fn unsupported_install_reason() -> String { - #[cfg(target_os = "macos")] - { - return "This copy is not running from a writable tty7.app bundle, so replacing it would be \ - unsafe. Move tty7 to Applications or another writable folder, or open the release \ - page to install the update." - .to_string(); + return Ok(format!("tty7-{version}-macos-{arch}.zip")); } #[cfg(target_os = "linux")] { - return "The first in-app updater supports packaged macOS app bundles. Use the release page \ - or your package manager to update this Linux installation." - .to_string(); + let _ = version; + return Err(UpdateInstallHint::UnsupportedLinux); } #[cfg(target_os = "windows")] { - return "The first in-app updater supports packaged macOS app bundles. Open the release page \ - to update this Windows installation." - .to_string(); + let Some(layout) = current_windows_update_layout() else { + return Err(UpdateInstallHint::UnsupportedWindows); + }; + windows_layout_is_updatable(&layout)?; + if !layout.directory().join("tty7-updater.exe").is_file() { + return Err(UpdateInstallHint::UnsupportedWindows); + } + return windows_package_for_layout(version, &layout) + .ok_or(UpdateInstallHint::UnsupportedWindows); + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = version; + Err(UpdateInstallHint::UnsupportedPlatform) } - #[allow(unreachable_code)] - "Automatic installation is not available on this platform. Open the release page.".to_string() } fn prepare_update(version: &str, asset: &ReleaseAsset) -> Result { @@ -525,7 +578,16 @@ fn prepare_update(version: &str, asset: &ReleaseAsset) -> Result .get(&asset.url) .map_err(anyhow::Error::msg) .with_context(|| format!("downloading {}", asset.name))?; - prepare_macos_update(version, &asset.name, &archive, &checksums) + #[cfg(target_os = "macos")] + { + return prepare_macos_update(version, &asset.name, &archive, &checksums); + } + #[cfg(target_os = "windows")] + { + return prepare_windows_update(version, &asset.name, &archive, &checksums); + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + anyhow::bail!("automatic installation is not supported on this platform") } #[derive(Debug)] @@ -544,7 +606,7 @@ impl PreparedUpdate { if let Some(config_dir) = self.config_dir { command.env("TTY7_CONFIG_DIR", config_dir); } - command + tty7_core::core::proc::hide_console(&mut command) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -557,6 +619,7 @@ impl PreparedUpdate { } } +#[cfg(target_os = "macos")] fn update_staging_dir(parent: &Path) -> Result { tempfile::Builder::new() .prefix(".tty7-update-") @@ -564,6 +627,14 @@ fn update_staging_dir(parent: &Path) -> Result { .context("creating update staging directory") } +#[cfg(target_os = "windows")] +fn system_update_staging_dir() -> Result { + tempfile::Builder::new() + .prefix("tty7-update-") + .tempdir() + .context("creating the Windows update staging directory") +} + fn write_staged_asset(dir: &Path, name: &str, bytes: &[u8]) -> Result { let path = dir.join(name); std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?; @@ -592,8 +663,8 @@ fn prepare_macos_update( [ PathBuf::from("verify"), current.clone(), - archive, - checksums, + archive.clone(), + checksums.clone(), PathBuf::from(asset_name), dir.clone(), PathBuf::from(version), @@ -611,6 +682,9 @@ fn prepare_macos_update( PathBuf::from("install"), std::process::id().to_string().into(), current, + archive, + checksums, + PathBuf::from(asset_name), dir.clone(), PathBuf::from(version), log, @@ -620,14 +694,88 @@ fn prepare_macos_update( }) } -#[cfg(not(target_os = "macos"))] -fn prepare_macos_update( - _version: &str, - _asset_name: &str, - _archive: &[u8], - _checksums: &[u8], +#[cfg(target_os = "windows")] +fn prepare_windows_update( + version: &str, + asset_name: &str, + package: &[u8], + checksums: &[u8], ) -> Result { - anyhow::bail!("the first in-app updater only supports macOS") + let layout = current_windows_update_layout() + .context("tty7 is not running from a recognized Windows installation")?; + // Re-checked here rather than trusting the check that produced the offer: + // an installation can be relocated, or its privileges changed, between the + // update check and the user pressing the button. + if let Err(hint) = windows_layout_is_updatable(&layout) { + anyhow::bail!("{}", hint.english()); + } + let install_dir = layout.directory().to_path_buf(); + let bundled = bundled_updater().context("tty7-updater.exe is not bundled with this app")?; + let staging = system_update_staging_dir()?; + let dir = staging.path().to_path_buf(); + let package = write_staged_asset(&dir, asset_name, package)?; + let checksums = write_staged_asset(&dir, "checksums.txt", checksums)?; + + // Verification runs before the GUI commits to quitting. Both Windows + // update modes repeat their archive checks after the parent exits. + let install_command = match &layout { + WindowsUpdateLayout::Inno(_) => { + run_updater( + &bundled, + [ + PathBuf::from("verify"), + package.clone(), + checksums.clone(), + PathBuf::from(asset_name), + PathBuf::from(version), + ], + )?; + "install" + } + WindowsUpdateLayout::Portable(_) => { + run_updater( + &bundled, + [ + PathBuf::from("verify-portable"), + package.clone(), + checksums.clone(), + PathBuf::from(asset_name), + PathBuf::from(version), + dir.clone(), + ], + )?; + "install-portable" + } + }; + + // Windows locks a running executable. Run a private copy from the staging + // directory so Inno can replace the bundled helper in the installation. + let updater = dir.join("tty7-updater.exe"); + std::fs::copy(&bundled, &updater) + .with_context(|| format!("copying the Windows updater to {}", updater.display()))?; + + let log = + crate::core::config::config_path("update.log").unwrap_or_else(|| dir.join("update.log")); + if let Some(parent) = log.parent() { + std::fs::create_dir_all(parent).context("creating the update log directory")?; + } + let dir = staging.keep(); + Ok(PreparedUpdate { + updater, + args: vec![ + PathBuf::from(install_command), + std::process::id().to_string().into(), + package, + checksums, + PathBuf::from(asset_name), + install_dir, + PathBuf::from(version), + log, + dir.clone(), + ], + config_dir: crate::core::config::config_dir_path(), + stage: dir, + }) } #[cfg(target_os = "macos")] @@ -648,14 +796,242 @@ fn bundled_updater() -> Option { updater.is_file().then_some(updater) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +fn bundled_updater() -> Option { + let updater = current_windows_update_layout()? + .directory() + .join("tty7-updater.exe"); + updater.is_file().then_some(updater) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] fn bundled_updater() -> Option { None } -#[cfg(not(target_os = "macos"))] -fn current_macos_app_bundle() -> Option { - None +#[cfg(target_os = "windows")] +#[derive(Clone, Debug, PartialEq, Eq)] +enum WindowsUpdateLayout { + Inno(PathBuf), + Portable(PathBuf), +} + +#[cfg(target_os = "windows")] +impl WindowsUpdateLayout { + fn directory(&self) -> &Path { + match self { + Self::Inno(directory) | Self::Portable(directory) => directory, + } + } +} + +#[cfg(target_os = "windows")] +fn windows_package_for_layout(version: &str, layout: &WindowsUpdateLayout) -> Option { + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else { + return None; + }; + Some(match layout { + WindowsUpdateLayout::Inno(_) => format!("tty7-{version}-windows-{arch}-setup.exe"), + WindowsUpdateLayout::Portable(_) => format!("tty7-{version}-windows-{arch}.zip"), + }) +} + +#[cfg(target_os = "windows")] +fn current_windows_update_layout() -> Option { + let executable = std::env::current_exe().ok()?; + windows_update_layout_for(&executable) +} + +#[cfg(target_os = "windows")] +fn windows_update_layout_for(executable: &Path) -> Option { + let directory = executable.parent()?; + if directory.join(WINDOWS_INNO_INSTALL_MARKER).is_file() { + return Some(WindowsUpdateLayout::Inno(directory.to_path_buf())); + } + let marker = std::fs::read(directory.join(WINDOWS_PORTABLE_MARKER)).ok()?; + (marker == WINDOWS_PORTABLE_MARKER_CONTENT) + .then(|| WindowsUpdateLayout::Portable(directory.to_path_buf())) +} + +#[cfg(target_os = "windows")] +fn windows_directory_is_writable(directory: &Path) -> bool { + tempfile::Builder::new() + .prefix(".tty7-update-write-test-") + .tempfile_in(directory) + .is_ok() +} + +/// Rejects the Windows installation layouts that cannot be replaced by this +/// process, before anything is downloaded. +#[cfg(target_os = "windows")] +fn windows_layout_is_updatable(layout: &WindowsUpdateLayout) -> Result<(), UpdateInstallHint> { + match layout { + WindowsUpdateLayout::Inno(directory) => { + if windows_inno_needs_elevation(directory) { + return Err(UpdateInstallHint::WindowsAllUsersInstall); + } + Ok(()) + } + WindowsUpdateLayout::Portable(directory) => { + if !windows_directory_is_writable(directory) { + return Err(UpdateInstallHint::UnsupportedWindows); + } + Ok(()) + } + } +} + +#[cfg(target_os = "windows")] +fn windows_inno_needs_elevation(install_dir: &Path) -> bool { + windows_inno_needs_elevation_for( + windows_all_users_install_path().as_deref(), + install_dir, + windows_directory_is_writable(install_dir), + ) +} + +/// Whether replacing this Inno installation would need administrator rights. +/// +/// The updater runs the release Setup silently, as the signed-in user, from a +/// private staging directory. That is only correct for a per-user install. +/// Two independent signals, because either alone misreads a real machine: +/// +/// * An all-users install records its state under `HKLM`. A silent Setup +/// launched without elevation resolves `{autopf}` to +/// `%LocalAppData%\Programs`, never sees that state, and installs a +/// *second* copy while the real installation goes untouched — or Inno +/// re-launches itself elevated and the user gets a bare UAC prompt for an +/// unsigned executable in `%TEMP%`, seconds after the GUI vanished. +/// Neither outcome is one tty7 should produce on its own initiative. +/// * A directory this process cannot write is one Setup cannot write +/// either, whatever the registry says. This also catches an installation +/// whose uninstall entry was pruned, relocated, or written by a different +/// user account. +/// +/// Pure so the decision is unit-tested without touching the registry or +/// `C:\Program Files`. +#[cfg(target_os = "windows")] +fn windows_inno_needs_elevation_for( + all_users_app_path: Option<&Path>, + install_dir: &Path, + writable: bool, +) -> bool { + if all_users_app_path.is_some_and(|path| same_windows_directory(path, install_dir)) { + return true; + } + !writable +} + +/// Compares two Windows directory paths the way the filesystem does: without +/// regard to case, and without letting a trailing separator make +/// `C:\Program Files\tty7\` a different place from `C:\Program Files\tty7`. +/// Deliberately textual — `canonicalize` would hit the disk and answers +/// `\\?\`-prefixed, which is not what the registry stores. +#[cfg(target_os = "windows")] +fn same_windows_directory(left: &Path, right: &Path) -> bool { + fn normalize(path: &Path) -> Option { + let text = path.to_str()?.trim_end_matches(['\\', '/']); + (!text.is_empty()).then(|| text.to_lowercase()) + } + match (normalize(left), normalize(right)) { + (Some(left), Some(right)) => left == right, + _ => false, + } +} + +/// The `{app}` directory of an all-users tty7 installation, read from the +/// machine hive. `AppId` is frozen in `windows-installer.iss` for exactly this +/// kind of lookup, and Inno stamps the resolved install directory into +/// `Inno Setup: App Path`. Absent for a per-user install, whose uninstall +/// entry lives under `HKCU` instead. +#[cfg(target_os = "windows")] +fn windows_all_users_install_path() -> Option { + use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _}; + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_READ, REG_SZ, RegCloseKey, RegOpenKeyExW, RegQueryValueExW, + }; + + const UNINSTALL_KEY: &str = concat!( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\", + r"{9A3F6C1E-4B7D-4E2A-8C5F-D01B92E64A37}_is1" + ); + const APP_PATH_VALUE: &str = "Inno Setup: App Path"; + + struct RegistryKey(HKEY); + + impl Drop for RegistryKey { + fn drop(&mut self) { + // SAFETY: only constructed from a successful `RegOpenKeyExW`, and + // owns exactly one handle. + unsafe { + RegCloseKey(self.0); + } + } + } + + fn wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + let path = wide(UNINSTALL_KEY); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: `path` is NUL-terminated and live for the call; `key` is a valid + // out-parameter, wrapped only when the call reports success. The tty7 + // installer is x64-only, so the native 64-bit view is the only one its + // uninstall entry can appear in. + let code = unsafe { RegOpenKeyExW(HKEY_LOCAL_MACHINE, path.as_ptr(), 0, KEY_READ, &mut key) }; + if code != ERROR_SUCCESS { + return None; + } + let key = RegistryKey(key); + + let name = wide(APP_PATH_VALUE); + let mut kind = 0u32; + let mut bytes = 0u32; + // SAFETY: the key is live, the value name is NUL-terminated, and the + // type/size out-parameters are valid; a null data pointer asks for the + // size only. + let code = unsafe { + RegQueryValueExW( + key.0, + name.as_ptr(), + std::ptr::null(), + &mut kind, + std::ptr::null_mut(), + &mut bytes, + ) + }; + if code != ERROR_SUCCESS || kind != REG_SZ || bytes == 0 || !bytes.is_multiple_of(2) { + return None; + } + + let mut value = vec![0u16; bytes as usize / 2]; + // SAFETY: `value` is sized from the query above and stays live; Win32 is + // told its capacity in bytes through `bytes`. + let code = unsafe { + RegQueryValueExW( + key.0, + name.as_ptr(), + std::ptr::null(), + &mut kind, + value.as_mut_ptr().cast(), + &mut bytes, + ) + }; + if code != ERROR_SUCCESS { + return None; + } + value.truncate(bytes as usize / 2); + while value.last() == Some(&0) { + value.pop(); + } + (!value.is_empty()).then(|| PathBuf::from(std::ffi::OsString::from_wide(&value))) } #[cfg(target_os = "macos")] @@ -667,8 +1043,9 @@ fn can_stage_replacement_in(dir: &Path) -> bool { } fn run_updater(updater: &Path, args: impl IntoIterator) -> Result<()> { - let output = Command::new(updater) - .args(args) + let mut command = Command::new(updater); + command.args(args); + let output = tty7_core::core::proc::hide_console(&mut command) .output() .context("running tty7-updater verification")?; if !output.status.success() { @@ -680,6 +1057,13 @@ fn run_updater(updater: &Path, args: impl IntoIterator) -> Resul Ok(()) } +/// `(major, minor, patch, is_release)`. Ordering the release flag last, with +/// `false < true`, is what lets a prerelease be superseded by the stable +/// release that carries the same core version: a Nightly stamped +/// `26.7.1-nightly.20260716` is offered `v26.7.1` and graduates out of the +/// prerelease. Two prereleases sharing a core compare equal, so nothing here +/// can walk a user from one prerelease to another — only `/releases/latest` +/// feeds this comparison, and that endpoint never returns one. fn parse_version(s: &str) -> Option<(u64, u64, u64, bool)> { let trimmed = s.trim(); let core = trimmed.strip_prefix('v').unwrap_or(trimmed); @@ -717,7 +1101,7 @@ mod tests { fn release_asset_requires_the_platform_package_and_checksums() { let name = "tty7-27.1.0-macos-arm64.zip"; let assets = [github_asset(name), github_asset("checksums.txt")]; - let selected = select_release_asset_for(Some(name.to_string()), &assets); + let selected = select_release_asset_for(Ok(name.to_string()), &assets); assert_eq!( selected.asset, Some(ReleaseAsset { @@ -732,31 +1116,26 @@ mod tests { #[test] fn release_without_checksums_is_never_installable() { let name = "tty7-27.1.0-macos-arm64.zip"; - let selected = select_release_asset_for(Some(name.to_string()), &[github_asset(name)]); + let selected = select_release_asset_for(Ok(name.to_string()), &[github_asset(name)]); assert!(selected.asset.is_none()); - assert!( - selected - .reason - .as_deref() - .is_some_and(|reason| reason.contains("checksums.txt")) - ); + assert_eq!(selected.reason, Some(UpdateInstallHint::MissingChecksums)); } #[test] fn release_without_the_exact_platform_package_is_never_guessed() { let selected = select_release_asset_for( - Some("tty7-27.1.0-macos-arm64.zip".to_string()), + Ok("tty7-27.1.0-macos-arm64.zip".to_string()), &[ github_asset("tty7-27.1.0-macos-x86_64.zip"), github_asset("checksums.txt"), ], ); assert!(selected.asset.is_none()); - assert!( - selected - .reason - .as_deref() - .is_some_and(|reason| reason.contains("macos-arm64")) + assert_eq!( + selected.reason, + Some(UpdateInstallHint::MissingPackage( + "tty7-27.1.0-macos-arm64.zip".to_string() + )) ); } @@ -800,6 +1179,10 @@ mod tests { assert!(is_update_available("v26.7.1", "26.7.1-nightly.20260716")); assert!(!is_update_available("v26.7.0", "26.7.1-nightly.20260716")); assert!(!is_update_available("v26.7.1-rc.1", "26.7.1")); + // Nightly is a build channel, not an update channel: one Nightly never + // supersedes another. `/releases/latest` cannot return a prerelease, so + // this pair is unreachable in practice — asserted so a future change to + // the endpoint cannot quietly turn Nightly into an update source. assert!(!is_update_available( "26.7.1-nightly.20260717", "26.7.1-nightly.20260716" @@ -830,4 +1213,144 @@ mod tests { let _ = std::fs::remove_file(&path); } + + #[cfg(target_os = "windows")] + #[test] + fn windows_markers_distinguish_inno_portable_and_unknown_layouts() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("tty7-app.exe"); + std::fs::write(&executable, b"test app").unwrap(); + assert_eq!(windows_update_layout_for(&executable), None); + + std::fs::write(root.path().join(WINDOWS_PORTABLE_MARKER), b"portable-v1").unwrap(); + assert_eq!( + windows_update_layout_for(&executable), + Some(WindowsUpdateLayout::Portable(root.path().to_path_buf())) + ); + + std::fs::write(root.path().join(WINDOWS_INNO_INSTALL_MARKER), b"inno-v1").unwrap(); + assert_eq!( + windows_update_layout_for(&executable), + Some(WindowsUpdateLayout::Inno(root.path().to_path_buf())) + ); + + std::fs::remove_file(root.path().join(WINDOWS_INNO_INSTALL_MARKER)).unwrap(); + std::fs::write(root.path().join(WINDOWS_PORTABLE_MARKER), b"invalid").unwrap(); + assert_eq!(windows_update_layout_for(&executable), None); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_layout_selects_the_matching_release_package() { + let directory = PathBuf::from(r"C:\tty7"); + assert_eq!( + windows_package_for_layout("26.8.2", &WindowsUpdateLayout::Inno(directory.clone())) + .as_deref(), + Some("tty7-26.8.2-windows-x86_64-setup.exe") + ); + assert_eq!( + windows_package_for_layout("26.8.2", &WindowsUpdateLayout::Portable(directory)) + .as_deref(), + Some("tty7-26.8.2-windows-x86_64.zip") + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn an_all_users_inno_install_is_never_updated_in_place() { + let all_users = PathBuf::from(r"C:\Program Files\tty7"); + let per_user = PathBuf::from(r"C:\Users\someone\AppData\Local\Programs\tty7"); + + // The machine-hive entry names this directory: elevation would be + // required, so tty7 declines however writable the directory looks. + assert!(windows_inno_needs_elevation_for( + Some(&all_users), + &all_users, + true + )); + // Inno stores the path with a trailing separator in `InstallLocation` + // and without one in `Inno Setup: App Path`; both name one place. + assert!(windows_inno_needs_elevation_for( + Some(Path::new(r"C:\Program Files\tty7\")), + &all_users, + true + )); + assert!(windows_inno_needs_elevation_for( + Some(Path::new(r"c:\program files\TTY7")), + &all_users, + true + )); + + // A per-user install on a machine that also carries an all-users one + // updates itself: the machine entry names a different directory. + assert!(!windows_inno_needs_elevation_for( + Some(&all_users), + &per_user, + true + )); + assert!(!windows_inno_needs_elevation_for(None, &per_user, true)); + + // No machine entry, but the directory refuses writes — a relocated or + // pruned installation Setup could not replace either. + assert!(windows_inno_needs_elevation_for(None, &per_user, false)); + } + + #[cfg(target_os = "windows")] + #[test] + fn an_all_users_inno_install_reports_the_elevation_hint() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("tty7-app.exe"); + std::fs::write(&executable, b"test app").unwrap(); + std::fs::write(root.path().join(WINDOWS_INNO_INSTALL_MARKER), b"inno-v1").unwrap(); + let layout = windows_update_layout_for(&executable).unwrap(); + + // A writable temp directory is never the all-users installation, so + // this layout is offered the normal in-place update. + assert_eq!(windows_layout_is_updatable(&layout), Ok(())); + + assert_eq!( + select_release_asset_for(Err(UpdateInstallHint::WindowsAllUsersInstall), &[]).reason, + Some(UpdateInstallHint::WindowsAllUsersInstall) + ); + let hint = UpdateInstallHint::WindowsAllUsersInstall.english(); + assert!(hint.contains("all users"), "{hint}"); + assert!(hint.contains("release page"), "{hint}"); + } + + #[cfg(target_os = "windows")] + #[test] + fn an_unwritable_portable_directory_is_not_offered_an_update() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().to_path_buf(); + assert!(windows_directory_is_writable(&directory)); + assert_eq!( + windows_layout_is_updatable(&WindowsUpdateLayout::Portable(directory)), + Ok(()) + ); + + let missing = root.path().join("gone"); + assert!(!windows_directory_is_writable(&missing)); + assert_eq!( + windows_layout_is_updatable(&WindowsUpdateLayout::Portable(missing)), + Err(UpdateInstallHint::UnsupportedWindows) + ); + } + + /// Reads the real machine hive. Vacuous on a machine with no all-users + /// installation; on one that has it, the value Inno actually wrote must be + /// an absolute path and must make the decision function refuse an in-place + /// update of that directory. + #[cfg(target_os = "windows")] + #[test] + fn the_all_users_install_path_lookup_survives_this_machine() { + let Some(path) = windows_all_users_install_path() else { + return; + }; + assert!(path.is_absolute(), "{}", path.display()); + assert!( + windows_inno_needs_elevation_for(Some(&path), &path, true), + "the installed all-users path {} was not recognised", + path.display() + ); + } } diff --git a/src/ui/i18n.rs b/src/ui/i18n.rs index 26c08389..74c0c847 100644 --- a/src/ui/i18n.rs +++ b/src/ui/i18n.rs @@ -356,6 +356,23 @@ pub enum L10nKey { SettingsAboutTech, SettingsVersion, SettingsUpdates, + SettingsUpdateAndRelaunch, + SettingsUpdateViewRelease, + SettingsUpdateChecking, + SettingsUpdateUpToDate, + SettingsUpdateDownloading, + SettingsUpdateInstalling, + SettingsUpdateCheckNow, + SettingsUpdateCheckFailed, + SettingsUpdatePrepareFailed, + SettingsUpdateLaunchFailed, + SettingsUpdateUnsupportedMacos, + SettingsUpdateUnsupportedLinux, + SettingsUpdateUnsupportedWindows, + SettingsUpdateWindowsAllUsers, + SettingsUpdateUnsupportedPlatform, + SettingsUpdateMissingPackage, + SettingsUpdateMissingChecksums, SettingsVersionAvailable, SettingsCheckUpdatesDesc, SettingsCheckUpdatesOnLaunch, @@ -1661,12 +1678,63 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsVersion => ("Version", "版本"), L10nKey::SettingsUpdates => ("Updates", "更新"), + L10nKey::SettingsUpdateAndRelaunch => ("Update and Relaunch", "更新并重新启动"), + L10nKey::SettingsUpdateViewRelease => ("View Release", "查看发布页面"), + L10nKey::SettingsUpdateChecking => ("Checking for updates…", "正在检查更新…"), + L10nKey::SettingsUpdateUpToDate => { + ("You're running the latest version.", "当前已是最新版本。") + } + L10nKey::SettingsUpdateDownloading => ( + "Downloading and verifying the update…", + "正在下载并验证更新…", + ), + L10nKey::SettingsUpdateInstalling => { + ("Relaunching with the update…", "正在通过更新重新启动…") + } + L10nKey::SettingsUpdateCheckNow => ("Check Now", "立即检查"), + L10nKey::SettingsUpdateCheckFailed => ( + "Could not check for updates: {error}", + "无法检查更新:{error}", + ), + L10nKey::SettingsUpdatePrepareFailed => ("Update failed: {error}", "更新失败:{error}"), + L10nKey::SettingsUpdateLaunchFailed => ( + "Could not start the installer: {error}", + "无法启动安装程序:{error}", + ), + L10nKey::SettingsUpdateUnsupportedMacos => ( + "This copy is not running from a writable tty7.app bundle, so replacing it would be unsafe. Move tty7 to Applications or another writable folder, or open the release page to install the update.", + "当前副本并非从可写的 tty7.app 包运行,直接替换并不安全。请将 tty7 移到“应用程序”或其他可写文件夹,或者打开发布页面安装更新。", + ), + L10nKey::SettingsUpdateUnsupportedLinux => ( + "The first in-app updater supports packaged macOS app bundles. Use the release page or your package manager to update this Linux installation.", + "当前应用内更新器支持打包的 macOS 应用。请通过发布页面或包管理器更新此 Linux 安装。", + ), + L10nKey::SettingsUpdateUnsupportedWindows => ( + "Automatic Windows updates are available for recognized Inno Setup and portable ZIP installations. This copy is missing a valid installation marker, updater, or writable portable directory, so open the release page to update it manually.", + "Windows 自动更新适用于可识别的 Inno Setup 安装版和便携 ZIP 版。当前副本缺少有效的安装标记、更新程序或可写的便携目录,请打开发布页面手动更新。", + ), + L10nKey::SettingsUpdateWindowsAllUsers => ( + "tty7 is installed for all users, which needs administrator rights to replace. tty7 will not raise an elevation prompt on its own behalf, so open the release page and run the installer yourself to update it.", + "tty7 是为所有用户安装的,替换它需要管理员权限。tty7 不会自行弹出提权请求,请打开发布页面并自行运行安装程序进行更新。", + ), + L10nKey::SettingsUpdateUnsupportedPlatform => ( + "Automatic installation is not available on this platform. Open the release page.", + "此平台不支持自动安装,请打开发布页面。", + ), + L10nKey::SettingsUpdateMissingPackage => ( + "The release has no {name} package for this installation. Open the release page to choose another package.", + "该版本没有适用于当前安装的 {name} 包。请打开发布页面选择其他包。", + ), + L10nKey::SettingsUpdateMissingChecksums => ( + "The release has no checksums.txt, so tty7 refuses to install it automatically.", + "该版本缺少 checksums.txt,因此 tty7 拒绝自动安装。", + ), L10nKey::SettingsVersionAvailable => { ("Version {version} is available.", "新版本 {version} 可用。") } L10nKey::SettingsCheckUpdatesDesc => ( - "Check GitHub for a newer release on launch and show it here. tty7 never updates itself — downloading happens on the Releases page.", - "启动时检查 GitHub 是否有新版本并在此显示。tty7 不会自行更新——下载在 Releases 页面完成。", + "tty7 checks stable releases on launch. Packaged macOS bundles and per-user Windows installations update without opening a browser: a dedicated helper verifies the checksum and version before replacing anything, then relaunches the GUI. Linux, all-users Windows installations and other unsupported layouts fall back to the release page.", + "tty7 会在启动时检查稳定版发布。打包的 macOS 应用和为当前用户安装的 Windows 版本无需打开浏览器即可更新:专用助手会在替换前验证校验和与版本,然后重新启动界面。Linux、为所有用户安装的 Windows 版本以及其他不受支持的安装布局则会打开发布页面。", ), L10nKey::SettingsCheckUpdatesOnLaunch => ("Check for updates on launch", "启动时检查更新"), L10nKey::SettingsCommandLine => ("Command line", "命令行"), @@ -3248,6 +3316,23 @@ mod tests { L10nKey::SettingsAboutDesc2, L10nKey::SettingsAboutTech, L10nKey::SettingsUpdates, + L10nKey::SettingsUpdateAndRelaunch, + L10nKey::SettingsUpdateViewRelease, + L10nKey::SettingsUpdateChecking, + L10nKey::SettingsUpdateUpToDate, + L10nKey::SettingsUpdateDownloading, + L10nKey::SettingsUpdateInstalling, + L10nKey::SettingsUpdateCheckNow, + L10nKey::SettingsUpdateCheckFailed, + L10nKey::SettingsUpdatePrepareFailed, + L10nKey::SettingsUpdateLaunchFailed, + L10nKey::SettingsUpdateUnsupportedMacos, + L10nKey::SettingsUpdateUnsupportedLinux, + L10nKey::SettingsUpdateUnsupportedWindows, + L10nKey::SettingsUpdateWindowsAllUsers, + L10nKey::SettingsUpdateUnsupportedPlatform, + L10nKey::SettingsUpdateMissingPackage, + L10nKey::SettingsUpdateMissingChecksums, L10nKey::SettingsVersionAvailable, L10nKey::SettingsCheckUpdatesDesc, L10nKey::SettingsCheckUpdatesOnLaunch, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 42383c7b..b871ddc8 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -109,6 +109,59 @@ impl ExplorerContextMenuNote { } } +fn localized_update_phase(phase: &crate::core::update::UpdatePhase) -> Option { + use crate::core::update::{UpdateFailure, UpdatePhase}; + + match phase { + UpdatePhase::Idle => None, + UpdatePhase::Checking => Some(t(L10nKey::SettingsUpdateChecking).to_string()), + UpdatePhase::UpToDate => Some(t(L10nKey::SettingsUpdateUpToDate).to_string()), + UpdatePhase::Downloading => Some(t(L10nKey::SettingsUpdateDownloading).to_string()), + UpdatePhase::Installing => Some(t(L10nKey::SettingsUpdateInstalling).to_string()), + UpdatePhase::Failed(failure) => { + let (key, error) = match failure { + UpdateFailure::Check(error) => (L10nKey::SettingsUpdateCheckFailed, error), + UpdateFailure::Prepare(error) => (L10nKey::SettingsUpdatePrepareFailed, error), + UpdateFailure::Launch(error) => (L10nKey::SettingsUpdateLaunchFailed, error), + }; + Some(t_fmt(key, &[("error", error)])) + } + } +} + +fn localized_update_install_hint(hint: &crate::core::update::UpdateInstallHint) -> String { + use crate::core::update::UpdateInstallHint; + + match hint { + #[cfg(target_os = "macos")] + UpdateInstallHint::UnsupportedMacos => { + t(L10nKey::SettingsUpdateUnsupportedMacos).to_string() + } + #[cfg(target_os = "linux")] + UpdateInstallHint::UnsupportedLinux => { + t(L10nKey::SettingsUpdateUnsupportedLinux).to_string() + } + #[cfg(target_os = "windows")] + UpdateInstallHint::UnsupportedWindows => { + t(L10nKey::SettingsUpdateUnsupportedWindows).to_string() + } + #[cfg(target_os = "windows")] + UpdateInstallHint::WindowsAllUsersInstall => { + t(L10nKey::SettingsUpdateWindowsAllUsers).to_string() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + UpdateInstallHint::UnsupportedPlatform => { + t(L10nKey::SettingsUpdateUnsupportedPlatform).to_string() + } + UpdateInstallHint::MissingPackage(name) => { + t_fmt(L10nKey::SettingsUpdateMissingPackage, &[("name", name)]) + } + UpdateInstallHint::MissingChecksums => { + t(L10nKey::SettingsUpdateMissingChecksums).to_string() + } + } +} + fn settings_search_entries() -> &'static [SearchEntry] { use L10nKey::*; use SettingsSection::*; @@ -1364,7 +1417,10 @@ impl Tty7App { .child(div().flex_1().child(Slider::new(&slider))) .child( div() - .w(px(36.)) + .w(px(38.)) + .flex_shrink_0() + .whitespace_nowrap() + .text_right() .text_sm() .text_color(cx.theme().foreground) .child(format!("{:.0}%", opacity * 100.)), @@ -1488,7 +1544,10 @@ impl Tty7App { .child(div().flex_1().child(Slider::new(&slider))) .child( div() - .w(px(36.)) + .w(px(38.)) + .flex_shrink_0() + .whitespace_nowrap() + .text_right() .text_sm() .text_color(cx.theme().foreground) .child(format!("{:.0}%", readout * 100.)), @@ -3498,7 +3557,10 @@ impl Tty7App { .child(div().flex_1().child(Slider::new(&scroll_slider))) .child( div() - .w(px(36.)) + .w(px(38.)) + .flex_shrink_0() + .whitespace_nowrap() + .text_right() .text_sm() .text_color(foreground) .child(format!("{scroll_mult:.2}×")), @@ -4657,20 +4719,7 @@ impl Tty7App { | crate::core::update::UpdatePhase::Downloading | crate::core::update::UpdatePhase::Installing ); - let phase_text = match &update_status.phase { - crate::core::update::UpdatePhase::Idle => None, - crate::core::update::UpdatePhase::Checking => Some("Checking for updates…".to_string()), - crate::core::update::UpdatePhase::UpToDate => { - Some("You're running the latest version.".to_string()) - } - crate::core::update::UpdatePhase::Downloading => { - Some("Downloading and verifying the update…".to_string()) - } - crate::core::update::UpdatePhase::Installing => { - Some("Relaunching with the update…".to_string()) - } - crate::core::update::UpdatePhase::Failed(message) => Some(message.clone()), - }; + let phase_text = localized_update_phase(&update_status.phase); let check_for_updates = cx.global::().check_for_updates; let install_cli_on_path = cx.global::().install_cli_on_path; let (explorer_status, explorer_note) = self @@ -4805,10 +4854,14 @@ impl Tty7App { ) .when_some(update, |this, upd| { let button_label = if upd.installable { - "Update and Relaunch" + t(L10nKey::SettingsUpdateAndRelaunch).to_string() } else { - "View Release" + t(L10nKey::SettingsUpdateViewRelease).to_string() }; + let availability = t_fmt( + L10nKey::SettingsVersionAvailable, + &[("version", &upd.version)], + ); this.child( v_flex() .gap_1() @@ -4816,10 +4869,12 @@ impl Tty7App { h_flex() .gap_3() .items_center() - .child(div().text_sm().text_color(foreground).child(t_fmt( - L10nKey::SettingsVersionAvailable, - &[("version", &upd.version)], - ))) + .child( + div() + .text_sm() + .text_color(foreground) + .child(availability), + ) .child( Button::new("install-update") .label(button_label) @@ -4831,27 +4886,37 @@ impl Tty7App { ), ) .when_some(upd.install_hint, |this, hint| { - this.child(div().text_xs().text_color(muted_fg).child(hint)) + this.child( + div() + .text_xs() + .text_color(muted_fg) + .child(localized_update_install_hint(&hint)), + ) }), ) }) .when_some(phase_text, |this, text| { this.child(div().text_sm().text_color(muted_fg).child(text)) }) - .child(div().text_sm().text_color(muted_fg).child( - "tty7 checks stable releases and can update packaged macOS app bundles without opening a browser. A dedicated helper verifies checksums, version, and code signing before replacement, then relaunches the GUI. Compatible servers and shells stay running; if the wire protocol changed, tty7 asks whether to restart the server after relaunch. Other platforms and unsupported layouts fall back to the release page.", - )) + .child( + div() + .text_sm() + .text_color(muted_fg) + .child(t(L10nKey::SettingsCheckUpdatesDesc)), + ) .child( h_flex().child( Button::new("check-update-now") - .label(if matches!( - update_status.phase, - crate::core::update::UpdatePhase::Checking - ) { - "Checking…" - } else { - "Check Now" - }) + .label( + if matches!( + update_status.phase, + crate::core::update::UpdatePhase::Checking + ) { + t(L10nKey::SettingsUpdateChecking) + } else { + t(L10nKey::SettingsUpdateCheckNow) + }, + ) .small() .disabled(update_busy) .on_click(cx.listener(|_, _, _window, cx| { @@ -4932,12 +4997,7 @@ impl Tty7App { ), ) .when_some(explorer_feedback, |section, message| { - section.child( - div() - .text_xs() - .text_color(muted_fg) - .child(message), - ) + section.child(div().text_xs().text_color(muted_fg).child(message)) }) .child( div() From 4cf3d4dad55e4ee7bbd09813507ac9af4011070f Mon Sep 17 00:00:00 2001 From: Hongwei Qin <122079993+shihuaidexianyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:57:36 +0800 Subject: [PATCH 2/6] fix(install): prefer bundled server over release download for SSH remotes (#344) * fix(install): prefer bundled server over release download for SSH remotes SSH remote installs used , which only checks and ignores the server binary already shipped next to the Windows executable. WSL already uses to find that bundled binary. Add to auto-discover the bundled server and fall back to the GitHub release download only when no matching local asset exists. Switch and to use it. This lets the Windows installer/zip (which already stages server binaries under /server/) satisfy SSH remote installs without hitting the network. The explicit path keeps its strict no-fallback behavior, and WSL remains bundled-only. Refs: future issue/PR for bundling server binaries into Windows releases. * style(install): fix rustfmt formatting in bundled-server tests --------- Co-authored-by: l0ng-ai --- crates/tty7-core/src/daemon/install/mod.rs | 31 ++++++++++-- crates/tty7-core/src/daemon/install/tests.rs | 52 ++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs index 574f9e3e..209da220 100644 --- a/crates/tty7-core/src/daemon/install/mod.rs +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -128,6 +128,9 @@ pub trait ServerBinarySource: Send + Sync { pub struct BundledOrRelease<'a> { pub fetch: &'a dyn AssetFetcher, pub bundled: Option, + /// When a bundled directory is configured but the requested asset is absent, + /// fall back to the release download instead of failing with `MissingBundled`. + pub fallback_on_missing: bool, } impl<'a> BundledOrRelease<'a> { @@ -135,6 +138,18 @@ impl<'a> BundledOrRelease<'a> { Self { fetch, bundled: wsl::BundledServerBinary::from_env_only(), + fallback_on_missing: false, + } + } + + /// Prefer a server binary shipped next to the client executable (see + /// `wsl::BundledServerBinary::discover`), falling back to the GitHub release + /// download when no matching bundled asset is present. + pub fn discover(fetch: &'a dyn AssetFetcher) -> Self { + Self { + fetch, + bundled: Some(wsl::BundledServerBinary::discover()), + fallback_on_missing: true, } } } @@ -151,7 +166,17 @@ impl ServerBinarySource for BundledOrRelease<'_> { on_progress: &dyn Fn(u64, Option), ) -> Result { match &self.bundled { - Some(bundled) => bundled.load(version, asset), + Some(bundled) => match bundled.load(version, asset) { + Ok(binary) => Ok(binary), + Err(InstallError::MissingBundled { .. }) if self.fallback_on_missing => { + ReleaseDownload { fetch: self.fetch }.load_with_progress( + version, + asset, + on_progress, + ) + } + Err(e) => Err(e), + }, None => ReleaseDownload { fetch: self.fetch }.load_with_progress( version, asset, @@ -1017,7 +1042,7 @@ pub fn ensure_remote_server_labeled(conn: &Arc, host: &str) -> io let ops = ssh_ops::SshRemoteOps::new(conn.clone()); let fetch = default_fetcher(); let confirm = install_confirm(); - let source = BundledOrRelease::from_env(fetch.as_ref()); + let source = BundledOrRelease::discover(fetch.as_ref()); let report = Installer::with_source(&ops, &source, confirm.as_ref(), host).run()?; log::info!( "remote {host}: {} at {} ({}{})", @@ -1055,7 +1080,7 @@ pub fn replace_remote_server(conn: &Arc) -> io::Result<()> { let ops = ssh_ops::SshRemoteOps::new(conn.clone()); let fetch = default_fetcher(); let confirm = install_confirm(); - let source = BundledOrRelease::from_env(fetch.as_ref()); + let source = BundledOrRelease::discover(fetch.as_ref()); Installer::with_source(&ops, &source, confirm.as_ref(), host).replace()?; Ok(()) } diff --git a/crates/tty7-core/src/daemon/install/tests.rs b/crates/tty7-core/src/daemon/install/tests.rs index abe32835..6b0109dd 100644 --- a/crates/tty7-core/src/daemon/install/tests.rs +++ b/crates/tty7-core/src/daemon/install/tests.rs @@ -980,6 +980,7 @@ fn without_a_bundle_the_source_is_the_plain_download() { let source = BundledOrRelease { fetch: &release, bundled: None, + fallback_on_missing: false, }; let loaded = source.load("26.7.5", ASSET_X86_64).expect("downloads"); assert_eq!(loaded.bytes, SERVER_BYTES); @@ -1001,6 +1002,7 @@ fn a_bundle_is_used_instead_of_downloading() { let source = BundledOrRelease { fetch: &release, bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), + fallback_on_missing: false, }; let loaded = source.load("26.7.5", ASSET_X86_64).expect("loads locally"); assert_eq!(loaded.bytes, b"\x7fELF local build"); @@ -1026,6 +1028,7 @@ fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() { let source = BundledOrRelease { fetch: &release, bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), + fallback_on_missing: false, }; let err = source.load("26.7.5", ASSET_X86_64).expect_err("no binary"); assert!(matches!(err, InstallError::MissingBundled { .. }), "{err}"); @@ -1040,6 +1043,55 @@ fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn discover_falls_back_to_release_when_bundled_is_missing() { + let dir = std::env::temp_dir().join(format!("tty7-bundle-discover-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let release = FakeRelease::new(); + let source = BundledOrRelease { + fetch: &release, + bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), + fallback_on_missing: true, + }; + let loaded = source + .load("26.7.5", ASSET_X86_64) + .expect("falls back to release"); + assert_eq!(loaded.bytes, SERVER_BYTES); + assert_eq!( + release.fetched().len(), + 2, + "the manifest and the asset must be fetched when the bundled binary is absent" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn discover_uses_bundled_when_it_is_present() { + let dir = std::env::temp_dir().join(format!( + "tty7-bundle-discover-present-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(ASSET_X86_64), b"\x7fELF discovered build").unwrap(); + + let release = FakeRelease::new(); + let source = BundledOrRelease { + fetch: &release, + bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), + fallback_on_missing: true, + }; + let loaded = source.load("26.7.5", ASSET_X86_64).expect("loads locally"); + assert_eq!(loaded.bytes, b"\x7fELF discovered build"); + assert!( + release.fetched().is_empty(), + "a discovered bundled install must not touch the network" + ); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn the_published_path_is_absolute_and_dialect_qualified() { let real = RemoteProtocol::of_this_build(); From 8a7b2f3bea997c3e36915f031c5fbc327a2f24a1 Mon Sep 17 00:00:00 2001 From: ARNO Date: Wed, 5 Aug 2026 11:15:55 +0800 Subject: [PATCH 3/6] style(terminal): increase powerline half-circle segments for smoother curves (#341) Co-authored-by: l0ng-ai --- src/terminal/element.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/element.rs b/src/terminal/element.rs index f2c18bd3..d500ffcc 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -688,7 +688,7 @@ fn powerline_path(bounds: Bounds, shape: PowerlineShape) -> gpui::Path

Date: Wed, 5 Aug 2026 12:39:49 +0800 Subject: [PATCH 4/6] fix(ui): replace remote server binary from the mismatch dialog (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(install): prefer bundled server over release download for SSH remotes SSH remote installs used , which only checks and ignores the server binary already shipped next to the Windows executable. WSL already uses to find that bundled binary. Add to auto-discover the bundled server and fall back to the GitHub release download only when no matching local asset exists. Switch and to use it. This lets the Windows installer/zip (which already stages server binaries under /server/) satisfy SSH remote installs without hitting the network. The explicit path keeps its strict no-fallback behavior, and WSL remains bundled-only. Refs: future issue/PR for bundling server binaries into Windows releases. * style(install): fix rustfmt formatting in bundled-server tests * fix(ui): replace remote server binary from the mismatch dialog The version/protocol mismatch dialog previously offered a 'Restart Server' button that only restarted the existing daemon without replacing the incompatible binary. This left users stuck on the same mismatch after the restart. - Change restart_mismatched_remote_server to call replace_remote_server (replace binary + restart daemon) instead of restart_remote_server. - Add a dedicated L10nKey::RemoteMismatchReplaceServer ('Update Server' / '更新服务器端') and use it for the dialog's action button and detail text. - Update the mismatch title/detail copy so it describes replacing the server binary rather than restarting it. Refs: #351 --------- Co-authored-by: l0ng-ai --- src/ui/i18n.rs | 13 ++++++++----- src/ui/remote_connect.rs | 4 ++-- src/ui/remote_workspace.rs | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/ui/i18n.rs b/src/ui/i18n.rs index 74c0c847..485e196f 100644 --- a/src/ui/i18n.rs +++ b/src/ui/i18n.rs @@ -726,6 +726,7 @@ pub enum L10nKey { RemoteMismatchDetail, RemoteMismatchUnknownBuild, RemoteMismatchUnknownBuildFromExe, + RemoteMismatchReplaceServer, RemoteDaemonStartFailed, RemoteDaemonUnreachable, RemoteDaemonTooOld, @@ -2371,23 +2372,24 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::RemoteInstallBytes => ("bytes", "字节"), L10nKey::RemoteMismatchTitle => ( - "Restart tty7's server on \"{machine}\"?", - "重启 \"{machine}\" 上的 tty7 服务器?", + "Update tty7's server on \"{machine}\"?", + "更新 \"{machine}\" 上的 tty7 服务器端?", ), L10nKey::RemoteMismatchDetail => ( "{machine} is serving tty7 sessions from {running}, which speaks a protocol \ this client ({wanted}) cannot. tty7 has installed a matching server there, \ but the one already running is the one your sessions are on.\n\ \n\ - {restart_server}\u{2003}starts {wanted} there and ends every session it is hosting.\n\ + {replace_server}\u{2003}replaces it with {wanted} and ends every session it is hosting.\n\ {cancel}\u{2003}leaves {machine} exactly as it is. This window will not connect.", "{machine} 正在使用 {running} 提供 tty7 会话,该版本使用的协议无法被\ - 此客户端({wanted})识别。tty7 已在那里安装了匹配的服务器,\ + 此客户端({wanted})识别。tty7 已在那里安装了匹配的服务器端,\ 但正在运行的是你当前会话所在的版本。\n\ \n\ - {restart_server}\u{2003}会在该机器上启动 {wanted} 并结束其托管的所有会话。\n\ + {replace_server}\u{2003}会将其替换为 {wanted} 并结束其托管的所有会话。\n\ {cancel}\u{2003}会保持 {machine} 现状不变。此窗口将不会连接。", ), + L10nKey::RemoteMismatchReplaceServer => ("Update Server", "更新服务器端"), L10nKey::RemoteMismatchUnknownBuild => ("an unknown build", "未知构建"), L10nKey::RemoteMismatchUnknownBuildFromExe => { ("an unknown build (from {exe})", "未知构建(来自 {exe})") @@ -3686,6 +3688,7 @@ mod tests { L10nKey::RemoteMismatchDetail, L10nKey::RemoteMismatchUnknownBuild, L10nKey::RemoteMismatchUnknownBuildFromExe, + L10nKey::RemoteMismatchReplaceServer, L10nKey::RemoteDaemonStartFailed, L10nKey::RemoteDaemonUnreachable, L10nKey::RemoteDaemonTooOld, diff --git a/src/ui/remote_connect.rs b/src/ui/remote_connect.rs index d3065467..0b269a8b 100644 --- a/src/ui/remote_connect.rs +++ b/src/ui/remote_connect.rs @@ -641,7 +641,7 @@ pub(crate) fn claim_mailbox() -> std::sync::MutexGuard<'static, ()> { } pub fn mismatch_answers() -> [&'static str; 2] { - [t(L10nKey::Cancel), t(L10nKey::RestartServer)] + [t(L10nKey::Cancel), t(L10nKey::RemoteMismatchReplaceServer)] } pub fn mismatch_detail(m: &MismatchedRemoteDaemon) -> String { @@ -660,7 +660,7 @@ pub fn mismatch_detail(m: &MismatchedRemoteDaemon) -> String { ("machine", &m.host), ("running", &running), ("wanted", &m.wanted_version), - ("restart_server", t(L10nKey::RestartServer)), + ("replace_server", t(L10nKey::RemoteMismatchReplaceServer)), ("cancel", t(L10nKey::Cancel)), ], ) diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index a49d457a..75e11304 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -492,7 +492,7 @@ impl Tty7App { match remote_connect::mismatch_target(&mismatch) .ok_or_else(|| t_fmt(L10nKey::RemoteNoRouteToHost, &[("machine", &label)])) { - Ok(target) => self.restart_remote_server(target, label, window, cx), + Ok(target) => self.replace_remote_server(target, label, window, cx), Err(e) => Tty7App::report_restart_failure(&label, &e, window, cx), } } From 41117e3828176d5c92a59cfc1528062da28558d9 Mon Sep 17 00:00:00 2001 From: Hongwei Qin <122079993+shihuaidexianyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:46:02 +0800 Subject: [PATCH 5/6] fix(ui): show remote server errors under their switcher group (#354) * fix(install): prefer bundled server over release download for SSH remotes SSH remote installs used , which only checks and ignores the server binary already shipped next to the Windows executable. WSL already uses to find that bundled binary. Add to auto-discover the bundled server and fall back to the GitHub release download only when no matching local asset exists. Switch and to use it. This lets the Windows installer/zip (which already stages server binaries under /server/) satisfy SSH remote installs without hitting the network. The explicit path keeps its strict no-fallback behavior, and WSL remains bundled-only. Refs: future issue/PR for bundling server binaries into Windows releases. * style(install): fix rustfmt formatting in bundled-server tests * fix(ui): show remote server errors under their switcher group Remote server restart/replace failures were reported through a global modal dialog, which mixed errors from different machines together and blocked the UI. - Add to keep per-host error messages. - Rename to ; when a is available, store the error under that host's key and expand its switcher group. Only fall back to a modal when no target is known. - Read when building switcher groups and surface the message in the existing per-group error block. - Add a Dismiss button to the group error block and clear stored errors when the user retries or replaces the server. Refs: # * fix(ui): keep remote errors visible when the switcher is closed The grouped error block is only on screen while the switcher is open, so routing every failure into it silently swallowed the ones raised from the window menu's restart-server command and from a mismatch hit mid-connect. Fall back to the modal whenever there is no switcher to put the error in. Also scope the Dismiss button to its own host: it retired whatever connect flow happened to be in `self.connect`, including one still connecting to a different machine. And clear a stored error when a fresh connect to that host starts, so a later successful connect does not leave the group showing a stale failure. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/ui/app.rs | 4 ++++ src/ui/remote_workspace.rs | 44 +++++++++++++++++++++++++++----------- src/ui/switcher.rs | 38 ++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index c909ff37..b1e4669b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -409,6 +409,9 @@ pub struct Tty7App { crate::ui::host_registry::HostId, crate::ui::switcher::HostSnapshot, >, + /// Errors reported for a remote host that should be shown inside that host's + /// switcher group instead of as a global modal or toast. + pub(crate) remote_host_errors: std::collections::HashMap, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -711,6 +714,7 @@ impl Tty7App { connect: None, switcher: None, host_snapshots: std::collections::HashMap::new(), + remote_host_errors: std::collections::HashMap::new(), }; if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 { crate::ui::tray::init(cx); diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 75e11304..a580066d 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -291,6 +291,8 @@ impl Tty7App { pub(crate) fn connect_to_host(&mut self, choice: HostChoice, cx: &mut Context) { remote_connect::register(cx); + // Whatever went wrong last time is about to be answered by this attempt. + self.remote_host_errors.remove(&choice.target.to_string()); let header = match remote_connect::control_route(&choice.target, cx) { Ok(header) => header, Err(e) => { @@ -489,11 +491,12 @@ impl Tty7App { cx: &mut Context, ) { let label = mismatch.host.clone(); - match remote_connect::mismatch_target(&mismatch) - .ok_or_else(|| t_fmt(L10nKey::RemoteNoRouteToHost, &[("machine", &label)])) - { - Ok(target) => self.replace_remote_server(target, label, window, cx), - Err(e) => Tty7App::report_restart_failure(&label, &e, window, cx), + match remote_connect::mismatch_target(&mismatch) { + Some(target) => self.replace_remote_server(target, label, window, cx), + None => { + let e = t_fmt(L10nKey::RemoteNoRouteToHost, &[("machine", &label)]); + self.report_remote_host_error(None, &label, &e, window, cx); + } } } @@ -529,15 +532,17 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + self.remote_host_errors.remove(&target.to_string()); let header = match remote_connect::control_route(&target, cx) { Ok(header) => header.restart_server(), Err(e) => { - Tty7App::report_restart_failure(&label, &e, window, cx); + self.report_remote_host_error(Some(&target), &label, &e, window, cx); return; } }; let host = header.target.origin_key(); let host_id = target.host_id(); + let target_for_error = target.clone(); log::info!("restarting tty7's server on {label} at the user's request"); let running = Arc::new(std::sync::atomic::AtomicBool::new(true)); self.watch_for_restart_consent(host_id, running.clone(), cx); @@ -549,14 +554,14 @@ impl Tty7App { .await; running.store(false, std::sync::atomic::Ordering::Relaxed); remote_connect::clear_install_progress(host_id); - let _ = this.update_in(cx, |_, window, cx| match outcome { + let _ = this.update_in(cx, |this, window, cx| match outcome { Ok(()) => { log::info!("{label} is now serving this client's build"); reconnect_after_restart(&host, cx); } Err(e) => { log::warn!("could not restart tty7's server on {label}: {e}"); - Tty7App::report_restart_failure(&label, &e, window, cx); + this.report_remote_host_error(Some(&target_for_error), &label, &e, window, cx); } }); }) @@ -595,16 +600,18 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + self.remote_host_errors.remove(&target.to_string()); let route = match remote_connect::control_route(&target, cx) { Ok(header) => header.replace_server(), Err(e) => { log::warn!("could not address {label} to replace its server: {e}"); - Tty7App::report_restart_failure(&label, &e, window, cx); + self.report_remote_host_error(Some(&target), &label, &e, window, cx); return; } }; let host = route.target.origin_key(); let host_id = target.host_id(); + let target_for_error = target.clone(); log::info!("replacing tty7's server on {label} at the user's request"); let running = Arc::new(std::sync::atomic::AtomicBool::new(true)); self.watch_for_restart_consent(host_id, running.clone(), cx); @@ -616,26 +623,39 @@ impl Tty7App { .await; running.store(false, std::sync::atomic::Ordering::Relaxed); remote_connect::clear_install_progress(host_id); - let _ = this.update_in(cx, |_, window, cx| match outcome { + let _ = this.update_in(cx, |this, window, cx| match outcome { Ok(()) => { log::info!("{label} is now serving this client's build"); reconnect_after_restart(&host, cx); } Err(e) => { log::warn!("could not replace tty7's server on {label}: {e}"); - Tty7App::report_restart_failure(&label, &e, window, cx); + this.report_remote_host_error(Some(&target_for_error), &label, &e, window, cx); } }); }) .detach(); } - fn report_restart_failure( + fn report_remote_host_error( + &mut self, + target: Option<&RemoteTarget>, label: &str, error: &str, window: &mut Window, cx: &mut Context, ) { + // The grouped report is only visible while the switcher is open. Anywhere + // else — the window menu's "restart server", or a mismatch raised mid-connect + // — the modal is the only thing the user would see, so keep it. + if let (Some(target), Some(switcher)) = (target, self.switcher.as_mut()) { + let key = target.to_string(); + switcher.expand(&key); + self.remote_host_errors.insert(key, error.to_string()); + cx.notify(); + return; + } + let answer = window.prompt( PromptLevel::Warning, &t_fmt(L10nKey::RemoteRestartFailedTitle, &[("machine", label)]), diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 6efcfe29..1861da94 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -95,6 +95,10 @@ impl Switcher { fn text(&self, cx: &App) -> String { self.query.read(cx).value().trim().to_lowercase() } + + pub(crate) fn expand(&mut self, key: &str) { + self.collapsed.remove(key); + } } impl Tty7App { @@ -261,6 +265,11 @@ impl Tty7App { { group.error = Some(error.clone()); } + if group.error.is_none() { + if let Some(error) = self.remote_host_errors.get(&target.to_string()) { + group.error = Some(error.clone()); + } + } let id = target.host_id(); let reported = remote_connect::install_progress_for(id); if group.link == Link::Connecting @@ -611,6 +620,10 @@ impl Tty7App { if let Some(error) = group.error.as_ref().filter(|_| group.installing.is_none()) { let retry = GroupRef::of(group); let replace = retry.clone(); + let retry_key = group.key.clone(); + let replace_key = group.key.clone(); + let dismiss_key = group.key.clone(); + let dismiss_target = group.target.clone(); let theme = cx.theme(); block = block.child( @@ -642,6 +655,7 @@ impl Tty7App { .ghost() .xsmall() .on_click(cx.listener(move |this, _, _window, cx| { + this.remote_host_errors.remove(&retry_key); if let Some(target) = retry.target.clone() { this.connect_to_host( HostChoice { @@ -667,6 +681,7 @@ impl Tty7App { .ghost() .xsmall() .on_click(cx.listener(move |this, _, window, cx| { + this.remote_host_errors.remove(&replace_key); if let Some(target) = replace.target.clone() { this.confirm_replace_remote_server( target, @@ -678,6 +693,29 @@ impl Tty7App { })), ) }, + ) + .child( + Button::new(gpui::SharedString::from(format!( + "switcher-dismiss:{}", + group.key + ))) + .label(t(L10nKey::Dismiss)) + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _, _window, cx| { + this.remote_host_errors.remove(&dismiss_key); + // The other half of this block can come from a + // failed connect. Retire that too, but only when + // it is this host's failure — a connect to + // anywhere else is still in flight. + if let Some(ConnectFlow::Failed { choice, .. }) = + &this.connect + && Some(&choice.target) == dismiss_target.as_ref() + { + this.connect = None; + } + cx.notify(); + })), ), ), ); From 2fa518a767df162c6a8f2e2c326c92c98b401015 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Wed, 5 Aug 2026 17:42:21 +0800 Subject: [PATCH 6/6] refactor(settings): rescope the About page (#350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About had grown three sections that change system state and that nobody looks for under "About": a PATH install, a registry write, and a daemon restart. Two of them move out. The `tty7` CLI goes to Agents. That page already describes tty7 <-> agent integration in one direction (hooks reporting session status); the CLI is the other direction, and its own description leads with "so scripts and coding agents can drive tty7". The Loading and Unavailable arms there no longer return early, since the CLI toggle is about this GUI's own host rather than whichever machine the hook rows describe. The Windows Explorer context menu goes to the installer, which is where VS Code and Git for Windows put theirs: writing shell verbs is an install-time decision, not a runtime preference. A task checkbox drives new `--register-explorer-menu` / `--unregister-explorer-menu` flags, so the key layout stays in core::explorer_context_menu instead of being copied into the .iss. `status()` existed only to paint the settings UI and goes with it. The uninstaller unregisters unconditionally: an install that registered once and was later upgraded without the box ticked still holds keys that would otherwise point at a deleted exe. Server restart stays — it is about the app itself. Also fixes localization the About section had skipped: eight hardcoded English strings in the update block now have keys, and the orphaned SettingsCheckUpdatesDesc key (which still claimed "tty7 never updates itself", contradicted by the macOS in-app updater) is reused for a one-line description in place of a 60-word account of the updater's internals. Finally, terminology in the Chinese UI. hook, agent, worktree, diff and fork are read and spoken in English by Chinese developers, so translating them lost more than it gained. Scrollback was worse than a style question: 回滚 means rollback, the opposite direction. 窗格 for pane is kept — that one is standard. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- .github/scripts/windows-installer.iss | 16 ++ docs/features.md | 3 +- docs/features.zh-CN.md | 9 +- src/core/explorer_context_menu.rs | 249 ++--------------------- src/main.rs | 53 ++++- src/ui/app.rs | 38 +--- src/ui/i18n.rs | 252 +++++++---------------- src/ui/settings.rs | 282 ++++++-------------------- 8 files changed, 224 insertions(+), 678 deletions(-) diff --git a/.github/scripts/windows-installer.iss b/.github/scripts/windows-installer.iss index 6c94843b..777020bf 100644 --- a/.github/scripts/windows-installer.iss +++ b/.github/scripts/windows-installer.iss @@ -56,6 +56,12 @@ RestartApplications=no [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +; Writing shell verbs is an install-time decision, the way VS Code and Git for +; Windows treat theirs — not a runtime preference, so tty7 has no setting for +; it. Off by default: the registry is user-visible system state. The keys land +; under HKCU even for an all-users install, so this only ever affects whoever +; ran the installer. Inno restores the previous choice when upgrading. +Name: "explorermenu"; Description: "Add ""Open in tty7"" to the folder context menu"; GroupDescription: "Shell integration:"; Flags: unchecked ; Builds before the tty7/tty7-app split installed the GUI as tty7.exe. Upgrading ; only *adds* tty7-app.exe, so the old binary would stay on disk — and a taskbar @@ -107,6 +113,11 @@ Name: "{autoprograms}\tty7"; Filename: "{app}\tty7-app.exe"; AppUserModelID: "co Name: "{autodesktop}\tty7"; Filename: "{app}\tty7-app.exe"; Tasks: desktopicon; AppUserModelID: "com.github.tty7" [Run] +; The registry shape lives in core::explorer_context_menu, not here: the app +; reads those same keys to decide whether an existing registration still points +; at this install, and two hand-kept copies of the layout would drift. Runs +; before the launch entry below so a first start already sees the final state. +Filename: "{app}\tty7-app.exe"; Parameters: "--register-explorer-menu"; Tasks: explorermenu; Flags: runhidden waituntilterminated Filename: "{app}\tty7-app.exe"; Description: "{cm:LaunchProgram,tty7}"; Flags: nowait postinstall skipifsilent [UninstallRun] @@ -117,6 +128,11 @@ Filename: "{app}\tty7-app.exe"; Description: "{cm:LaunchProgram,tty7}"; Flags: n ; the call returns without opening a window. RunOnceId keys the entry so a repeated ; uninstall doesn't run it twice. Filename: "{app}\tty7-app.exe"; Parameters: "--stop-daemon"; Flags: runhidden waituntilterminated; RunOnceId: "StopDaemon" +; Unconditional, and deliberately not gated on the task: an install that had the +; menu registered and was later upgraded without the box ticked still holds the +; keys, and verbs pointing at a deleted exe are worse than a no-op. Removing keys +; that were never written succeeds silently. +Filename: "{app}\tty7-app.exe"; Parameters: "--unregister-explorer-menu"; Flags: runhidden waituntilterminated; RunOnceId: "UnregisterExplorerMenu" [Code] (* Gracefully stop the persistent daemon before we overwrite tty7-app.exe. We can't diff --git a/docs/features.md b/docs/features.md index bc58f1b8..215cecf6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -23,6 +23,7 @@ - **Sync with system** — Settings → Appearance; pick separate light and dark themes and tty7 follows the OS appearance live (`theme_follow_system`, `theme_preset_light` / `theme_preset_dark` in `config.json`) - **Window opacity & blur** — Settings → Appearance → Window; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur` - **CJK / IME input** +- **Windows Explorer menu** — the installer offers *Add “Open in tty7” to the folder context menu* as a setup task, off by default, and the uninstaller always takes it back out. Writing shell verbs is an install-time decision, so there is no runtime setting; a portable-zip install can do it itself with `tty7-app.exe --register-explorer-menu` (or `--unregister-explorer-menu`). Either way the keys land under `HKCU`, so only your own Windows account is affected ## Fonts @@ -66,7 +67,7 @@ it never wraps or replaces the agent. - **Tray icon** — a system tray / menu bar item that flips to an attention state the moment any agent needs your input; its menu lists every agent pane (brand avatar + status dot, click to reveal), switches the notification policy, and offers *Quit and Stop Daemon* alongside the plain session-keeping quit (`show_tray_icon`, on by default) - **`tty7 wait`** — the CLI's orchestration primitive: block until a pane's agent needs input or finishes its turn (`tty7 wait %3 --until waiting,done --changed --timeout 600`, exit 124 on timeout), so one agent can sleep until its peer blocks on a permission prompt instead of screen-scraping — then `tty7 capture %3 --plain` to read the result. The agent status is a level, not an event, so `--changed` ignores the state the pane was already in when the wait began; without it, the JSON's `stale` flag says whether the answer might belong to the previous turn - **Orchestration skill** — a switch (Settings → Agents) that installs a Claude Code skill (`~/.claude/skills/tty7-orchestration`) teaching a *primary* agent the delegation loop — spawn a worker pane, send it a bounded task, `wait` on it, capture the result. A skill rather than a global instruction on purpose: only its one-line description rides in context until explicitly invoked, and worker agents never inherit orchestration authority -- **`tty7` on PATH** — the CLI ships inside every installer and is put on PATH at launch, so a script or a coding agent can drive tty7 from any terminal. Inside a tty7 pane it works regardless, since panes inherit the app's environment. On Unix it is a symlink into whichever of `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/bin`, `~/.cargo/bin` your PATH already covers; on Windows the install directory is appended to your user PATH, and the uninstaller takes it back out. A `tty7` you installed yourself is left alone, never replaced. Off via Settings → About or `install_cli_on_path: false` in `config.json` +- **`tty7` on PATH** — the CLI ships inside every installer and is put on PATH at launch, so a script or a coding agent can drive tty7 from any terminal. Inside a tty7 pane it works regardless, since panes inherit the app's environment. On Unix it is a symlink into whichever of `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/bin`, `~/.cargo/bin` your PATH already covers; on Windows the install directory is appended to your user PATH, and the uninstaller takes it back out. A `tty7` you installed yourself is left alone, never replaced. Off via Settings → Agents or `install_cli_on_path: false` in `config.json` ## SSH diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 62ee5ee5..564ae3f7 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -16,13 +16,14 @@ - **标签页与分屏** —— 永远开在当前目录 - **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *Scratch* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json` 的 `sidebar_grouping`:默认 `repo`,`none` 恢复扁平列表) -- **命令面板** ⌘ P · 回滚搜索 ⌘ F +- **命令面板** ⌘ P · scrollback 搜索 ⌘ F - **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板) - **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 终端 → 鼠标可开关;分隔符用 `config.json` 的 `word_separators` 配置) - **9 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择 - **跟随系统外观** — 设置 → Appearance;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`) - **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur` - **CJK / 输入法输入** +- **Windows 资源管理器右键菜单** —— 安装程序提供 *Add “Open in tty7” to the folder context menu* 这个安装任务,默认不勾选,卸载时一律移除。写 shell verb 是安装期的决定,所以没有运行时开关;用 portable zip 的话可以自己执行 `tty7-app.exe --register-explorer-menu`(或 `--unregister-explorer-menu`)。两种方式写入的键都在 `HKCU` 下,只影响你自己的 Windows 账户 ## 字体 @@ -63,7 +64,7 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包 - **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *Quit and Stop Daemon*(`show_tray_icon`,默认开启) - **`tty7 wait`** —— CLI 的编排原语:阻塞到某个 pane 的 agent 等待输入或完成一轮(`tty7 wait %3 --until waiting,done --changed --timeout 600`,超时退出码 124),让一个 agent 睡到同伴卡在权限确认的那一刻,而不是抓屏猜——然后 `tty7 capture %3 --plain` 收结果。agent 状态是电平不是边沿,所以 `--changed` 会忽略 wait 开始时 pane 本来就处在的那个状态;不加它的话,JSON 里的 `stale` 标记会告诉你这个答案是不是上一轮留下的 - **Orchestration skill** —— 一个开关(设置 → Agents),安装一个 Claude Code skill(`~/.claude/skills/tty7-orchestration`),教 *primary* agent 完整的委派循环——开 worker pane、发一个边界清晰的任务、`wait` 等待、收结果。特意做成 skill 而非全局指令:平时只有一行描述占上下文,显式调用才加载全文,worker agent 也不会继承编排权限 -- **`tty7` 上 PATH** —— CLI 随每个安装包一起发布,启动时自动放到 PATH 上,脚本和 coding agent 在任何终端里都能驱动 tty7。tty7 自己的 pane 里则一定可用,因为 pane 继承 app 的环境。Unix 上是往 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.local/bin`、`~/bin`、`~/.cargo/bin` 中你 PATH 已经覆盖的那个目录里放一个软链;Windows 上是把安装目录追加到用户 PATH,卸载时再摘掉。你自己装的 `tty7` 一律保持原样,不会被覆盖。关掉:设置 → About,或 `config.json` 里 `install_cli_on_path: false` +- **`tty7` 上 PATH** —— CLI 随每个安装包一起发布,启动时自动放到 PATH 上,脚本和 coding agent 在任何终端里都能驱动 tty7。tty7 自己的 pane 里则一定可用,因为 pane 继承 app 的环境。Unix 上是往 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.local/bin`、`~/bin`、`~/.cargo/bin` 中你 PATH 已经覆盖的那个目录里放一个软链;Windows 上是把安装目录追加到用户 PATH,卸载时再摘掉。你自己装的 `tty7` 一律保持原样,不会被覆盖。关掉:设置 → Agents,或 `config.json` 里 `install_cli_on_path: false` ## SSH @@ -96,9 +97,9 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包 | ⌘ ] · ⌘ [ | 下一个窗格 · 上一个窗格 | | ⌘ ⌥ ←→↑↓ | 按方向切换焦点窗格 | | ⌘ ⏎ · ⌘ ⇧ ⏎ | 切换全屏 · 最大化 / 还原窗格 | -| ⌘ K | 清屏并清空回滚缓冲区 | +| ⌘ K | 清屏并清空 scrollback | | ⌘ P | 命令面板 | -| ⌘ F | 搜索回滚缓冲区 | +| ⌘ F | 搜索 scrollback | | ⌃ R | 模糊搜索 shell 历史 | | ⌘ + · ⌘ − · ⌘ 0 | 字号增大 · 减小 · 重置 | diff --git a/src/core/explorer_context_menu.rs b/src/core/explorer_context_menu.rs index becd4765..4b5384e3 100644 --- a/src/core/explorer_context_menu.rs +++ b/src/core/explorer_context_menu.rs @@ -1,13 +1,18 @@ //! Optional Windows Explorer context-menu integration. //! -//! tty7 deliberately does not register shell verbs during installation or -//! startup. The registry is user-visible system state, so only the explicit -//! buttons in Settings call [`register`] or [`unregister`]. Both verbs invoke -//! the GUI-subsystem `tty7-app.exe` directly so Explorer never allocates a -//! transient console. The app first offers the path to an already running GUI -//! through `GuiOpen`, then continues normal startup when no GUI receives it. +//! The registry is user-visible system state, so tty7 never writes these verbs +//! on its own: the Windows installer offers a task checkbox and invokes +//! [`register`], and its uninstaller always invokes [`unregister`]. There is no +//! runtime setting — the same install-time-only treatment VS Code and Git for +//! Windows give their shell entries. Keeping the key layout here rather than in +//! the .iss keeps one description of it in the tree. +//! +//! Both verbs invoke the GUI-subsystem `tty7-app.exe` directly so Explorer never +//! allocates a transient console. The app first offers the path to an already +//! running GUI through `GuiOpen`, then continues normal startup when no GUI +//! receives it. -use std::ffi::{OsStr, OsString}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; @@ -15,19 +20,6 @@ use anyhow::{Context as _, Result}; const DIRECTORY_KEY: &str = r"Software\Classes\Directory\shell\tty7"; const BACKGROUND_KEY: &str = r"Software\Classes\Directory\Background\shell\tty7"; -/// The state shown in Settings. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Status { - /// Neither tty7 verb exists for this user. - NotRegistered, - /// Both verbs exactly describe the currently running tty7 installation. - Registered, - /// At least one verb exists, but the pair is incomplete or points elsewhere. - NeedsUpdate, - /// Explorer shell verbs are unavailable on this operating system. - Unsupported, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Location { Directory, @@ -66,23 +58,6 @@ struct Registration { command: OsString, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct RegistryShape { - values: u32, - subkeys: u32, -} - -fn registration_tree_is_exact(root: RegistryShape, command: RegistryShape) -> bool { - root == (RegistryShape { - values: 2, - subkeys: 1, - }) && command - == (RegistryShape { - values: 1, - subkeys: 0, - }) -} - fn replace_entry_with( registration: &Registration, delete: impl FnOnce(&str) -> Result<()>, @@ -105,11 +80,6 @@ impl Registration { } } -/// Return the live per-user registration state. -pub fn status() -> Result { - platform_status() -} - /// Register both Explorer verbs for the current user. pub fn register() -> Result<()> { platform_register() @@ -151,6 +121,7 @@ fn registrations(app: &Path) -> [Registration; 2] { #[cfg(windows)] mod windows { use super::*; + use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt as _; use windows_sys::Win32::Foundation::{ @@ -175,21 +146,10 @@ mod windows { } } - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - enum EntryState { - Missing, - Matching, - Different, - } - fn wide(value: &OsStr) -> Vec { value.encode_wide().chain(std::iter::once(0)).collect() } - fn units(value: &OsStr) -> Vec { - value.encode_wide().collect() - } - fn io_error(action: &str, code: u32) -> anyhow::Error { anyhow::anyhow!( "{action}: {}", @@ -197,20 +157,6 @@ mod windows { ) } - fn open_key(path: &str) -> Result> { - let path = wide(OsStr::new(path)); - let mut key: HKEY = std::ptr::null_mut(); - // SAFETY: `path` is NUL-terminated and alive for the call; `key` is a - // valid out-parameter and is wrapped only when the call succeeds. - let code = - unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, path.as_ptr(), 0, KEY_READ, &mut key) }; - match code { - ERROR_SUCCESS => Ok(Some(RegistryKey(key))), - ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(None), - other => Err(io_error("opening the tty7 Explorer registry key", other)), - } - } - fn create_key(path: &str) -> Result { let path = wide(OsStr::new(path)); let mut key: HKEY = std::ptr::null_mut(); @@ -235,56 +181,6 @@ mod windows { Ok(RegistryKey(key)) } - fn query_string(key: &RegistryKey, name: Option<&OsStr>) -> Result>> { - let name = name.map(wide); - let name_ptr = name.as_ref().map_or(std::ptr::null(), |name| name.as_ptr()); - let mut kind = 0u32; - let mut bytes = 0u32; - // SAFETY: the key is live, the optional value-name pointer is either - // null or NUL-terminated, and the size/type out-parameters are valid. - let code = unsafe { - RegQueryValueExW( - key.0, - name_ptr, - std::ptr::null(), - &mut kind, - std::ptr::null_mut(), - &mut bytes, - ) - }; - if matches!(code, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) { - return Ok(None); - } - if code != ERROR_SUCCESS { - return Err(io_error("reading a tty7 Explorer registry value", code)); - } - if kind != REG_SZ || !bytes.is_multiple_of(2) { - return Ok(None); - } - - let mut value = vec![0u16; (bytes as usize).div_ceil(2)]; - // SAFETY: `value` is sized from the preceding query and remains live; - // Win32 receives its capacity in bytes through `bytes`. - let code = unsafe { - RegQueryValueExW( - key.0, - name_ptr, - std::ptr::null(), - &mut kind, - value.as_mut_ptr().cast(), - &mut bytes, - ) - }; - if code != ERROR_SUCCESS { - return Err(io_error("reading a tty7 Explorer registry value", code)); - } - value.truncate(bytes as usize / 2); - while value.last() == Some(&0) { - value.pop(); - } - Ok(Some(value)) - } - fn set_string(key: &RegistryKey, name: Option<&OsStr>, value: &OsStr) -> Result<()> { let name = name.map(wide); let name_ptr = name.as_ref().map_or(std::ptr::null(), |name| name.as_ptr()); @@ -302,59 +198,6 @@ mod windows { } } - fn key_shape(key: &RegistryKey) -> Result { - let mut subkeys = 0u32; - let mut values = 0u32; - // SAFETY: `key` is live and the two count pointers reference writable - // locals. Every optional output that is not needed is passed as null, - // which `RegQueryInfoKeyW` explicitly permits. - let code = unsafe { - RegQueryInfoKeyW( - key.0, - std::ptr::null_mut(), - std::ptr::null_mut(), - std::ptr::null(), - &mut subkeys, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut values, - std::ptr::null_mut(), - std::ptr::null_mut(), - std::ptr::null_mut(), - std::ptr::null_mut(), - ) - }; - if code == ERROR_SUCCESS { - Ok(RegistryShape { values, subkeys }) - } else { - Err(io_error("inspecting the tty7 Explorer registry key", code)) - } - } - - fn entry_state(registration: &Registration) -> Result { - let Some(root) = open_key(registration.location.key())? else { - return Ok(EntryState::Missing); - }; - let label = query_string(&root, None)?; - let icon = query_string(&root, Some(OsStr::new("Icon")))?; - - let command_path = format!(r"{}\command", registration.location.key()); - let command_key = match open_key(&command_path)? { - Some(key) => key, - None => return Ok(EntryState::Different), - }; - let command = query_string(&command_key, None)?; - let matches = label.as_deref() == Some(&units(OsStr::new(registration.location.label()))) - && icon.as_deref() == Some(&units(®istration.icon)) - && command.as_deref() == Some(&units(®istration.command)) - && registration_tree_is_exact(key_shape(&root)?, key_shape(&command_key)?); - Ok(if matches { - EntryState::Matching - } else { - EntryState::Different - }) - } - fn write_entry_contents(registration: &Registration) -> Result<()> { let root = create_key(registration.location.key())?; set_string(&root, None, OsStr::new(registration.location.label()))?; @@ -389,18 +232,6 @@ mod windows { } } - pub(super) fn status() -> Result { - let app = application_path()?; - let states = registrations(&app).map(|entry| entry_state(&entry)); - let [directory, background] = states; - let (directory, background) = (directory?, background?); - Ok(match (directory, background) { - (EntryState::Missing, EntryState::Missing) => Status::NotRegistered, - (EntryState::Matching, EntryState::Matching) => Status::Registered, - _ => Status::NeedsUpdate, - }) - } - pub(super) fn register() -> Result<()> { let app = application_path()?; for registration in registrations(&app) { @@ -422,11 +253,6 @@ mod windows { } } -#[cfg(windows)] -fn platform_status() -> Result { - windows::status() -} - #[cfg(windows)] fn platform_register() -> Result<()> { windows::register() @@ -437,11 +263,6 @@ fn platform_unregister() -> Result<()> { windows::unregister() } -#[cfg(not(windows))] -fn platform_status() -> Result { - Ok(Status::Unsupported) -} - #[cfg(not(windows))] fn platform_register() -> Result<()> { anyhow::bail!("Windows Explorer integration is only available on Windows") @@ -486,50 +307,6 @@ mod tests { ); } - #[test] - fn registration_shape_rejects_every_extra_value_or_subkey() { - assert!(registration_tree_is_exact( - RegistryShape { - values: 2, - subkeys: 1, - }, - RegistryShape { - values: 1, - subkeys: 0, - }, - )); - assert!(!registration_tree_is_exact( - RegistryShape { - values: 3, - subkeys: 1, - }, - RegistryShape { - values: 1, - subkeys: 0, - }, - )); - assert!(!registration_tree_is_exact( - RegistryShape { - values: 2, - subkeys: 1, - }, - RegistryShape { - values: 2, - subkeys: 0, - }, - )); - assert!(!registration_tree_is_exact( - RegistryShape { - values: 2, - subkeys: 2, - }, - RegistryShape { - values: 1, - subkeys: 0, - }, - )); - } - #[test] fn registration_replaces_the_owned_tree_before_writing() { let registration = Registration::new( diff --git a/src/main.rs b/src/main.rs index 9fd0d134..0d1712cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -143,6 +143,15 @@ fn open_path_from( None } +/// `Some(true)` to register the Explorer verbs, `Some(false)` to remove them. +fn explorer_menu_action_from(args: &[std::ffi::OsString]) -> Option { + args.iter().find_map(|arg| match arg.as_os_str() { + a if a == std::ffi::OsStr::new("--register-explorer-menu") => Some(true), + a if a == std::ffi::OsStr::new("--unregister-explorer-menu") => Some(false), + _ => None, + }) +} + /// Offers an explicit launch path to an already running local GUI. /// /// The dispatcher is injected so the startup decision can be tested without @@ -303,6 +312,26 @@ fn main() { crate::core::crash::install(role); crate::core::logfile::install(role); + // The Windows installer owns the Explorer context menu: a task checkbox + // runs these, and the uninstaller always runs the unregister half. Keeping + // the registry shape in `explorer_context_menu` rather than in the .iss + // means the installer and the running app can never disagree about it. + // Handled after the log file is open, because a GUI-subsystem process has + // no console to report a failure on and Inno does not surface exit codes: + // the log is the only place the reason can survive. + if let Some(register) = explorer_menu_action_from(&args) { + let result = if register { + crate::core::explorer_context_menu::register() + } else { + crate::core::explorer_context_menu::unregister() + }; + if let Err(error) = result { + log::error!("the Explorer context-menu update failed: {error}"); + std::process::exit(1); + } + return; + } + if daemon { if let Err(e) = crate::daemon::server::run_daemon() { log::error!("daemon exited with error: {e}"); @@ -405,11 +434,33 @@ mod tests { #[cfg(test)] mod argument_tests { - use super::{config_dir_from, forward_open_path_with, open_path_from}; + use super::{ + config_dir_from, explorer_menu_action_from, forward_open_path_with, open_path_from, + }; use std::ffi::OsString; use std::path::PathBuf; use tty7_core::daemon::control::ReplyOk; + /// The installer passes exactly one of these; every other launch — above + /// all a plain `--open-path` from the very menu they register — must fall + /// through to normal startup instead of rewriting the registry and exiting. + #[test] + fn explorer_menu_flags_are_distinguished_and_otherwise_absent() { + assert_eq!( + explorer_menu_action_from(&[OsString::from("--register-explorer-menu")]), + Some(true) + ); + assert_eq!( + explorer_menu_action_from(&[OsString::from("--unregister-explorer-menu")]), + Some(false) + ); + assert_eq!( + explorer_menu_action_from(&[OsString::from("--open-path"), OsString::from("/work")]), + None + ); + assert_eq!(explorer_menu_action_from(&[]), None); + } + #[test] fn open_path_accepts_separate_and_equals_forms() { let separate = open_path_from( diff --git a/src/ui/app.rs b/src/ui/app.rs index b1e4669b..ef5863bc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -34,8 +34,7 @@ use crate::ui::palette::{ use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot}; use crate::ui::presets::Fill; use crate::ui::settings::{ - ExplorerContextMenuNote, Recording, SettingsSection, SettingsState, ThemeEditor, - humanize_action, + Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action, }; use crate::ui::theme::{apply_theme, set_menus, window_background}; @@ -1945,38 +1944,6 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.install_cli_on_path = on); } - pub(crate) fn register_explorer_context_menu(&mut self, cx: &mut Context) { - self.run_explorer_context_menu_action(true, cx); - } - - pub(crate) fn unregister_explorer_context_menu(&mut self, cx: &mut Context) { - self.run_explorer_context_menu_action(false, cx); - } - - fn run_explorer_context_menu_action(&mut self, register: bool, cx: &mut Context) { - // Registry operations are tiny and synchronous. Keeping the action on - // the UI thread also makes the displayed status correspond to the - // completed write, without a task racing a closed Settings window. - let result = if register { - crate::core::explorer_context_menu::register() - } else { - crate::core::explorer_context_menu::unregister() - }; - let note = match result { - Ok(()) if register => ExplorerContextMenuNote::Registered, - Ok(()) => ExplorerContextMenuNote::Unregistered, - Err(error) if register => ExplorerContextMenuNote::RegisterFailed(error.to_string()), - Err(error) => ExplorerContextMenuNote::UnregisterFailed(error.to_string()), - }; - let status = - crate::core::explorer_context_menu::status().map_err(|error| error.to_string()); - if let Some(settings) = self.settings.as_mut() { - settings.explorer_context_menu_status = status; - settings.explorer_context_menu_note = Some(note); - } - cx.notify(); - } - pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context) { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } @@ -3618,9 +3585,6 @@ impl Tty7App { theme_search, recording: None, rebinding_note: None, - explorer_context_menu_status: crate::core::explorer_context_menu::status() - .map_err(|error| error.to_string()), - explorer_context_menu_note: None, ssh_form: None, ssh_detail: crate::ui::settings::SshDetail::None, ssh_filter, diff --git a/src/ui/i18n.rs b/src/ui/i18n.rs index 485e196f..319f799b 100644 --- a/src/ui/i18n.rs +++ b/src/ui/i18n.rs @@ -379,21 +379,6 @@ pub enum L10nKey { SettingsCommandLine, SettingsCommandLineDesc, SettingsInstallCliOnPath, - SettingsExplorerContextMenu, - SettingsExplorerContextMenuDesc, - SettingsExplorerNotRegistered, - SettingsExplorerRegistered, - SettingsExplorerNeedsUpdate, - SettingsExplorerUnavailable, - SettingsExplorerStatusUnavailable, - SettingsExplorerRegister, - SettingsExplorerUpdate, - SettingsExplorerUnregister, - SettingsExplorerRegisteredNote, - SettingsExplorerUnregisteredNote, - SettingsExplorerRegisterFailed, - SettingsExplorerUnregisterFailed, - SettingsExplorerWindows11Note, SettingsServer, SettingsServerDesc, SettingsRestartServer, @@ -421,7 +406,6 @@ pub enum L10nKey { SettingsSearchDetectUrlsKeywords, SettingsSearchDiffPreviewFromCountsKeywords, SettingsSearchDimInactivePanesKeywords, - SettingsSearchExplorerContextMenuKeywords, SettingsSearchFocusFollowsMouseKeywords, SettingsSearchFontFamilyKeywords, SettingsSearchFontLigaturesKeywords, @@ -1098,7 +1082,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::SettingsNavTerminal => ("Terminal", "终端"), L10nKey::SettingsNavInput => ("Input", "输入"), L10nKey::SettingsNavSsh => ("SSH", "SSH"), - L10nKey::SettingsNavAgents => ("Agents", "智能体"), + L10nKey::SettingsNavAgents => ("Agents", "Agents"), L10nKey::SettingsNavWindowTabs => ("Window & Tabs", "窗口与标签页"), L10nKey::SettingsNavKeybindings => ("Keybindings", "按键绑定"), L10nKey::SettingsNavAbout => ("About", "关于"), @@ -1439,7 +1423,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { "仅适用于没有可继承目录的 shell,例如窗口的第一个标签页。新标签页和分屏仍会继承活动窗格的目录,已经打开的 shell 会继续运行。", ), L10nKey::SettingsScrolling => ("Scrolling", "滚动"), - L10nKey::SettingsScrollback => ("Scrollback", "回滚缓冲"), + L10nKey::SettingsScrollback => ("Scrollback", "Scrollback"), L10nKey::SettingsScrollbackDesc => ( "Lines of history kept per pane. Applies to new panes.", "每个窗格保留的历史行数。仅适用于新窗格。", @@ -1529,14 +1513,14 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { "⌥+key sends the escape chord shells expect (⌥B = back one word) instead of typing a special character (∫).", "⌥+按键 发送 shell 期望的转义组合键(⌥B = 后退一个词),而不是输入特殊字符(∫)。", ), - L10nKey::SettingsAgentsIntro => ("Agents", "智能体"), + L10nKey::SettingsAgentsIntro => ("Agents", "Agents"), L10nKey::SettingsAgentsIntroDesc => ( "Hook integrations give panes running these agents live session status (working / waiting / done) in the tab bar. Only active inside tty7.", - "钩子集成让标签栏中的窗格实时显示这些智能体的会话状态(进行中 / 等待中 / 已完成)。仅在 tty7 内生效。", + "hook 集成让标签栏中的窗格实时显示这些 agent 的会话状态(进行中 / 等待中 / 已完成)。仅在 tty7 内生效。", ), L10nKey::SettingsReadingAgentConfig => ( "Reading this machine's agent config…", - "正在读取这台机器的智能体配置…", + "正在读取这台机器的 agent 配置…", ), L10nKey::SettingsStatusNotInstalled => ("Not installed", "未安装"), L10nKey::SettingsStatusInstalled => ("Installed", "已安装"), @@ -1547,7 +1531,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::SettingsUninstall => ("Uninstall", "卸载"), L10nKey::SettingsOfflineMachines => ( "{count} more saved machines are not connected — open a workspace on one to install its hooks there.", - "还有 {count} 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装钩子。", + "还有 {count} 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装 hook。", ), L10nKey::SettingsSyncWithSystem => ("Sync with system", "跟随系统"), L10nKey::SettingsSyncWithSystemDesc => ( @@ -1598,7 +1582,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::SettingsShowTrayIcon => ("Show tray icon", "显示托盘图标"), L10nKey::SettingsShowTrayIconDesc => ( "Keep a status item in the system tray / menu bar: it signals when a coding agent needs your input, and its menu jumps to agent panes.", - "在系统托盘/菜单栏保留状态项:当编码智能体需要输入时发出提示,其菜单可跳转到智能体窗格。", + "在系统托盘/菜单栏保留状态项:当编码 agent 需要输入时发出提示,其菜单可跳转到该 agent 的窗格。", ), L10nKey::SettingsTabs => ("Tabs", "标签页"), L10nKey::SettingsNewTabPosition => ("New tab position", "新标签页位置"), @@ -1618,11 +1602,11 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsDiffPreviewFromCounts => ( "Open diff preview from sidebar counts", - "从侧栏计数打开差异预览", + "从侧栏计数打开 diff 预览", ), L10nKey::SettingsDiffPreviewFromCountsDesc => ( "Click a row's +N −N to open the working-tree diff in an overlay. Off keeps the branch and the counts on the row and just stops them being clickable.", - "点击行上的 +N −N 可在浮层中打开工作树差异。关闭时行上仍显示分支和计数,但不再可点击。", + "点击行上的 +N −N 可在浮层中打开 worktree diff。关闭时行上仍显示分支和计数,但不再可点击。", ), L10nKey::SettingsNotifications => ("Notifications", "通知"), L10nKey::SettingsNotifyOnCommandFinish => ("Notify on command finish", "命令完成时通知"), @@ -1667,11 +1651,11 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::SettingsRestoreAllDefaults => ("Restore all defaults", "恢复全部默认值"), L10nKey::SettingsAboutDesc1 => ( "A terminal workbench: shells, workspaces, SSH, coding agents.", - "一个终端工作台:shell、工作区、SSH、编码智能体。", + "一个终端工作台:shell、工作区、SSH、编码 agent。", ), L10nKey::SettingsAboutDesc2 => ( "Editor-grade input in every shell, shells that outlive quits and reboots without tmux, a native SSH stack with profiles and port forwarding, and live status for panes running coding agents.", - "每个 shell 都具备编辑器级输入;无需 tmux 也能让 shell 在退出和重启后继续运行;原生的 SSH 栈支持主机配置和端口转发;为运行编码智能体的窗格提供实时状态。", + "每个 shell 都具备编辑器级输入;无需 tmux 也能让 shell 在退出和重启后继续运行;原生的 SSH 栈支持主机配置和端口转发;为运行编码 agent 的窗格提供实时状态。", ), L10nKey::SettingsAboutTech => ( "Pure Rust · GPU rendering on Zed's gpui · VT core from Alacritty", @@ -1741,43 +1725,12 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::SettingsCommandLine => ("Command line", "命令行"), L10nKey::SettingsCommandLineDesc => ( "Put the bundled `tty7` command on your PATH at launch, so scripts and coding agents can drive tty7 from any terminal. Inside a tty7 pane it works either way. Turn this off if you keep your own `tty7` — one you built or installed yourself — and do not want it shadowed. Takes effect at next launch.", - "启动时将自带的 `tty7` 命令加入 PATH,让脚本和编码智能体可在任意终端驱动 tty7。在 tty7 窗格内两种情况都可用。如果你自己构建或安装了 `tty7` 且不希望被遮蔽,请关闭此选项。下次启动时生效。", + "启动时将自带的 `tty7` 命令加入 PATH,让脚本和编码 agent 可在任意终端驱动 tty7。在 tty7 窗格内两种情况都可用。如果你自己构建或安装了 `tty7` 且不希望被遮蔽,请关闭此选项。下次启动时生效。", ), L10nKey::SettingsInstallCliOnPath => ( "Install the `tty7` command on PATH", "将 `tty7` 命令安装到 PATH", ), - L10nKey::SettingsExplorerContextMenu => ("Windows Explorer", "Windows 文件资源管理器"), - L10nKey::SettingsExplorerContextMenuDesc => ( - "Add “Open in tty7” when you right-click a folder and “Open tty7 here” when you right-click a folder background. This is off by default and is registered only for your Windows account.", - "右键单击文件夹时添加“Open in tty7”,右键单击文件夹背景时添加“Open tty7 here”。此功能默认关闭,且只为当前 Windows 账户注册。", - ), - L10nKey::SettingsExplorerNotRegistered => ("Not registered", "未注册"), - L10nKey::SettingsExplorerRegistered => ("Registered", "已注册"), - L10nKey::SettingsExplorerNeedsUpdate => ("Needs update", "需要更新"), - L10nKey::SettingsExplorerUnavailable => ("Unavailable", "不可用"), - L10nKey::SettingsExplorerStatusUnavailable => ("Status unavailable", "无法获取状态"), - L10nKey::SettingsExplorerRegister => ("Register", "注册"), - L10nKey::SettingsExplorerUpdate => ("Update", "更新"), - L10nKey::SettingsExplorerUnregister => ("Unregister", "取消注册"), - L10nKey::SettingsExplorerRegisteredNote => ( - "Registered. Right-click a folder or folder background in Explorer to open it in tty7.", - "已注册。现在可以在文件资源管理器中右键单击文件夹或文件夹背景,以在 tty7 中打开。", - ), - L10nKey::SettingsExplorerUnregisteredNote => ( - "Unregistered from Windows Explorer.", - "已从 Windows 文件资源管理器中取消注册。", - ), - L10nKey::SettingsExplorerRegisterFailed => { - ("Could not register: {error}", "无法注册:{error}") - } - L10nKey::SettingsExplorerUnregisterFailed => { - ("Could not unregister: {error}", "无法取消注册:{error}") - } - L10nKey::SettingsExplorerWindows11Note => ( - "On Windows 11, classic shell entries may appear under “Show more options”.", - "在 Windows 11 上,经典右键菜单项可能显示在“显示更多选项”中。", - ), L10nKey::SettingsServer => ("Server", "服务器"), L10nKey::SettingsServerDesc => ( "Restart the server on this computer to pick up a newly granted macOS permission, recover if it stops responding, or start from a clean slate. This ends all running shells here; your tabs and layout reopen with fresh shells. A remote machine's server is restarted from its own menu in the workspace switcher.", @@ -1812,11 +1765,11 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsSearchClaudeCodeKeywords => ( "agent integration hooks install uninstall status rich session working waiting tab bar sidebar badge claude", - "Claude Code 智能体 集成 钩子 安装 卸载 状态 会话 claude agent integration hooks install", + "Claude Code agent 集成 hook 安装 卸载 状态 会话 claude agent integration hooks install", ), L10nKey::SettingsSearchCodexKeywords => ( "agent integration hooks install openai codex", - "Codex 智能体 集成 钩子 安装 OpenAI codex agent integration hooks install", + "Codex agent 集成 hook 安装 OpenAI codex agent integration hooks install", ), L10nKey::SettingsSearchCommandLineToolKeywords => ( "cli tty7 path shell command install symlink terminal iterm agent script", @@ -1829,7 +1782,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsSearchCopilotCliKeywords => ( "agent integration hooks install github copilot", - "Copilot CLI 智能体 集成 钩子 安装 GitHub copilot agent integration hooks install", + "Copilot CLI agent 集成 hook 安装 GitHub copilot agent integration hooks install", ), L10nKey::SettingsSearchCopyOnSelectKeywords => ( "clipboard selection yank mouse", @@ -1853,16 +1806,12 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsSearchDiffPreviewFromCountsKeywords => ( "diff overlay preview sidebar counts git changes click branch lines", - "从侧栏计数打开差异预览 差异 预览 侧栏 git diff preview sidebar counts git changes", + "从侧栏计数打开 diff 预览 diff 预览 侧栏 git diff preview sidebar counts git changes", ), L10nKey::SettingsSearchDimInactivePanesKeywords => ( "fade unfocused inactive split pane focus opacity highlight active dimming", "调暗 非活动窗格 淡化 未聚焦 分屏 高亮 active dimming pane focus", ), - L10nKey::SettingsSearchExplorerContextMenuKeywords => ( - "windows explorer context menu right click folder directory background shell menu register unregister open here", - "Windows 文件资源管理器 右键 菜单 文件夹 目录 背景 注册 取消注册 打开 explorer context menu right click folder directory background shell register unregister open here", - ), L10nKey::SettingsSearchFocusFollowsMouseKeywords => ( "pane hover activate", "焦点跟随鼠标 悬停 激活 窗格 focus follows mouse hover activate pane", @@ -1885,7 +1834,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsSearchGrokBuildKeywords => ( "agent integration hooks install xai grok build", - "Grok Build 智能体 集成 钩子 安装 xai grok build agent integration hooks install", + "Grok Build agent 集成 hook 安装 xai grok build agent integration hooks install", ), L10nKey::SettingsSearchHideMouseWhileTypingKeywords => ( "cursor pointer autohide", @@ -1938,7 +1887,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsSearchOpencodeKeywords => ( "agent integration plugin install opencode", - "OpenCode 智能体 集成 插件 安装 opencode agent integration plugin install", + "OpenCode agent 集成 插件 安装 opencode agent integration plugin install", ), L10nKey::SettingsSearchOptionAsMetaKeywords => ( "alt keyboard modifier escape macos option meta option acts as meta", @@ -1946,7 +1895,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsSearchPiKeywords => ( "agent integration extension install pi", - "Pi 智能体 集成 扩展 安装 pi agent integration extension install", + "Pi agent 集成 扩展 安装 pi agent integration extension install", ), L10nKey::SettingsSearchPortForwardingKeywords => ( "ssh tunnel local remote dynamic socks forward rule", @@ -1974,7 +1923,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsSearchScrollbackKeywords => ( "history buffer lines scroll", - "回滚 历史 缓冲区 行数 scrollback history buffer lines", + "scrollback 回看 向上滚动 历史 缓冲区 行数 scrollback history buffer lines", ), L10nKey::SettingsSearchShowTrayIconKeywords => ( "tray menu bar status item agent attention system icon", @@ -2095,7 +2044,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::FileTreeContextOpen => ("Open", "打开"), L10nKey::FileTreeContextCdHere => ("cd Here", "cd 到此处"), L10nKey::FileTreeContextInsertPath => ("Insert Path in Terminal", "在终端中插入路径"), - L10nKey::FileTreeContextAttachAgent => ("Attach to Agent", "附加到智能体"), + L10nKey::FileTreeContextAttachAgent => ("Attach to Agent", "附加到 agent"), L10nKey::FileTreeContextNewFile => ("New File", "新建文件"), L10nKey::FileTreeContextNewFolder => ("New Folder", "新建文件夹"), L10nKey::FileTreeContextRename => ("Rename", "重命名"), @@ -2160,7 +2109,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { "进入 git 仓库后,此标签页会列出未提交的变更。", ), L10nKey::PanelNoChanges => ("No uncommitted changes.", "没有未提交的变更。"), - L10nKey::PanelNoChangesHint => ("The working tree is clean.", "工作树是干净的。"), + L10nKey::PanelNoChangesHint => ("The working tree is clean.", "worktree 是干净的。"), L10nKey::PanelSessionSubtitle => ("Session", "会话"), L10nKey::PanelProcessesSubtitle => ("Processes", "进程"), L10nKey::PanelPortsSubtitle => ("Ports", "端口"), @@ -2169,7 +2118,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::PanelSsh => ("ssh", "ssh"), L10nKey::PanelBranch => ("branch", "分支"), L10nKey::PanelChangesRow => ("changes", "变更"), - L10nKey::PanelAgent => ("agent", "智能体"), + L10nKey::PanelAgent => ("agent", "agent"), L10nKey::PanelAgentIdle => ("idle", "空闲"), L10nKey::PanelAgentWorking => ("working", "进行中"), L10nKey::PanelAgentWaiting => ("waiting", "等待中"), @@ -2196,14 +2145,14 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { "{count} running shells will be ended and the layout forgotten.", "{count} 个正在运行的 shell 将会被终止,布局也将被清除。", ), - L10nKey::DiffReading => ("Reading diff…", "正在读取差异…"), + L10nKey::DiffReading => ("Reading diff…", "正在读取 diff…"), L10nKey::DiffNotARepo => ("Not a git repository", "不是 git 仓库"), L10nKey::DiffReadFailed => ( "Couldn't read the working-tree diff — retrying on the next refresh.", - "无法读取工作树差异——下次刷新时重试。", + "无法读取 worktree diff——下次刷新时重试。", ), - L10nKey::DiffWorkingTreeClean => ("Working tree clean", "工作树干净"), - L10nKey::DiffCloseTooltip => ("Close Diff (Esc)", "关闭差异 (Esc)"), + L10nKey::DiffWorkingTreeClean => ("Working tree clean", "worktree 干净"), + L10nKey::DiffCloseTooltip => ("Close Diff (Esc)", "关闭 diff (Esc)"), L10nKey::DiffChangedFiles => ("{count} changed files", "{count} 个变更文件"), L10nKey::DiffUntrackedCount => (" · {count} untracked", " · {count} 个未跟踪文件"), L10nKey::DiffMoreFiles => ( @@ -2212,25 +2161,25 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::DiffOversizedNotice => ( "This working tree is too large to render efficiently ({summary}). Every file is collapsed — expand individual files, or run `git diff` in the terminal.", - "此工作树太大,无法高效渲染({summary})。每个文件都已折叠——可展开单个文件,或在终端中运行 `git diff`。", + "此 worktree 太大,无法高效渲染({summary})。每个文件都已折叠——可展开单个文件,或在终端中运行 `git diff`。", ), L10nKey::DiffTruncatedPerFile => ( "Diff truncated at {limit} lines — run `git diff` in the terminal for the rest.", - "差异在 {limit} 行处截断——在终端中运行 `git diff` 查看其余部分。", + "diff 在 {limit} 行处截断——在终端中运行 `git diff` 查看其余部分。", ), L10nKey::DiffTruncatedBudget => ( "Body not loaded — this working tree is past tty7's diff budget. Run `git diff` in the terminal for this file.", - "内容未加载——此工作树已超出 tty7 的差异预算。在终端中运行 `git diff` 查看此文件。", + "内容未加载——此 worktree 已超出 tty7 的 diff 预算。在终端中运行 `git diff` 查看此文件。", ), L10nKey::DiffUntrackedHeader => ("Untracked files ({count})", "未跟踪文件 ({count})"), L10nKey::DiffMoreUntracked => ( "… and {count} more — run `git status` in the terminal to see them.", "…还有 {count} 个——在终端中运行 `git status` 查看。", ), - L10nKey::DiffLines => ("{count} diff lines", "{count} 行差异"), + L10nKey::DiffLines => ("{count} diff lines", "{count} 行 diff"), L10nKey::DiffChangedLines => ( "{total} changed lines, {loaded} diff rows loaded before {cap} cut the rest", - "{total} 行变更,在 {cap} 截断前已加载 {loaded} 行差异", + "{total} 行变更,在 {cap} 截断前已加载 {loaded} 行 diff", ), L10nKey::DiffBudgetAndCap => ( "tty7's budget and the per-file cap", @@ -2241,16 +2190,17 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::DiffUntrackedSummary => ("{count} untracked", "{count} 个未跟踪"), L10nKey::PendingConnecting => ("Connecting to {machine}…", "正在连接 {machine}…"), L10nKey::PendingUnreachable => ("Couldn't reach {machine}", "无法连接到 {machine}"), - L10nKey::WorktreePromptNeedsName => ("The worktree needs a name", "工作树需要一个名称"), - L10nKey::WorktreePromptTitle => ("New Worktree Tab", "新建工作树标签页"), - L10nKey::WorktreePromptName => ("Worktree Name", "工作树名称"), + L10nKey::WorktreePromptNeedsName => ("The worktree needs a name", "worktree 需要一个名称"), + L10nKey::WorktreePromptTitle => ("New Worktree Tab", "新建 worktree 标签页"), + L10nKey::WorktreePromptName => ("Worktree Name", "worktree 名称"), L10nKey::WorktreePromptBranch => ("New Branch", "新分支"), L10nKey::WorktreePromptBase => ("Start From", "起始分支"), L10nKey::WorktreePromptCreating => ("Creating…", "正在创建…"), L10nKey::WorktreePromptCreate => ("Create", "创建"), - L10nKey::AppNewWorktreeFailed => { - ("New worktree failed: {error}", "新建工作树失败:{error}") - } + L10nKey::AppNewWorktreeFailed => ( + "New worktree failed: {error}", + "新建 worktree 失败:{error}", + ), L10nKey::HomeTimeJustNow => ("just now", "刚刚"), L10nKey::HomeTimeMinutesAgo => ("{count} min ago", "{count} 分钟前"), L10nKey::HomeTimeHourAgo => ("1 hour ago", "1 小时前"), @@ -2449,7 +2399,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { } L10nKey::AppNoRunningCodingAgent => ( "No running coding agent found — start one (claude, codex, …) in a pane first.", - "未找到运行中的编码智能体——请先在某个窗格中启动一个(claude、codex 等)。", + "未找到运行中的编码 agent——请先在某个窗格中启动一个(claude、codex 等)。", ), L10nKey::SwitcherThisComputer => ("This Computer", "本机"), L10nKey::SwitcherRestartingServer => ("Restarting tty7's server…", "正在重启 tty7 服务器…"), @@ -2491,10 +2441,10 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::CmdGroupView => ("View", "视图"), L10nKey::CmdGroupTerminal => ("Terminal", "终端"), L10nKey::CmdGroupSsh => ("SSH", "SSH"), - L10nKey::CmdGroupAgents => ("Agents", "智能体"), + L10nKey::CmdGroupAgents => ("Agents", "Agents"), L10nKey::CmdGroupApplication => ("Application", "应用"), L10nKey::CmdNewTab => ("New Tab", "新标签页"), - L10nKey::CmdNewWorktreeTab => ("New Worktree Tab", "新建工作树标签页"), + L10nKey::CmdNewWorktreeTab => ("New Worktree Tab", "新建 worktree 标签页"), L10nKey::CmdNewWorktreeTabSubtitle => ( "isolated checkout on a fresh branch", "在全新分支上独立检出", @@ -2521,12 +2471,12 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::CmdCopySessionId => ("Copy Session ID", "复制会话 ID"), L10nKey::CmdCopySessionIdSubtitle => ( "the coding agent's own session id", - "编码智能体自身的会话 ID", + "编码 agent 自身的会话 ID", ), - L10nKey::CmdForkSession => ("Fork Session", "派生会话"), + L10nKey::CmdForkSession => ("Fork Session", "Fork 会话"), L10nKey::CmdForkSessionSubtitle => ( "branch this agent session into a new tab", - "将此智能体会话派生到新标签页", + "将此 agent 会话 fork 到新标签页", ), L10nKey::CmdMarkTabAsUnread => ("Mark Tab as Unread", "将标签页标记为未读"), L10nKey::CmdClosePaneTab => ("Close Pane / Tab", "关闭窗格/标签页"), @@ -2562,7 +2512,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::CmdChangeTheme => ("Change Theme…", "更改主题…"), L10nKey::CmdResetFontSize => ("Reset Font Size", "重置字号"), L10nKey::CmdEnterFullScreen => ("Enter Full Screen", "进入全屏"), - L10nKey::CmdClearScrollback => ("Clear Scrollback", "清除回滚"), + L10nKey::CmdClearScrollback => ("Clear Scrollback", "清除 scrollback"), L10nKey::CmdFindInTerminal => ("Find in Terminal…", "在终端中查找…"), L10nKey::CmdFindNext => ("Find Next", "查找下一个"), L10nKey::CmdFindPrevious => ("Find Previous", "查找上一个"), @@ -2576,18 +2526,18 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::CmdSshRemoteFiles => ("SSH: Remote Files", "SSH:远程文件"), L10nKey::CmdSshPortForwarding => ("SSH: Port Forwarding", "SSH:端口转发"), L10nKey::CmdSshConnectWithInput => ("SSH: Connect {input}", "SSH:连接 {input}"), - L10nKey::CmdAgentSendSelection => ("Agent: Send Selection", "智能体:发送选区"), + L10nKey::CmdAgentSendSelection => ("Agent: Send Selection", "Agent:发送选区"), L10nKey::CmdAgentSendSelectionSubtitle => ( "selection → running coding agent", - "选区 → 运行中的编码智能体", + "选区 → 运行中的编码 agent", ), L10nKey::CmdAgentSendGitDiffForReview => ( "Agent: Send Git Diff for Review", - "智能体:发送 Git diff 以供审查", + "Agent:发送 git diff 以供审查", ), L10nKey::CmdAgentSendGitDiffSubtitle => ( "git diff → running coding agent", - "git diff → 运行中的编码智能体", + "git diff → 运行中的编码 agent", ), L10nKey::CmdSettings => ("Settings…", "设置…"), L10nKey::CmdKeyboardShortcuts => ("Keyboard Shortcuts", "键盘快捷键"), @@ -2630,17 +2580,18 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::AppWorktreeRemoveDetailDirty => ( "The closed tab's worktree at {path} has uncommitted changes.", - "位于 {path} 的已关闭标签页的工作树有未提交的变更。", + "位于 {path} 的已关闭标签页的 worktree 有未提交的变更。", ), L10nKey::AppWorktreeRemoveDetailClean => ( "The closed tab's worktree at {path} is clean.", - "位于 {path} 的已关闭标签页的工作树是干净的。", + "位于 {path} 的已关闭标签页的 worktree 是干净的。", + ), + L10nKey::AppWorktreeRemoveTitle => ( + "Remove worktree \"{branch}\"?", + "删除 worktree\"{branch}\"?", ), - L10nKey::AppWorktreeRemoveTitle => { - ("Remove worktree \"{branch}\"?", "删除工作树\"{branch}\"?") - } L10nKey::AppWorktreeDiscardAndRemove => ("Discard Changes & Remove", "放弃变更并删除"), - L10nKey::AppWorktreeRemove => ("Remove Worktree", "删除工作树"), + L10nKey::AppWorktreeRemove => ("Remove Worktree", "删除 worktree"), L10nKey::AppWorktreeKeep => ("Keep", "保留"), L10nKey::AppReopenTabFailed => ( "Could not reopen the tab: no terminal started", @@ -2659,32 +2610,33 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::AppSplitPaneFailed => { ("Could not split the pane: {error}", "无法拆分窗格:{error}") } - L10nKey::AppWorktreeRemoved => { - ("Removed worktree \"{branch}\"", "已删除工作树\"{branch}\"") - } + L10nKey::AppWorktreeRemoved => ( + "Removed worktree \"{branch}\"", + "已删除 worktree\"{branch}\"", + ), L10nKey::AppWorktreeRemoveFailed => ( "Worktree removal failed: {error}", - "删除工作树失败:{error}", + "删除 worktree 失败:{error}", ), L10nKey::AppForkStillConnecting => ( "Could not fork: the pane is still connecting", - "无法派生:窗格仍在连接中", + "无法 fork:窗格仍在连接中", ), L10nKey::AppPaneNoCodingAgent => ( "This pane isn't running a coding agent", - "此窗格未运行编码智能体", + "此窗格未运行编码 agent", ), L10nKey::AppForkNoCommand => ( "tty7 has no fork command for {name}", - "tty7 没有用于 {name} 的派生命令", + "tty7 没有用于 {name} 的 fork 命令", ), L10nKey::AppForkLocalOnly => ( "{name} sessions can only be forked from a local pane", - "{name} 会话只能从本地窗格派生", + "{name} 会话只能从本地窗格 fork", ), L10nKey::AppForkNoSessionId => ( "tty7 hasn't seen a {name} session id in this pane — install its hooks in Settings → Agents", - "tty7 尚未在此窗格中看到 {name} 的会话 ID——请在设置 → 智能体中安装其钩子", + "tty7 尚未在此窗格中看到 {name} 的会话 ID——请在设置 → Agents 中安装其 hook", ), L10nKey::AppForkSessionIdNotToken => ( "{name}'s session id isn't a plain token", @@ -2692,7 +2644,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::AppForkMidTurn => ( "{name} is mid-turn — the fork won't include the turn in flight", - "{name} 正在处理中——派生不会包含进行中的这一轮", + "{name} 正在处理中——fork 不会包含进行中的这一轮", ), L10nKey::AppTabNoWorkingDirectory => ( "This tab has no working directory yet", @@ -2733,7 +2685,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::AppAgentHooksOffline => ( "Not connected to this machine, so its agent config can't be read or written. Open a workspace on it and come back.", - "未连接到这台机器,因此无法读取或写入其智能体配置。请在其上打开一个工作区后再回来。", + "未连接到这台机器,因此无法读取或写入其 agent 配置。请在其上打开一个工作区后再回来。", ), L10nKey::AppAgentHooksHomeDirUnresolved => { ("cannot resolve home directory", "无法解析主目录") @@ -2796,13 +2748,13 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::AppMenuHelp => ("Help", "帮助"), L10nKey::AppMenuNewTab => ("New Tab", "新标签页"), L10nKey::AppMenuNewWorkspace => ("New Workspace", "新工作区"), - L10nKey::AppMenuNewWorktreeTab => ("New Worktree Tab", "新工作树标签页"), + L10nKey::AppMenuNewWorktreeTab => ("New Worktree Tab", "新 worktree 标签页"), L10nKey::AppMenuSplitRight => ("Split Right", "向右分屏"), L10nKey::AppMenuSplitDown => ("Split Down", "向下分屏"), L10nKey::AppMenuRenameTab => ("Rename Tab…", "重命名标签页…"), L10nKey::AppMenuCopyWorkingDirectory => ("Copy Working Directory", "复制工作目录"), L10nKey::AppMenuCopySessionId => ("Copy Session ID", "复制会话 ID"), - L10nKey::AppMenuForkSession => ("Fork Session", "派生会话"), + L10nKey::AppMenuForkSession => ("Fork Session", "Fork 会话"), L10nKey::AppMenuClosePaneTab => ("Close Pane / Tab", "关闭窗格 / 标签页"), L10nKey::AppMenuCloseOtherTabs => ("Close Other Tabs", "关闭其他标签页"), L10nKey::AppMenuCloseTabsRight => ("Close Tabs to the Right", "关闭右侧标签页"), @@ -2830,7 +2782,7 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::AppMenuFocusNextPane => ("Focus Next Pane", "聚焦下一个窗格"), L10nKey::AppMenuFocusPreviousPane => ("Focus Previous Pane", "聚焦上一个窗格"), L10nKey::AppMenuZoomPane => ("Zoom Pane", "缩放窗格"), - L10nKey::AppMenuClearScrollback => ("Clear Scrollback", "清除回滚"), + L10nKey::AppMenuClearScrollback => ("Clear Scrollback", "清除 scrollback"), L10nKey::AppMenuEnterFullscreen => ("Enter Full Screen", "进入全屏"), L10nKey::AppMenuDocumentation => ("tty7 Documentation", "tty7 文档"), L10nKey::AppMenuKeyboardShortcuts => ("Keyboard Shortcuts", "键盘快捷键"), @@ -2883,15 +2835,15 @@ fn translate_variant(locale: Locale, key: L10nKey, branch: &'static str) -> &'st // --- Offline machines --- (SettingsOfflineMachines, "zero") => ( "0 more saved machines are not connected — open a workspace on one to install its hooks there.", - "还有 0 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装钩子。", + "还有 0 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装 hook。", ), (SettingsOfflineMachines, "one") => ( "1 more saved machine is not connected — open a workspace on it to install its hooks there.", - "还有 1 台已保存的机器未连接——在那台机器上打开工作区,即可在那里安装钩子。", + "还有 1 台已保存的机器未连接——在那台机器上打开工作区,即可在那里安装 hook。", ), (SettingsOfflineMachines, "other") => ( "{count} more saved machines are not connected — open a workspace on one to install its hooks there.", - "还有 {count} 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装钩子。", + "还有 {count} 台已保存的机器未连接——在其中一台上打开工作区,即可在那台机器上安装 hook。", ), // --- Panel untracked files --- @@ -3341,21 +3293,6 @@ mod tests { L10nKey::SettingsCommandLine, L10nKey::SettingsCommandLineDesc, L10nKey::SettingsInstallCliOnPath, - L10nKey::SettingsExplorerContextMenu, - L10nKey::SettingsExplorerContextMenuDesc, - L10nKey::SettingsExplorerNotRegistered, - L10nKey::SettingsExplorerRegistered, - L10nKey::SettingsExplorerNeedsUpdate, - L10nKey::SettingsExplorerUnavailable, - L10nKey::SettingsExplorerStatusUnavailable, - L10nKey::SettingsExplorerRegister, - L10nKey::SettingsExplorerUpdate, - L10nKey::SettingsExplorerUnregister, - L10nKey::SettingsExplorerRegisteredNote, - L10nKey::SettingsExplorerUnregisteredNote, - L10nKey::SettingsExplorerRegisterFailed, - L10nKey::SettingsExplorerUnregisterFailed, - L10nKey::SettingsExplorerWindows11Note, L10nKey::SettingsServer, L10nKey::SettingsServerDesc, L10nKey::SettingsRestartServer, @@ -3383,7 +3320,6 @@ mod tests { L10nKey::SettingsSearchDetectUrlsKeywords, L10nKey::SettingsSearchDiffPreviewFromCountsKeywords, L10nKey::SettingsSearchDimInactivePanesKeywords, - L10nKey::SettingsSearchExplorerContextMenuKeywords, L10nKey::SettingsSearchFocusFollowsMouseKeywords, L10nKey::SettingsSearchFontFamilyKeywords, L10nKey::SettingsSearchFontLigaturesKeywords, @@ -3912,44 +3848,6 @@ mod tests { assert_eq!(current_locale(), Locale::En); } - #[test] - fn explorer_settings_are_translated_with_error_details() { - let keys = [ - L10nKey::SettingsExplorerContextMenu, - L10nKey::SettingsExplorerContextMenuDesc, - L10nKey::SettingsExplorerNotRegistered, - L10nKey::SettingsExplorerRegistered, - L10nKey::SettingsExplorerNeedsUpdate, - L10nKey::SettingsExplorerUnavailable, - L10nKey::SettingsExplorerStatusUnavailable, - L10nKey::SettingsExplorerRegister, - L10nKey::SettingsExplorerUpdate, - L10nKey::SettingsExplorerUnregister, - L10nKey::SettingsExplorerRegisteredNote, - L10nKey::SettingsExplorerUnregisteredNote, - L10nKey::SettingsExplorerRegisterFailed, - L10nKey::SettingsExplorerUnregisterFailed, - L10nKey::SettingsExplorerWindows11Note, - L10nKey::SettingsSearchExplorerContextMenuKeywords, - ]; - for key in keys { - assert_ne!( - translate(Locale::En, key), - translate(Locale::ZhHans, key), - "Simplified Chinese should not fall back to English for {key:?}" - ); - } - - assert_eq!( - apply_template( - translate(Locale::ZhHans, L10nKey::SettingsExplorerRegisterFailed), - &[("error", "access denied")], - None, - ), - "无法注册:access denied" - ); - } - #[test] fn plural_and_select_branches_are_translated() { let plural_keys = [ diff --git a/src/ui/settings.rs b/src/ui/settings.rs index b871ddc8..d5cd0f3f 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -85,30 +85,6 @@ struct SearchEntry { keywords: L10nKey, } -#[derive(Clone)] -pub(crate) enum ExplorerContextMenuNote { - Registered, - Unregistered, - RegisterFailed(String), - UnregisterFailed(String), -} - -impl ExplorerContextMenuNote { - fn localized(&self) -> String { - match self { - Self::Registered => t(L10nKey::SettingsExplorerRegisteredNote).to_string(), - Self::Unregistered => t(L10nKey::SettingsExplorerUnregisteredNote).to_string(), - Self::RegisterFailed(error) => { - t_fmt(L10nKey::SettingsExplorerRegisterFailed, &[("error", error)]) - } - Self::UnregisterFailed(error) => t_fmt( - L10nKey::SettingsExplorerUnregisterFailed, - &[("error", error)], - ), - } - } -} - fn localized_update_phase(phase: &crate::core::update::UpdatePhase) -> Option { use crate::core::update::{UpdateFailure, UpdatePhase}; @@ -457,15 +433,10 @@ fn settings_search_entries() -> &'static [SearchEntry] { keywords: SettingsSearchHowShellsWorkKeywords, }, SearchEntry { - section: About, + section: Agents, title: SettingsSearchCommandLineToolTitle, keywords: SettingsSearchCommandLineToolKeywords, }, - SearchEntry { - section: About, - title: SettingsExplorerContextMenu, - keywords: SettingsSearchExplorerContextMenuKeywords, - }, ] } @@ -519,9 +490,6 @@ pub(crate) struct SettingsState { pub(crate) theme_search: Entity, pub(crate) recording: Option, pub(crate) rebinding_note: Option, - pub(crate) explorer_context_menu_status: - Result, - pub(crate) explorer_context_menu_note: Option, pub(crate) ssh_form: Option, pub(crate) ssh_detail: SshDetail, pub(crate) ssh_filter: Entity, @@ -3757,22 +3725,21 @@ impl Tty7App { page = page.children(self.agent_hooks_machine_picker(selected_host, cx)); + // The hook rows describe whichever machine is selected above; the + // command-line section below is always about this GUI's own host, so it + // is appended after the match rather than inside the ready arm. match view { AgentHooksView::Loading => { - return page - .child( - div() - .py_4() - .text_sm() - .text_color(muted_fg) - .child(t(L10nKey::SettingsReadingAgentConfig)), - ) - .into_any_element(); + page = page.child( + div() + .py_4() + .text_sm() + .text_color(muted_fg) + .child(t(L10nKey::SettingsReadingAgentConfig)), + ); } AgentHooksView::Unavailable(reason) => { - return page - .child(div().py_4().text_sm().text_color(warning).child(reason)) - .into_any_element(); + page = page.child(div().py_4().text_sm().text_color(warning).child(reason)); } AgentHooksView::Ready(rows) => { for (i, row) in rows.into_iter().enumerate() { @@ -3847,7 +3814,46 @@ impl Tty7App { } } } - page.into_any_element() + + let install_cli_on_path = cx.global::().install_cli_on_path; + page.child( + v_flex() + .mt_6() + .gap_2() + .child(self.section_rule(cx)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(t(L10nKey::SettingsCommandLine)), + ) + .child( + div() + .text_sm() + .text_color(muted_fg) + .child(t(L10nKey::SettingsCommandLineDesc)), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + crate::ui::theme::switch("install-cli-on-path", cx) + .checked(install_cli_on_path) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + this.set_install_cli_on_path(*on, cx) + })), + ) + .child( + div() + .text_sm() + .text_color(foreground) + .child(t(L10nKey::SettingsInstallCliOnPath)), + ), + ), + ) + .into_any_element() } fn agent_hooks_machine_picker(&self, selected: HostId, cx: &mut Context) -> Option

{ @@ -4701,12 +4707,7 @@ impl Tty7App { fn render_settings_about(&self, cx: &mut Context) -> AnyElement { let theme = cx.theme(); - let (foreground, muted_fg, success, warning) = ( - theme.foreground, - theme.muted_foreground, - theme.success, - theme.warning, - ); + let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground); let update_status = cx .try_global::() @@ -4721,66 +4722,6 @@ impl Tty7App { ); let phase_text = localized_update_phase(&update_status.phase); let check_for_updates = cx.global::().check_for_updates; - let install_cli_on_path = cx.global::().install_cli_on_path; - let (explorer_status, explorer_note) = self - .active_settings() - .map(|settings| { - ( - settings.explorer_context_menu_status.clone(), - settings.explorer_context_menu_note.clone(), - ) - }) - .unwrap_or(( - Ok(crate::core::explorer_context_menu::Status::Unsupported), - None, - )); - let ( - explorer_status_text, - explorer_status_color, - register_label, - register_disabled, - unregister_disabled, - ) = match explorer_status.as_ref() { - Ok(crate::core::explorer_context_menu::Status::NotRegistered) => ( - t(L10nKey::SettingsExplorerNotRegistered), - muted_fg, - t(L10nKey::SettingsExplorerRegister), - false, - true, - ), - Ok(crate::core::explorer_context_menu::Status::Registered) => ( - t(L10nKey::SettingsExplorerRegistered), - success, - t(L10nKey::SettingsExplorerRegister), - true, - false, - ), - Ok(crate::core::explorer_context_menu::Status::NeedsUpdate) => ( - t(L10nKey::SettingsExplorerNeedsUpdate), - warning, - t(L10nKey::SettingsExplorerUpdate), - false, - false, - ), - Ok(crate::core::explorer_context_menu::Status::Unsupported) => ( - t(L10nKey::SettingsExplorerUnavailable), - muted_fg, - t(L10nKey::SettingsExplorerRegister), - true, - true, - ), - Err(_) => ( - t(L10nKey::SettingsExplorerStatusUnavailable), - warning, - t(L10nKey::SettingsExplorerRegister), - false, - false, - ), - }; - let explorer_feedback = explorer_note - .as_ref() - .map(ExplorerContextMenuNote::localized) - .or_else(|| explorer_status.err()); let logo = Arc::new(Image::from_bytes( ImageFormat::Png, @@ -4943,107 +4884,6 @@ impl Tty7App { ), ), ) - .when(cfg!(windows), |page| { - page.child( - v_flex() - .mt_6() - .gap_2() - .child(self.section_rule(cx)) - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(foreground) - .child(t(L10nKey::SettingsExplorerContextMenu)), - ) - .child( - div() - .text_sm() - .text_color(muted_fg) - .child(t(L10nKey::SettingsExplorerContextMenuDesc)), - ) - .child( - h_flex() - .gap_2() - .items_center() - .child(div().size_2().rounded_full().bg(explorer_status_color)) - .child( - div() - .text_sm() - .text_color(foreground) - .child(explorer_status_text), - ), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("explorer-menu-register") - .label(register_label) - .small() - .disabled(register_disabled) - .on_click(cx.listener(|this, _, _window, cx| { - this.register_explorer_context_menu(cx) - })), - ) - .child( - Button::new("explorer-menu-unregister") - .label(t(L10nKey::SettingsExplorerUnregister)) - .small() - .disabled(unregister_disabled) - .on_click(cx.listener(|this, _, _window, cx| { - this.unregister_explorer_context_menu(cx) - })), - ), - ) - .when_some(explorer_feedback, |section, message| { - section.child(div().text_xs().text_color(muted_fg).child(message)) - }) - .child( - div() - .text_xs() - .text_color(muted_fg) - .child(t(L10nKey::SettingsExplorerWindows11Note)), - ), - ) - }) - .child( - v_flex() - .mt_6() - .gap_2() - .child(self.section_rule(cx)) - .child( - div() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(foreground) - .child(t(L10nKey::SettingsCommandLine)), - ) - .child( - div() - .text_sm() - .text_color(muted_fg) - .child(t(L10nKey::SettingsCommandLineDesc)), - ) - .child( - h_flex() - .gap_2() - .items_center() - .child( - crate::ui::theme::switch("install-cli-on-path", cx) - .checked(install_cli_on_path) - .on_click(cx.listener(|this, on: &bool, _w, cx| { - this.set_install_cli_on_path(*on, cx) - })), - ) - .child( - div() - .text_sm() - .text_color(foreground) - .child(t(L10nKey::SettingsInstallCliOnPath)), - ), - ), - ) .child( v_flex() .mt_6() @@ -5141,7 +4981,7 @@ mod tests { ("bell", Terminal), ("known_hosts", Ssh), ("claude", Agents), - ("right click", About), + ("symlink", Agents), ]; for (query, expected) in cases { assert_eq!( @@ -5153,18 +4993,16 @@ mod tests { } } + /// The `tty7` CLI exists so scripts and coding agents can drive tty7, so it + /// lives with the other agent integrations rather than under About. #[test] - fn explorer_context_menu_search_entry_uses_localized_keys() { + fn command_line_tool_is_searchable_under_agents() { let entry = settings_search_entries() .iter() - .find(|entry| entry.title == L10nKey::SettingsExplorerContextMenu) - .expect("Explorer settings should be searchable"); + .find(|entry| entry.title == L10nKey::SettingsSearchCommandLineToolTitle) + .expect("the CLI setting should be searchable"); - assert_eq!(entry.section.profile_label(), "settings:about"); - assert_eq!( - entry.keywords, - L10nKey::SettingsSearchExplorerContextMenuKeywords - ); + assert_eq!(entry.section.profile_label(), "settings:agents"); } #[test]