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 151e6826..777020bf 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 @@ -82,6 +87,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 @@ -90,9 +99,18 @@ Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion ; still has to produce an installer. See bundle-windows.ps1. Source: "{#StageDir}\server\*"; DestDir: "{app}\server"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist +; AppUserModelID is what lets toast notifications carry the tty7 name and icon +; instead of the notify-rust PowerShell fallback: Windows only honors an +; unpackaged app's toast identity when a shortcut stamps it. Must match +; `core::aumid::AUMID` (src/core/aumid.rs), which at startup stamps the +; per-user shortcut below if some older installer left it unstamped, and +; writes one from scratch for the portable zip. It deliberately leaves an +; all-users install alone — it cannot write {commonprograms} unelevated, and a +; per-user twin would both duplicate the Start Menu entry and outlive this +; uninstaller — so an elevated install depends on the stamp right here. [Icons] -Name: "{autoprograms}\tty7"; Filename: "{app}\tty7-app.exe" -Name: "{autodesktop}\tty7"; Filename: "{app}\tty7-app.exe"; Tasks: desktopicon +Name: "{autoprograms}\tty7"; Filename: "{app}\tty7-app.exe"; AppUserModelID: "com.github.tty7" +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 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 8dc7ee9c..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", @@ -9686,6 +9687,7 @@ dependencies = [ "tray-icon", "tty7-core", "uuid", + "windows 0.58.0", "windows-sys 0.61.2", "winresource", ] diff --git a/Cargo.toml b/Cargo.toml index 41f0b246..a4f88659 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,10 +139,34 @@ 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 +# before it honors an unpackaged app's toast identity (otherwise the toast +# backend falls back to PowerShell's). 0.58 is already in the tree via gpui. +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Storage_EnhancedStorage", + # IShellLinkW::GetPath takes a WIN32_FIND_DATAW, so reading an existing + # shortcut's target back — which is how `aumid` decides whether it has to + # write at all — needs the file-system bindings too. + "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Variant", + "Win32_UI_Shell", + "Win32_UI_Shell_PropertiesSystem", +] } # Embeds `assets/favicon.ico` into the `.exe` so Windows shows the tty7 logo in # the taskbar / window / Explorer (macOS gets its icon from the `.app` bundle via @@ -197,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/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(); diff --git a/docs/features.md b/docs/features.md index fbfeeccb..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 diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 73020854..564ae3f7 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -23,6 +23,7 @@ - **跟随系统外观** — 设置 → 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 账户 ## 字体 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/aumid.rs b/src/core/aumid.rs new file mode 100644 index 00000000..746a8668 --- /dev/null +++ b/src/core/aumid.rs @@ -0,0 +1,472 @@ +//! Windows toast branding: an App User Model ID (AUMID) of our own. +//! +//! Without one, notify-rust falls back to PowerShell's AUMID and every toast +//! shows the PowerShell icon and name. Windows only honors an unpackaged app's +//! AUMID when a Start Menu shortcut carries the matching +//! `System.AppUserModel.ID`, so `init()` — called once at GUI startup — brands +//! the process and, when nothing else already supplies that shortcut, writes +//! one. +//! +//! It is deliberately reluctant to write. The installer stamps its own +//! shortcuts (see `windows-installer.iss`), so the runtime write only has to +//! cover the portable zip and installs that predate that change. It therefore +//! touches at most the single per-user `tty7.lnk` that Inno's default install +//! owns anyway — never a second Start Menu entry beside an all-users install +//! (which would show "tty7" twice and outlive the uninstaller), and never +//! anything at all from a `cargo` build directory (which would repoint the +//! user's installed shortcut at `target\debug`). +//! +//! Everything is best-effort. `toast_app_id()` yields the AUMID only once a +//! shortcut carrying it is in place *and* the shell has had time to index it; +//! otherwise callers keep the PowerShell identity, which looks wrong but still +//! shows up. That distinction matters: an AUMID the shell has not indexed does +//! not make `Toast::show()` fail, it makes it return success and drop the +//! toast on the floor. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// The toast/taskbar identity. Keep in sync with the `AppUserModelID` on the +/// installer shortcuts in `.github/scripts/windows-installer.iss` — a +/// mismatch silently splits the identity in two (a unit test checks this). +pub(crate) const AUMID: &str = "com.github.tty7"; + +/// How long we keep using the PowerShell identity after writing the shortcut +/// ourselves. The shell picks a new `.lnk` up asynchronously and silently +/// discards toasts for an AUMID it has not indexed yet; the measured lag on +/// Windows 11 was a few seconds. Overshooting only costs an unbranded toast +/// in the opening seconds of a first-ever run, so the bound is generous. +const INDEX_GRACE: Duration = Duration::from_secs(30); + +/// One-time GUI-startup hook; see the module docs. Cheap after the first call. +pub(crate) fn init() { + let _ = toast_app_id(); +} + +/// The AUMID to put on toasts, when (and only when) a shortcut the shell has +/// seen carries it. Memoized — the first call does the COM work. +pub(crate) fn toast_app_id() -> Option<&'static str> { + static STATE: OnceLock = OnceLock::new(); + match STATE.get_or_init(setup) { + Branding::Unavailable => None, + Branding::Ready => Some(AUMID), + Branding::Pending(written_at) => (written_at.elapsed() >= INDEX_GRACE).then_some(AUMID), + } +} + +enum Branding { + /// Nothing carries our AUMID and we are not in a position to add it. + Unavailable, + /// A shortcut carrying it was already on disk before we started, so the + /// shell has had it since long before this process existed. + Ready, + /// We wrote that shortcut just now — see `INDEX_GRACE`. + Pending(Instant), +} + +fn setup() -> Branding { + use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; + unsafe { + // Branding the process is also what groups the taskbar button under + // our own identity, and it is safe everywhere — including the build + // directories the shortcut half below refuses to touch. + let _ = SetCurrentProcessExplicitAppUserModelID(&windows::core::HSTRING::from(AUMID)); + } + match decide() { + Ok(Decision::Branded) => Branding::Ready, + Ok(Decision::Skip(why)) => { + log::debug!("keeping the PowerShell toast identity: {why}"); + Branding::Unavailable + } + Ok(Decision::Write(lnk)) => match write_shortcut(&lnk) { + Ok(()) => Branding::Pending(Instant::now()), + Err(e) => { + log::warn!("toast branding disabled, keeping the PowerShell identity: {e}"); + Branding::Unavailable + } + }, + Err(e) => { + log::warn!("toast branding disabled, keeping the PowerShell identity: {e}"); + Branding::Unavailable + } + } +} + +enum Decision { + /// A shortcut already carries our AUMID; write nothing. + Branded, + /// Create or refresh this per-user shortcut, in place. + Write(PathBuf), + /// Nothing we may safely write. The string is for the log. + Skip(&'static str), +} + +fn decide() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("current exe: {e}"))?; + + // An elevated install owns `%ProgramData%\...\tty7.lnk`, which we cannot + // rewrite unelevated. A per-user twin beside it would list "tty7" twice in + // the Start Menu and survive the uninstaller, so that file settles the + // question on its own: branded if the installer stamped our AUMID on it, + // unbranded until the user upgrades to an installer that does. + if let Some(lnk) = all_users_shortcut_path() + && lnk.is_file() + { + let stamped = read_shortcut(&lnk) + .map_err(|e| format!("read {}: {e}", lnk.display()))? + .aumid + .as_deref() + == Some(AUMID); + return Ok(if stamped { + Decision::Branded + } else { + Decision::Skip("an all-users Start Menu shortcut owns the entry") + }); + } + + let lnk = start_menu_shortcut_path().ok_or("APPDATA is not set")?; + let existing = if lnk.is_file() { + Some(read_shortcut(&lnk).map_err(|e| format!("read {}: {e}", lnk.display()))?) + } else { + None + }; + let stamped = existing + .as_ref() + .is_some_and(|s| s.aumid.as_deref() == Some(AUMID)); + let on_target = existing + .as_ref() + .and_then(|s| s.target.as_deref()) + .is_some_and(|t| same_path(t, &exe)); + if stamped && on_target { + return Ok(Decision::Branded); + } + + if is_build_output(&exe) { + // `cargo run` must never repoint the installed Start Menu shortcut at + // `target\debug`. If an install already left a stamped shortcut here, + // toasts from the dev build still brand correctly off it — Windows + // only asks that the AUMID be registered, not that it point at us. + return Ok(if stamped { + Decision::Branded + } else { + Decision::Skip("running from a cargo build directory") + }); + } + + Ok(Decision::Write(lnk)) +} + +/// True when `exe` sits in a `cargo` build directory rather than an install. +/// +/// Two independent signals. The layout check is the offline half: +/// `target[\]\{debug,release}\tty7-app.exe`. `CACHEDIR.TAG` is the +/// half that does not care about names — cargo writes it into every build +/// directory precisely to mark the tree as derived, so it also covers a +/// renamed `CARGO_TARGET_DIR` and the `target\...\deps\` binaries the test +/// harness runs from. +fn is_build_output(exe: &Path) -> bool { + has_build_layout(exe) + || exe + .ancestors() + .any(|dir| dir.join("CACHEDIR.TAG").is_file()) +} + +fn has_build_layout(exe: &Path) -> bool { + fn named(dir: Option<&Path>, name: &str) -> bool { + dir.and_then(Path::file_name) + .is_some_and(|n| n.eq_ignore_ascii_case(name)) + } + let profile = exe.parent(); + (named(profile, "debug") || named(profile, "release")) + && exe.ancestors().any(|dir| named(Some(dir), "target")) +} + +/// Windows paths are case-insensitive and a `.lnk` may hold a short (8.3) or +/// otherwise unnormalized form of the same file, so compare canonically and +/// only fall back to text when the target no longer exists. +fn same_path(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => a.as_os_str().eq_ignore_ascii_case(b.as_os_str()), + } +} + +fn programs_dir(env_var: &str) -> Option { + Some( + PathBuf::from(std::env::var_os(env_var)?) + .join("Microsoft") + .join("Windows") + .join("Start Menu") + .join("Programs"), + ) +} + +/// The only shortcut we ever write: the per-user Start Menu, which is also +/// where Inno's default (non-elevated) install puts `tty7.lnk` — so refreshing +/// it adds no entry the uninstaller does not already know how to remove. +fn start_menu_shortcut_path() -> Option { + Some(programs_dir("APPDATA")?.join("tty7.lnk")) +} + +/// The all-users twin an elevated install writes. Read-only for us. +fn all_users_shortcut_path() -> Option { + Some(programs_dir("ProgramData")?.join("tty7.lnk")) +} + +#[derive(Default)] +struct Shortcut { + aumid: Option, + target: Option, +} + +fn wide(s: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + s.encode_wide().collect() +} + +fn hstring(path: &Path) -> Result { + windows::core::HSTRING::from_wide(&wide(path.as_os_str())) + .map_err(|e| format!("{}: {e}", path.display())) +} + +/// Any prior COM init on this thread (`S_FALSE`) or another model +/// (`RPC_E_CHANGED_MODE`) is fine — we only need an initialized thread, and +/// `ShellLink` is apartment-threaded either way. +fn co_init() { + use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx}; + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + } +} + +/// Read back the two properties `decide()` cares about. Absent ones come back +/// as `None`; only genuine COM failures are errors. +fn read_shortcut(lnk: &Path) -> Result { + use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID; + use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, CoCreateInstance, IPersistFile, STGM_READ, + }; + use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore; + use windows::Win32::UI::Shell::{IShellLinkW, SLGP_RAWPATH, ShellLink}; + use windows::core::{BSTR, Interface}; + + let lnk_w = hstring(lnk)?; + co_init(); + unsafe { + let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) + .map_err(|e| format!("ShellLink: {e}"))?; + let persist: IPersistFile = link.cast().map_err(|e| format!("IPersistFile: {e}"))?; + persist + .Load(&lnk_w, STGM_READ) + .map_err(|e| format!("Load: {e}"))?; + + let store: IPropertyStore = link.cast().map_err(|e| format!("IPropertyStore: {e}"))?; + // `BSTR::try_from` goes through `PropVariantToBSTR`, so it copes with + // both the VT_LPWSTR the installer writes and the VT_BSTR we write. + let aumid = store + .GetValue(&PKEY_AppUserModel_ID) + .ok() + .and_then(|v| BSTR::try_from(&v).ok()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + + // SLGP_RAWPATH returns the stored target verbatim; without it the + // shell may go looking for a moved file, including over the network. + let mut buf = [0u16; 1024]; + let target = link + .GetPath(&mut buf, std::ptr::null_mut(), SLGP_RAWPATH.0 as u32) + .ok() + .map(|()| { + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + PathBuf::from(String::from_utf16_lossy(&buf[..len])) + }) + .filter(|p| !p.as_os_str().is_empty()); + + Ok(Shortcut { aumid, target }) + } +} + +/// Create or overwrite `lnk`, pointing at the running exe and carrying our +/// AUMID. The exe has the icon compiled in (build.rs), which is what the toast +/// header shows. +fn write_shortcut(lnk: &Path) -> Result<(), String> { + use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID; + use windows::Win32::System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, IPersistFile}; + use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore; + use windows::Win32::UI::Shell::{IShellLinkW, ShellLink}; + use windows::core::{Interface, PROPVARIANT}; + + if let Some(dir) = lnk.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + } + let exe = std::env::current_exe().map_err(|e| format!("current exe: {e}"))?; + let exe_w = hstring(&exe)?; + let lnk_w = hstring(lnk)?; + + co_init(); + unsafe { + let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) + .map_err(|e| format!("ShellLink: {e}"))?; + link.SetPath(&exe_w).map_err(|e| format!("SetPath: {e}"))?; + let store: IPropertyStore = link.cast().map_err(|e| format!("IPropertyStore: {e}"))?; + store + .SetValue(&PKEY_AppUserModel_ID, &PROPVARIANT::from(AUMID)) + .map_err(|e| format!("SetValue: {e}"))?; + store.Commit().map_err(|e| format!("Commit: {e}"))?; + let persist: IPersistFile = link.cast().map_err(|e| format!("IPersistFile: {e}"))?; + persist + .Save(&lnk_w, true) + .map_err(|e| format!("Save: {e}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + #[test] + fn aumid_matches_the_installer_shortcuts() { + let iss = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/.github/scripts/windows-installer.iss" + )) + .expect("read windows-installer.iss"); + let needle = format!("AppUserModelID: \"{}\"", super::AUMID); + assert!( + iss.contains(&needle), + "windows-installer.iss must set {needle} on its shortcuts" + ); + } + + #[test] + fn start_menu_shortcut_lives_under_programs() { + let Some(lnk) = super::start_menu_shortcut_path() else { + return; // no APPDATA in this environment — nothing to assert + }; + assert_eq!(lnk.file_name().and_then(|n| n.to_str()), Some("tty7.lnk")); + assert_eq!( + lnk.parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()), + Some("Programs") + ); + } + + /// The two Start Menu roots must be distinct files, or the "an all-users + /// shortcut owns the entry" branch would swallow the per-user one too. + #[test] + fn the_two_start_menu_roots_are_distinct() { + let (Some(user), Some(all)) = ( + super::start_menu_shortcut_path(), + super::all_users_shortcut_path(), + ) else { + return; + }; + assert_ne!(user, all); + } + + #[test] + fn cargo_layouts_are_recognized_as_build_output() { + for exe in [ + r"C:\src\tty7\target\debug\tty7-app.exe", + r"C:\src\tty7\target\release\tty7-app.exe", + r"C:\src\tty7\target\x86_64-pc-windows-msvc\release\tty7-app.exe", + r"C:\src\tty7\TARGET\Debug\tty7-app.exe", + ] { + assert!( + super::has_build_layout(Path::new(exe)), + "{exe} should look like a build directory" + ); + } + } + + #[test] + fn install_layouts_are_not_build_output() { + for exe in [ + r"C:\Program Files\tty7\tty7-app.exe", + r"C:\Users\me\AppData\Local\Programs\tty7\tty7-app.exe", + // Portable zip, extracted anywhere the user likes. + r"D:\tools\tty7\tty7-app.exe", + // A "release" *install* directory with no `target` above it. + r"D:\tty7\release\tty7-app.exe", + ] { + let exe = Path::new(exe); + assert!(!super::has_build_layout(exe), "{} misread", exe.display()); + assert!(!super::is_build_output(exe), "{} misread", exe.display()); + } + } + + /// The test harness itself runs out of `target\...\deps\`, whose parent is + /// neither `debug` nor `release` — so this is the `CACHEDIR.TAG` half of + /// `is_build_output` proving itself against a real cargo layout. + #[test] + fn the_test_binary_counts_as_build_output() { + let exe = std::env::current_exe().expect("current exe"); + assert!( + super::is_build_output(&exe), + "{} should be detected as a cargo build", + exe.display() + ); + } + + #[test] + fn same_path_ignores_case_for_paths_that_do_not_exist() { + assert!(super::same_path( + Path::new(r"C:\Program Files\tty7\TTY7-APP.EXE"), + Path::new(r"c:\program files\tty7\tty7-app.exe"), + )); + assert!(!super::same_path( + Path::new(r"C:\Program Files\tty7\tty7-app.exe"), + Path::new(r"C:\src\tty7\target\debug\tty7-app.exe"), + )); + } + + /// Round-trips a shortcut through the real shell into a temp directory: + /// `write_shortcut` must produce something `read_shortcut` recognizes as + /// ours, or `decide()` would rewrite it on every single launch. Touches no + /// Start Menu. + #[test] + fn a_written_shortcut_reads_back_as_ours() { + let dir = std::env::temp_dir().join(format!("tty7-aumid-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let lnk = dir.join("tty7.lnk"); + + let written = super::write_shortcut(&lnk); + let read = written.as_ref().ok().map(|()| super::read_shortcut(&lnk)); + let _ = std::fs::remove_dir_all(&dir); + + written.expect("write shortcut"); + let shortcut = read.expect("read attempted").expect("read shortcut"); + assert_eq!(shortcut.aumid.as_deref(), Some(super::AUMID)); + let exe = std::env::current_exe().expect("current exe"); + let target = shortcut.target.unwrap_or_default(); + assert!( + super::same_path(&target, &exe), + "target {} should be {}", + target.display(), + exe.display() + ); + } + + /// Manual end-to-end check, never run by CI (`--ignored` to opt in). Prints + /// what `decide()` chose and pops a real toast, which should carry the tty7 + /// icon and name rather than PowerShell's. On a machine that had no + /// shortcut yet the first run writes one and stays unbranded for + /// `INDEX_GRACE`; run it a second time to see the branded toast. + #[test] + #[ignore = "may touch the real Start Menu and pops a real toast"] + fn sends_a_branded_toast() { + let decision = match super::decide().expect("decide") { + super::Decision::Branded => "already branded".to_string(), + super::Decision::Write(lnk) => format!("writing {}", lnk.display()), + super::Decision::Skip(why) => format!("skipping: {why}"), + }; + println!("decision: {decision}"); + println!("toast_app_id: {:?}", super::toast_app_id()); + crate::terminal::notify_desktop(Some("tty7"), "AUMID toast test"); + std::thread::sleep(std::time::Duration::from_secs(3)); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index b001814b..7835f623 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,8 @@ pub use tty7_core::core::*; pub mod actions; pub mod agent_prompt; +#[cfg(target_os = "windows")] +pub mod aumid; pub mod cli_install; pub mod config; pub mod explorer_context_menu; 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/main.rs b/src/main.rs index c1e156dc..0d1712cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -303,25 +303,6 @@ fn main() { } } - // 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. - 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 { - // No console on the GUI subsystem; the exit code is what the - // installer reads. - log::error!("the Explorer context-menu update failed: {error}"); - std::process::exit(1); - } - return; - } - apply_config_dir_arg(&args); let daemon = args @@ -331,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}"); @@ -362,6 +363,11 @@ fn main() { // carry the CLI's directory in its environment. crate::core::cli_install::install(config.install_cli_on_path); + // Give desktop toasts the tty7 icon and name instead of notify-rust's + // PowerShell fallback. Best-effort; no-op off Windows. + #[cfg(target_os = "windows")] + crate::core::aumid::init(); + let restore_session = config.restore_session; let daemon_result = if restore_session { crate::daemon::spawn::ensure_running() 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

, body: &str) { std::thread::spawn(move || { #[cfg(target_os = "macos")] ensure_notification_app(); - let _ = notify_rust::Notification::new() - .summary(&summary) - .body(&body) - .show(); + let mut notif = notify_rust::Notification::new(); + notif.summary(&summary).body(&body); + // Without our own AUMID, the Windows backend falls back to + // PowerShell's — icon and name included. Only set ours once the shell + // has indexed a shortcut carrying it: for an AUMID it does not know, + // `show()` reports success and drops the toast, so the ugly fallback + // beats the branded one every time we are not sure. + #[cfg(target_os = "windows")] + if let Some(app_id) = crate::core::aumid::toast_app_id() { + notif.app_id(app_id); + } + let _ = notif.show(); }); } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 0361cc08..e6bb0a41 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -100,6 +100,11 @@ pub struct TerminalView { owner_workspace: Option, restored: bool, ssh_spec: Option>, + /// The verified remote staging directory for pasted images, once one has + /// been prepared for this pane. `None` means "not prepared yet", never + /// "preparation failed" — see [`staging_cache`]. + #[cfg(not(target_os = "macos"))] + remote_clipboard_dir: Option, pub focus_handle: FocusHandle, pub font: Font, pub font_bold: Option, @@ -427,6 +432,116 @@ fn write_clipboard_image(img: &gpui::Image) -> Option { Some(path) } +/// The connection a paste should be uploaded over, when this pane runs on a +/// remote host reachable over the daemon's russh stack: either a native SSH +/// workspace pane or a standalone native SSH pane. WSL shares localhost and +/// needs path translation instead, and a workspace without connection details +/// has no channel to piggyback on — both keep the local-path behavior. +#[cfg(not(target_os = "macos"))] +fn remote_paste_spec<'a>( + workspace: Option<&'a crate::terminal::PaneWorkspace>, + ssh_spec: Option<&'a crate::daemon::protocol::NativeSshSpec>, +) -> Option<&'a crate::daemon::protocol::NativeSshSpec> { + if let Some(ws) = workspace { + if ws.shares_localhost() { + return None; + } + return ws.spec.as_deref(); + } + ssh_spec +} + +/// Staging images under the SSH user's own home keeps them out of the +/// world-writable `/tmp`, where any local account could pre-create the +/// directory, read what lands in it, or swap a pasted screenshot for one of +/// its own before the pane's agent opens it. +#[cfg(not(target_os = "macos"))] +const REMOTE_CLIPBOARD_PATH: [&str; 3] = [".cache", "tty7", "clipboard"]; + +/// Owner-only, and *only* owner: a staging directory anyone else can enter is +/// one anyone else can read the pasted screenshots out of. +#[cfg(not(target_os = "macos"))] +const REMOTE_CLIPBOARD_MODE: u32 = 0o700; + +/// Whether a prepared staging directory may be uploaded into. +/// +/// The mode is what a `stat` reported *after* a `chmod 0700` the daemon +/// watched succeed, which is the ownership proof: POSIX only lets a file's +/// owner change its mode, so a directory tty7 can chmod and then observe at +/// exactly `0700` is one the SSH user owns and nobody else can enter. A +/// symlink is refused outright because `stat` follows links, so a link planted +/// at the staging path would otherwise be judged by its target. +#[cfg(not(target_os = "macos"))] +fn staging_dir_is_safe( + is_symlink: bool, + kind: Option, + mode: u32, +) -> bool { + use crate::daemon::protocol::SftpEntryKind; + !is_symlink + && matches!(kind, Some(SftpEntryKind::Dir)) + && mode & 0o7777 == REMOTE_CLIPBOARD_MODE +} + +/// The staging directory to reuse on the next paste. Only a verified directory +/// is cached: a preparation that failed — a dropped link, a squatted path, a +/// remote with no POSIX `/home` — must be retried rather than latched, or +/// every later paste emits a remote path for a directory that was never +/// created. +#[cfg(not(target_os = "macos"))] +fn staging_cache(prepared: &Result) -> Option { + prepared.as_ref().ok().cloned() +} + +/// Create and verify the per-user staging directory, answering the absolute +/// remote path to upload into. Blocking: every step is a daemon round trip +/// over the pane's SSH connection, so this only ever runs off the UI thread. +#[cfg(not(target_os = "macos"))] +fn prepare_remote_clipboard_dir(route: &crate::ui::sftp::SftpRoute) -> Result { + use crate::daemon::protocol::{SftpOp, SftpOpResult}; + let home = match route.op(SftpOp::Realpath { + path: ".".to_string(), + }) { + SftpOpResult::Link(home) if home.starts_with('/') => home, + SftpOpResult::Error(e) => return Err(e), + other => { + return Err(format!( + "the remote home directory is not a path: {other:?}" + )); + } + }; + let mut dir = home; + for component in REMOTE_CLIPBOARD_PATH { + dir = crate::daemon::ssh::sftp::remote_join(&dir, component); + // An existing directory fails here with EEXIST; the checks below are + // what decide whether this one is ours, so the result carries no + // information worth branching on. + let _ = route.op(SftpOp::Mkdir { path: dir.clone() }); + } + if let SftpOpResult::Link(target) = route.op(SftpOp::Readlink { path: dir.clone() }) { + return Err(format!("{dir} is a symlink to {target}")); + } + if let SftpOpResult::Error(e) = route.op(SftpOp::Chmod { + path: dir.clone(), + mode: REMOTE_CLIPBOARD_MODE, + }) { + return Err(format!("{dir} is not owned by this session: {e}")); + } + match route.op(SftpOp::Stat { path: dir.clone() }) { + SftpOpResult::Stat(entry) + if staging_dir_is_safe(false, Some(entry.kind), entry.permissions) => + { + Ok(dir) + } + SftpOpResult::Stat(entry) => Err(format!( + "{dir} is not a private directory (mode {:o})", + entry.permissions & 0o7777 + )), + SftpOpResult::Error(e) => Err(e), + other => Err(format!("unexpected reply for {dir}: {other:?}")), + } +} + #[cfg(not(target_os = "macos"))] fn transcode_to_png(bytes: &[u8], format: gpui::ImageFormat) -> Option> { use gpui::ImageFormat as G; @@ -711,6 +826,8 @@ impl TerminalView { owner_workspace: None, restored: false, ssh_spec: None, + #[cfg(not(target_os = "macos"))] + remote_clipboard_dir: None, focus_handle, font, font_bold, @@ -2008,6 +2125,14 @@ impl TerminalView { fn paste_clipboard_image(&mut self, img: &gpui::Image, cx: &mut Context) { #[cfg(not(target_os = "macos"))] if let Some(path) = write_clipboard_image(img) { + // SSH panes can't see the local temp file, so the image is + // uploaded and the *remote* path pasted instead. Every step of + // that needs a blocking daemon round trip, which a keystroke + // handler must not do, so the remote pane pastes from a background + // task and this returns without touching the line. + if self.upload_image_for_remote(&path, cx) { + return; + } let text = shell_escape_path(&path.to_string_lossy()); self.paste(format!("{text} "), cx); return; @@ -2018,6 +2143,182 @@ impl TerminalView { cx.notify(); } + /// Upload a locally staged clipboard image to the pane's remote host and + /// paste the remote path, all off the UI thread. Answers whether this pane + /// took the paste over; `false` means a local, WSL, or spec-less pane the + /// caller should paste the local path for. + /// + /// The upload itself still outlives the paste — it has to, or Ctrl+V would + /// stall on the wire — so the job is watched to completion and a failure + /// at any point warns the user that the path they were handed is dangling. + #[cfg(not(target_os = "macos"))] + fn upload_image_for_remote(&mut self, local: &std::path::Path, cx: &mut Context) -> bool { + use crate::daemon::protocol::{SftpTransferKind, SftpTransferSpec}; + let Some(spec) = remote_paste_spec(self.workspace.as_ref(), self.ssh_spec.as_deref()) + else { + return false; + }; + let host = format!("{}@{}", spec.user, spec.host); + // The only caller stages through `write_clipboard_image`, so this + // holds; a name that could not stand alone as a remote path component + // would be a bug worth failing on rather than joining blindly. + let name = local + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .filter(|n| crate::daemon::ssh::sftp::safe_local_name(n)); + let Some(name) = name else { + log::warn!("refusing to upload a clipboard image named {local:?}"); + return false; + }; + let route = crate::ui::sftp::SftpRoute::new(self.pane_id, self.workspace.clone()); + let cached = self.remote_clipboard_dir.clone(); + let local = local.to_path_buf(); + let pane_id = self.pane_id; + cx.spawn(async move |this, cx| { + let prepared = match cached { + Some(dir) => Ok(dir), + None => { + let route = route.clone(); + cx.background_spawn(async move { prepare_remote_clipboard_dir(&route) }) + .await + } + }; + let dir = match this.update(cx, |view, _| { + view.remote_clipboard_dir = staging_cache(&prepared); + view.remote_clipboard_dir.clone() + }) { + Ok(Some(dir)) => dir, + Ok(None) => { + let reason = prepared.unwrap_or_else(|e| e); + Self::paste_local_image_path(&this, cx, &local, &host, &reason); + return; + } + Err(_) => return, + }; + let remote = crate::daemon::ssh::sftp::remote_join(&dir, &name); + let started = { + let (route, remote, local) = (route.clone(), remote.clone(), local.clone()); + cx.background_spawn(async move { + route.transfer_start(SftpTransferSpec { + pane_id, + kind: SftpTransferKind::Upload, + local, + remote, + recursive: false, + }) + }) + .await + }; + let job = match started { + Ok(job) => job, + Err(reason) => { + Self::paste_local_image_path(&this, cx, &local, &host, &reason); + return; + } + }; + let text = shell_escape_path(&remote); + if this + .update(cx, |view, cx| view.paste(format!("{text} "), cx)) + .is_err() + { + return; + } + if let Err(reason) = Self::watch_upload(route, job, &remote, cx).await { + let _ = this.update_in(cx, |view, window, cx| { + view.warn_image_upload_failed(&host, &reason, window, cx); + }); + } + }) + .detach(); + true + } + + /// Fall back to the local path when the remote staging directory cannot be + /// prepared — the paste is never dropped — and say why it is local. + #[cfg(not(target_os = "macos"))] + fn paste_local_image_path( + this: &gpui::WeakEntity, + cx: &mut gpui::AsyncApp, + local: &std::path::Path, + host: &str, + reason: &str, + ) { + let text = shell_escape_path(&local.to_string_lossy()); + let _ = this.update_in(cx, |view, window, cx| { + view.paste(format!("{text} "), cx); + view.warn_image_upload_failed(host, reason, window, cx); + }); + } + + /// Poll a started upload to a terminal state. The transfer history the + /// SFTP panel reads is only polled while that panel is open, and the + /// daemon drops finished jobs after 30s, so a paste that no one is + /// watching would otherwise fail in silence. + #[cfg(not(target_os = "macos"))] + async fn watch_upload( + route: crate::ui::sftp::SftpRoute, + job: u64, + remote: &str, + cx: &mut gpui::AsyncApp, + ) -> Result<(), String> { + use crate::daemon::protocol::{SftpJobState, SftpOp}; + // Long enough for a screenshot over a slow link, bounded so a wedged + // job cannot poll forever. + const POLL: std::time::Duration = std::time::Duration::from_millis(500); + const POLLS: usize = 600; + for _ in 0..POLLS { + cx.background_executor().timer(POLL).await; + let listed = { + let route = route.clone(); + cx.background_spawn(async move { route.transfer_list() }) + .await + }; + let Some(progress) = listed.into_iter().find(|j| j.job_id == job) else { + // Pruned after the retention window, or the daemon restarted: + // there is nothing left to report either way. + return Ok(()); + }; + match progress.state { + SftpJobState::Running => continue, + SftpJobState::Done => { + // The staging directory is already owner-only, so this is + // belt and braces against a wider umask on the remote. + let (route, path) = (route.clone(), remote.to_string()); + cx.background_spawn( + async move { route.op(SftpOp::Chmod { path, mode: 0o600 }) }, + ) + .await; + return Ok(()); + } + SftpJobState::Cancelled => return Ok(()), + SftpJobState::Error => { + return Err(progress.error.unwrap_or_else(|| "upload failed".into())); + } + } + } + Ok(()) + } + + /// One notification per failed paste — the pane's line already has a path + /// in it, and the user is the only one who can tell whether it matters. + #[cfg(not(target_os = "macos"))] + fn warn_image_upload_failed( + &self, + host: &str, + reason: &str, + window: &mut Window, + cx: &mut Context, + ) { + log::warn!("clipboard image upload to {host} failed: {reason}"); + window.push_notification( + crate::ui::i18n::t_fmt( + crate::ui::i18n::L10nKey::SftpImagePasteUploadFailed, + &[("host", host), ("error", reason)], + ), + cx, + ); + } + pub fn clear_scrollback(&mut self, cx: &mut Context) { self.terminal.term.lock().grid_mut().clear_history(); self.scroll_frac = 0.; @@ -4965,6 +5266,8 @@ mod tests { input_overlay_rows, menu_layout, paste_bytes, select_end_copy, shell_escape_path, smooth_scroll_step, submit_bytes, trim_trailing_spaces, wheel_route, wrapped_click_index, }; + #[cfg(not(target_os = "macos"))] + use super::{remote_paste_spec, staging_cache, staging_dir_is_safe}; use alacritty_terminal::term::TermMode; use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers}; use gpui_component::IconName; @@ -5161,6 +5464,141 @@ mod tests { assert_eq!(loopback_plan(true, Some(&w), None, 7), LoopbackPlan::Direct); } + /// The SSH user a paste would be uploaded for, or `None` when the pane + /// keeps the local-path behavior. + #[cfg(not(target_os = "macos"))] + fn remote_paste_user<'a>( + workspace: Option<&'a crate::terminal::PaneWorkspace>, + ssh_spec: Option<&'a crate::daemon::protocol::NativeSshSpec>, + ) -> Option<&'a str> { + remote_paste_spec(workspace, ssh_spec).map(|s| s.user.as_str()) + } + + #[cfg(not(target_os = "macos"))] + fn native_spec() -> crate::daemon::protocol::NativeSshSpec { + serde_json::from_str(r#"{"host":"dev.box","port":22,"user":"me","auth_mode":"auto"}"#) + .unwrap() + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn local_pane_pastes_the_local_image_path() { + assert_eq!(remote_paste_user(None, None), None); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn ssh_workspace_panes_upload_images_for_the_ssh_user() { + let w = ws(RemoteTarget::direct("me", "dev.box", 22), true); + assert_eq!(remote_paste_user(Some(&w), None), Some("me")); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn standalone_ssh_panes_upload_images_for_the_ssh_user() { + let spec = native_spec(); + assert_eq!(remote_paste_user(None, Some(&spec)), Some("me")); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn wsl_and_specless_workspaces_keep_the_local_image_path() { + let wsl = ws( + RemoteTarget::Wsl { + distro: "Ubuntu".into(), + }, + false, + ); + assert_eq!(remote_paste_user(Some(&wsl), None), None); + let bare = ws(RemoteTarget::direct("me", "dev.box", 22), false); + assert_eq!(remote_paste_user(Some(&bare), None), None); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn a_staging_dir_is_only_safe_when_it_is_a_private_directory_we_own() { + use crate::daemon::protocol::SftpEntryKind; + // `chmod 0700` succeeded and the mode came back as asked: ours. + assert!(staging_dir_is_safe( + false, + Some(SftpEntryKind::Dir), + 0o040700 + )); + // A mode anyone else can enter is one anyone else can read pastes from. + assert!(!staging_dir_is_safe( + false, + Some(SftpEntryKind::Dir), + 0o040755 + )); + assert!(!staging_dir_is_safe( + false, + Some(SftpEntryKind::Dir), + 0o040701 + )); + // Sticky/setgid bits mean someone else set the terms. + assert!(!staging_dir_is_safe( + false, + Some(SftpEntryKind::Dir), + 0o041700 + )); + // A symlink is judged by its target by `stat`, so refuse it outright. + assert!(!staging_dir_is_safe( + true, + Some(SftpEntryKind::Dir), + 0o040700 + )); + // A file (or a path that vanished) is not a staging dir. + assert!(!staging_dir_is_safe( + false, + Some(SftpEntryKind::File), + 0o100700 + )); + assert!(!staging_dir_is_safe(false, None, 0o040700)); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn a_failed_staging_preparation_is_retried_rather_than_latched() { + assert_eq!( + staging_cache(&Ok("/home/me/.cache/tty7/clipboard".to_string())), + Some("/home/me/.cache/tty7/clipboard".to_string()) + ); + // Nothing was created, so the next paste must try again instead of + // handing out a path under a directory that does not exist. + assert_eq!(staging_cache(&Err("link is down".to_string())), None); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn staged_images_land_under_the_remote_users_own_home() { + let mut dir = "/home/me".to_string(); + for component in super::REMOTE_CLIPBOARD_PATH { + dir = crate::daemon::ssh::sftp::remote_join(&dir, component); + } + assert_eq!(dir, "/home/me/.cache/tty7/clipboard"); + assert!( + !dir.starts_with("/tmp"), + "a world-writable staging dir is exactly what this avoids" + ); + assert_eq!(super::REMOTE_CLIPBOARD_MODE, 0o700); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn the_pasted_image_name_stands_alone_as_a_remote_path_component() { + use crate::daemon::ssh::sftp::safe_local_name; + use gpui::{Image, ImageFormat}; + + let pixel = image::RgbaImage::from_pixel(1, 1, image::Rgba([4, 5, 6, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(pixel) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let path = super::write_clipboard_image(&Image::from_bytes(ImageFormat::Png, png)).unwrap(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + assert!(safe_local_name(&name), "{name} must not traverse or nest"); + } + #[test] fn the_off_switch_disables_every_route() { let w = ws(RemoteTarget::direct("me", "dev.box", 22), true); diff --git a/src/ui/app.rs b/src/ui/app.rs index 74edc52b..ef5863bc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -408,6 +408,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)] @@ -710,6 +713,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/i18n.rs b/src/ui/i18n.rs index 02a03603..319f799b 100644 --- a/src/ui/i18n.rs +++ b/src/ui/i18n.rs @@ -356,16 +356,26 @@ pub enum L10nKey { SettingsAboutTech, SettingsVersion, SettingsUpdates, - SettingsVersionAvailable, - SettingsCheckUpdatesDesc, - SettingsCheckUpdatesOnLaunch, - SettingsUpdateInstall, + SettingsUpdateAndRelaunch, SettingsUpdateViewRelease, - SettingsUpdateCheckNow, SettingsUpdateChecking, SettingsUpdateUpToDate, SettingsUpdateDownloading, SettingsUpdateInstalling, + SettingsUpdateCheckNow, + SettingsUpdateCheckFailed, + SettingsUpdatePrepareFailed, + SettingsUpdateLaunchFailed, + SettingsUpdateUnsupportedMacos, + SettingsUpdateUnsupportedLinux, + SettingsUpdateUnsupportedWindows, + SettingsUpdateWindowsAllUsers, + SettingsUpdateUnsupportedPlatform, + SettingsUpdateMissingPackage, + SettingsUpdateMissingChecksums, + SettingsVersionAvailable, + SettingsCheckUpdatesDesc, + SettingsCheckUpdatesOnLaunch, SettingsCommandLine, SettingsCommandLineDesc, SettingsInstallCliOnPath, @@ -472,6 +482,7 @@ pub enum L10nKey { SftpTransferDone, SftpTransferCancelled, SftpTransferError, + SftpImagePasteUploadFailed, ForwardPanelTitle, ForwardDisconnected, ForwardDisconnectedFrom, @@ -699,6 +710,7 @@ pub enum L10nKey { RemoteMismatchDetail, RemoteMismatchUnknownBuild, RemoteMismatchUnknownBuildFromExe, + RemoteMismatchReplaceServer, RemoteDaemonStartFailed, RemoteDaemonUnreachable, RemoteDaemonTooOld, @@ -1651,28 +1663,65 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { ), L10nKey::SettingsVersion => ("Version", "版本"), L10nKey::SettingsUpdates => ("Updates", "更新"), - L10nKey::SettingsVersionAvailable => { - ("Version {version} is available.", "新版本 {version} 可用。") - } - L10nKey::SettingsCheckUpdatesDesc => ( - "Check GitHub for new stable releases. Packaged macOS builds can install the update and relaunch; every other platform opens the release page.", - "检查 GitHub 上是否有新的稳定版。打包的 macOS 版本可以直接安装更新并重启;其他平台会打开发布页面。", - ), - L10nKey::SettingsCheckUpdatesOnLaunch => ("Check for updates on launch", "启动时检查更新"), - L10nKey::SettingsUpdateInstall => ("Update and Relaunch", "更新并重启"), - L10nKey::SettingsUpdateViewRelease => ("View Release", "查看发布页"), - L10nKey::SettingsUpdateCheckNow => ("Check Now", "立即检查"), - L10nKey::SettingsUpdateChecking => ("Checking…", "检查中…"), + 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…", "正在重启以应用更新…") + ("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 => ( + "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", "命令行"), 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.", @@ -1966,6 +2015,10 @@ fn translate(locale: Locale, key: L10nKey) -> &'static str { L10nKey::SftpTransferDone => ("done", "完成"), L10nKey::SftpTransferCancelled => ("cancelled", "已取消"), L10nKey::SftpTransferError => ("error", "错误"), + L10nKey::SftpImagePasteUploadFailed => ( + "Could not upload the pasted image to {host}: {error}", + "无法将粘贴的图片上传到 {host}:{error}", + ), L10nKey::ForwardPanelTitle => ("Forwards", "端口转发"), L10nKey::ForwardDisconnected => ("Disconnected", "已断开"), L10nKey::ForwardDisconnectedFrom => ("Disconnected from {host}", "与 {host} 的连接已断开"), @@ -2269,23 +2322,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})") @@ -3216,16 +3270,26 @@ mod tests { L10nKey::SettingsAboutDesc2, L10nKey::SettingsAboutTech, L10nKey::SettingsUpdates, - L10nKey::SettingsVersionAvailable, - L10nKey::SettingsCheckUpdatesDesc, - L10nKey::SettingsCheckUpdatesOnLaunch, - L10nKey::SettingsUpdateInstall, + L10nKey::SettingsUpdateAndRelaunch, L10nKey::SettingsUpdateViewRelease, - L10nKey::SettingsUpdateCheckNow, 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, L10nKey::SettingsCommandLine, L10nKey::SettingsCommandLineDesc, L10nKey::SettingsInstallCliOnPath, @@ -3332,6 +3396,7 @@ mod tests { L10nKey::SftpTransferDone, L10nKey::SftpTransferCancelled, L10nKey::SftpTransferError, + L10nKey::SftpImagePasteUploadFailed, L10nKey::ForwardPanelTitle, L10nKey::ForwardDisconnected, L10nKey::ForwardDisconnectedFrom, @@ -3559,6 +3624,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..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.restart_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/settings.rs b/src/ui/settings.rs index 16235cb2..d5cd0f3f 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -85,6 +85,59 @@ struct SearchEntry { keywords: L10nKey, } +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::*; @@ -1332,7 +1385,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.)), @@ -1456,7 +1512,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.)), @@ -3466,7 +3525,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}×")), @@ -4658,22 +4720,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(t(L10nKey::SettingsUpdateChecking).to_string()) - } - crate::core::update::UpdatePhase::UpToDate => { - Some(t(L10nKey::SettingsUpdateUpToDate).to_string()) - } - crate::core::update::UpdatePhase::Downloading => { - Some(t(L10nKey::SettingsUpdateDownloading).to_string()) - } - crate::core::update::UpdatePhase::Installing => { - Some(t(L10nKey::SettingsUpdateInstalling).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 logo = Arc::new(Image::from_bytes( @@ -4748,10 +4795,14 @@ impl Tty7App { ) .when_some(update, |this, upd| { let button_label = if upd.installable { - t(L10nKey::SettingsUpdateInstall) + t(L10nKey::SettingsUpdateAndRelaunch).to_string() } else { - t(L10nKey::SettingsUpdateViewRelease) + t(L10nKey::SettingsUpdateViewRelease).to_string() }; + let availability = t_fmt( + L10nKey::SettingsVersionAvailable, + &[("version", &upd.version)], + ); this.child( v_flex() .gap_1() @@ -4759,10 +4810,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) @@ -4774,7 +4827,12 @@ 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)), + ) }), ) }) 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(); + })), ), ), );