diff --git a/.dockerignore b/.dockerignore index c6faaa5..0e7a51b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,13 @@ +# The node image builds from the repository root (deploy/node). Patterns +# without **/ match only at the root, so every .env (the node's and the +# relay's hold a Tailscale auth key) is named for any depth: none may reach +# the build stage, whose layer keeps whatever it copies. target dist +dist.noindex .git **/.DS_Store -*.app -.env +**/*.app +**/.env +**/.env.* +**/__pycache__ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7143e..ab08941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: - name: fmt run: cargo fmt --all -- --check - name: clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: test - run: cargo test --workspace + run: cargo test --locked --workspace # The setup script is generated, so only a dump of it can be parsed. # Every plan shape, with names that need quoting, through the parser # of the Windows PowerShell that runs it; plus the shipped scripts. @@ -41,7 +41,7 @@ jobs: $env:LATCH_DUMP_SETUP_SCRIPT = $out $env:LATCH_DUMP_MIGRATE = $migrate $env:LATCH_DUMP_DRY_RUN = $dry - cargo test -q -p latch-host --lib the_generated_script_can_be_dumped_for_a_syntax_check + cargo test -q --locked -p latch-host --lib the_generated_script_can_be_dumped_for_a_syntax_check if ($LASTEXITCODE -ne 0) { throw "dumping the setup script failed" } $files += $out } @@ -52,8 +52,104 @@ jobs: if ($errors.Count -gt 0) { $errors | Format-List | Out-String | Write-Host; throw "$f does not parse" } Write-Host "parses: $f" } + # The BroLink -> Latch retire step moves the engine folder, with the + # pairing state in it, on a PC that ran 4.0. Run it for real here, on + # temporary folders and a dummy service, through the Windows + # PowerShell setup uses: a clean move, a second run, a folder in the + # way (empty and part-filled), and a move that fails. + - name: retire step on an old layout + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $root = Join-Path $env:RUNNER_TEMP 'retire' + $old = Join-Path $root 'BroLink\engine' + $new = Join-Path $root 'Latch\engine' + $svc = 'LatchRetireSelftest' + $script = Join-Path $env:RUNNER_TEMP 'retire.ps1' + $env:LATCH_DUMP_RETIRE_SCRIPT = $script + $env:LATCH_DUMP_RETIRE_OLD = $old + $env:LATCH_DUMP_RETIRE_NEW = $new + $env:LATCH_DUMP_RETIRE_SERVICE = $svc + cargo test -q --locked -p latch-host --lib the_retire_step_can_be_dumped_for_a_windows_run + if ($LASTEXITCODE -ne 0) { throw "dumping the retire step failed" } + function Wait-ServiceGone { + foreach ($i in 1..20) { + sc.exe query $svc | Out-Null + if ($LASTEXITCODE -eq 1060) { return $true } + Start-Sleep -Milliseconds 500 + } + return $false + } + function Reset-Layout { + sc.exe query $svc | Out-Null + if ($LASTEXITCODE -ne 1060) { sc.exe delete $svc | Out-Null } + if (-not (Wait-ServiceGone)) { throw "the dummy service would not go away" } + if (Test-Path $root) { Remove-Item $root -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Join-Path $old 'config\credentials') | Out-Null + Copy-Item "$env:WINDIR\System32\whoami.exe" (Join-Path $old 'sunshine.exe') + Set-Content -LiteralPath (Join-Path $old 'config\sunshine_state.json') -Value '{"root":{"uniqueid":"selftest"}}' + Set-Content -LiteralPath (Join-Path $old 'config\credentials\cacert.pem') -Value 'cert' + Set-Content -LiteralPath (Join-Path $old 'config\credentials\cakey.pem') -Value 'key' + sc.exe create $svc binPath= "$env:WINDIR\System32\cmd.exe" start= demand | Out-Null + if ($LASTEXITCODE -ne 0) { throw "could not create the dummy service (sc exit $LASTEXITCODE)" } + } + function Invoke-Retire { + $out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script | Out-String + Write-Host $out + if ($LASTEXITCODE -ne 0) { throw "the retire step exited $LASTEXITCODE" } + return $out + } + function Assert-Moved([string]$why) { + foreach ($f in 'config\sunshine_state.json', 'config\credentials\cacert.pem', 'config\credentials\cakey.pem') { + if (-not (Test-Path -LiteralPath (Join-Path $new $f))) { throw "${why}: $f did not reach $new" } + } + if (Test-Path -LiteralPath (Join-Path $new 'engine')) { throw "${why}: the old engine landed inside $new" } + } + + Write-Host '== a clean move' + Reset-Layout + $out = Invoke-Retire + Assert-Moved 'clean move' + if (Test-Path -LiteralPath (Split-Path $old)) { throw 'clean move: the old folder is still there' } + if (-not (Wait-ServiceGone)) { throw 'clean move: the old service was not deleted' } + $state = Join-Path $new 'config\sunshine_state.json' + $before = (Get-FileHash -LiteralPath $state).Hash + + Write-Host '== a second run changes nothing' + $out = Invoke-Retire + if ($out -match 'could not') { throw 'second run: it reported a failure' } + if ((Get-FileHash -LiteralPath $state).Hash -ne $before) { throw 'second run: the pairing state changed' } + + Write-Host '== an empty engine folder in the way' + Reset-Layout + New-Item -ItemType Directory -Force -Path $new | Out-Null + $out = Invoke-Retire + Assert-Moved 'empty folder in the way' + + Write-Host '== a part-filled engine folder in the way' + Reset-Layout + New-Item -ItemType Directory -Force -Path (Join-Path $new 'tools') | Out-Null + Set-Content -LiteralPath (Join-Path $new 'tools\left-over.txt') -Value 'x' + $out = Invoke-Retire + Assert-Moved 'part-filled folder in the way' + if (-not (Get-ChildItem -LiteralPath (Split-Path $new) -Filter 'engine.partial-*')) { throw 'part-filled folder: it was not kept aside' } + + Write-Host '== a move that fails' + Reset-Layout + # Open without delete sharing, as an antivirus scan or an engine + # restarted in the gap would: Windows will not rename the folder. + $held = [IO.File]::Open((Join-Path $old 'config\sunshine_state.json'), 'Open', 'Read', 'Read') + try { $out = Invoke-Retire } finally { $held.Dispose() } + if ($out -notmatch 'could not move the engine \(try 5 of 5\)') { throw 'failed move: it was not retried' } + if (-not (Test-Path -LiteralPath (Join-Path $old 'config\sunshine_state.json'))) { throw 'failed move: the old state is gone' } + Assert-Moved 'failed move' + Write-Host 'retire step: every case passed' + # The runner fails a step on the last native command's exit code, + # and sc.exe query exits 1060 for a service that is gone, which is + # what these checks wait for. + exit 0 - name: release build - run: cargo build --workspace --release + run: cargo build --locked --workspace --release - uses: actions/upload-artifact@v4 with: name: latch-host-windows @@ -70,16 +166,27 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - name: clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: test - run: cargo test --workspace + run: cargo test --locked --workspace - name: release app - run: cargo build --release -p latch-host --target aarch64-apple-darwin + run: cargo build --locked --release -p latch-host --target aarch64-apple-darwin - name: bundle run: bash scripts/bundle-macos.sh + # Both production Linux hosts are arm64 (c_char is unsigned there, and + # openh264 builds its own assembly); x64 is the other common VPS. test-linux: - runs-on: ubuntu-latest + name: test-linux (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: x64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -89,11 +196,16 @@ jobs: - name: native deps run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang nasm pkg-config libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libx11-xcb-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev - name: clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: test - run: cargo test --workspace + run: cargo test --locked --workspace - name: release host - run: cargo build --release -p latch-host + run: cargo build --locked --release -p latch-host + - uses: actions/upload-artifact@v4 + with: + name: latch-host-linux-${{ matrix.arch }} + path: target/release/latch-host + if-no-files-found: error deploy-selftest: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a36710..274f7c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,8 @@ name: release # Two ways in: # * workflow_dispatch: build the binaries and leave them on the run page. -# * a v* tag: the same binaries, published as a GitHub release. +# * a v* tag: the same binaries, published as a GitHub release. The tag has +# to be the workspace version (Cargo.toml) with a v in front. on: workflow_dispatch: push: @@ -28,17 +29,24 @@ jobs: - uses: Swatinem/rust-cache@v2 # Never ship a binary whose tests were not run. - name: test - run: cargo test --workspace + run: cargo test --locked --workspace - name: build app - run: cargo build --release -p latch-host --target aarch64-apple-darwin + run: cargo build --locked --release -p latch-host --target aarch64-apple-darwin # bundle-macos.sh ad-hoc signs and verifies the bundle itself. - name: bundle run: bash scripts/bundle-macos.sh + # A Mac refuses an update whose bundle says another version than the + # release, deletes the download and fetches it again 15 minutes later. + - name: bundle version matches the tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + have="$(/usr/bin/plutil -extract CFBundleShortVersionString raw dist.noindex/Latch.app/Contents/Info.plist)" + [ "$have" = "${GITHUB_REF_NAME#v}" ] || { echo "::error::tag ${GITHUB_REF_NAME} but the bundle says ${have}; bump the version in Cargo.toml"; exit 1; } - name: package run: | - cp docs/MACOS.md dist/README-MACOS.md - cp LICENSE NOTICE dist/ - cd dist && tar czf latch-macos-arm64.tar.gz Latch.app README-MACOS.md LICENSE NOTICE + cp docs/MACOS.md dist.noindex/README-MACOS.md + cp LICENSE NOTICE dist.noindex/ + cd dist.noindex && tar czf latch-macos-arm64.tar.gz Latch.app README-MACOS.md LICENSE NOTICE # A 4.0 updater downloads only its original archive and app names. # The same app under that folder name lets it update at all; on its # first run it moves itself to Latch.app @@ -46,17 +54,17 @@ jobs: # adopt_legacy_bundle). Nothing in 4.1 or later reads this asset. - name: package for 4.0 updaters run: | - mkdir dist/legacy - ditto dist/Latch.app dist/legacy/BroLink.app - cp docs/MACOS.md dist/legacy/README-MACOS.md - cp LICENSE NOTICE dist/legacy/ - cd dist/legacy && tar czf ../brolink-macos-arm64.tar.gz BroLink.app README-MACOS.md LICENSE NOTICE + mkdir dist.noindex/legacy + ditto dist.noindex/Latch.app dist.noindex/legacy/BroLink.app + cp docs/MACOS.md dist.noindex/legacy/README-MACOS.md + cp LICENSE NOTICE dist.noindex/legacy/ + cd dist.noindex/legacy && tar czf ../brolink-macos-arm64.tar.gz BroLink.app README-MACOS.md LICENSE NOTICE - uses: actions/upload-artifact@v4 with: name: latch-macos-arm64 path: | - dist/latch-macos-arm64.tar.gz - dist/brolink-macos-arm64.tar.gz + dist.noindex/latch-macos-arm64.tar.gz + dist.noindex/brolink-macos-arm64.tar.gz if-no-files-found: error windows: @@ -66,9 +74,9 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: test - run: cargo test --workspace + run: cargo test --locked --workspace - name: build - run: cargo build --release -p latch-host + run: cargo build --locked --release -p latch-host - name: pin matches setup.rs shell: pwsh run: | @@ -120,10 +128,50 @@ jobs: brolink-windows-x64.zip if-no-files-found: error + # The Linux host, for VPS desktops: both production ones are arm64, x64 is + # the other common kind. Built on Ubuntu 24.04 like CI, so it needs glibc + # 2.39 or later. The Mac does not push updates to Linux hosts; they install + # from here (deploy/native/README.md) and check the published SHA-256. + linux: + strategy: + matrix: + include: + - runner: ubuntu-24.04 + arch: x64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: native deps + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang nasm pkg-config libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libx11-xcb-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev + - name: test + run: cargo test --locked --workspace + - name: build + run: cargo build --locked --release -p latch-host + - name: package + run: | + name="latch-host-linux-${{ matrix.arch }}" + mkdir -p "dist/$name" + cp target/release/latch-host LICENSE NOTICE "dist/$name/" + cp -R deploy/native "dist/$name/native" + tar -C dist -czf "$name.tar.gz" "$name" + sha256sum "$name.tar.gz" > "$name.tar.gz.sha256" + cat "$name.tar.gz.sha256" + - uses: actions/upload-artifact@v4 + with: + name: latch-host-linux-${{ matrix.arch }} + path: | + latch-host-linux-${{ matrix.arch }}.tar.gz + latch-host-linux-${{ matrix.arch }}.tar.gz.sha256 + if-no-files-found: error + publish: # Tags only: a manual run just leaves artifacts on the run page. if: startsWith(github.ref, 'refs/tags/v') - needs: [macos, windows] + needs: [macos, windows, linux] runs-on: ubuntu-latest permissions: contents: write @@ -132,8 +180,17 @@ jobs: # before it would be deleted. - uses: actions/checkout@v4 with: - sparse-checkout: CHANGELOG.md + sparse-checkout: | + CHANGELOG.md + Cargo.toml sparse-checkout-cone-mode: false + # The release is named after the tag and every program reports the + # workspace version: a tag pushed before the bump would publish a + # release no Mac can update to. Read the way bundle-macos.sh reads it. + - name: tag matches the workspace version + run: | + have="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)" + [ "$have" = "${GITHUB_REF_NAME#v}" ] || { echo "::error::tag ${GITHUB_REF_NAME} but Cargo.toml says ${have}"; exit 1; } - uses: actions/download-artifact@v4 with: path: artifacts diff --git a/.gitignore b/.gitignore index d23ab66..6a04eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /target /dist +/dist.noindex **/*.rs.bk .idea/ .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 27afe5a..a1ce25b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,159 @@ The release workflow publishes the section that matches the tag as the GitHub release notes, so each version gets a heading of the form `## X.Y.Z (date)`. +## Unreleased + +- **Updates after a stream.** A Windows PC took any Desktop left running + after a viewer had gone for a live stream and turned down every pushed + update, so the Mac sent the whole exe again every three minutes. The host + now defers an update only while a viewer is connected (a viewer that + vanished, as with a Mac that slept, counts as gone), reports that in its + status so the Mac waits instead of uploading, and a host from before this + that still says no is asked less and less often (up to once an hour). +- **Docker nodes and identity.** Under userspace Tailscale every tailnet + machine reached the control API from 127.0.0.1 and was trusted as the + local panel. Loopback connections are now checked with `tailscale whois` + of their port, and the node kit runs Tailscale with kernel networking. +- **Pairing.** The Mac hands the PIN over beside the first pairing request + instead of in front of it, so pairing goes on the moment the PC accepts + the PIN, a request that fails shows its error at once, and Cancel works + throughout. A refusal from Latch on the PC is shown with its reason + instead of "isn't answering" and ends the attempt; the PIN goes to this + Mac's own pending pairing, never another device's. +- **Cancelling a connection** no longer quits an app or desktop it had + rejoined on the PC. Connection failures are logged with every cause and + the banner names the step that failed. +- **Power.** Sleep is offered only for Windows PCs and refused by hosts + nothing can wake (a Linux VM such as a VPS desktop, a Mac, a container), + every power action asks first and follows the owner's setting, the + stream ends only once the machine has agreed, and actions go to the + machine picked even when two share a name. A Mac's failed Restart or Shut + down is reported, and a Developer ID build carries the Apple Events + entitlement it needs. +- **Stream input in order.** Keys, clicks and pastes reach the PC in the + order they were made: a quickly released Shift or ⌘ no longer drops off a + keystroke, a click lands where it was made, and keys typed right after ⌘V + wait for the paste. Horizontal scrolling goes the right way. ⌘Q, ⌘H and + ⌘+/⌘−/⌘0 reach the PC while the stream has the keys. The host key no longer + leaves keys stuck or sends a stray Alt, clicking the picture while + connecting no longer locks the mouse, and Tab reaches the PC in Free mode + with a panel open. A Mac at the other end gets ⌘ as Command. +- **Clipboard.** ⌘V works when the Mac's clipboard holds an image or + nothing, and after copying a file or an image on the PC it pastes that + there instead of the Mac's older text. A paste over 32 KB says it was cut + and no longer overwrites the Mac's clipboard with the cut copy; text heavy + in quotes or newlines no longer fails to cross. On Linux the clipboard + works again after the X server restarts. +- **Settings survive a bad value.** One setting a build cannot read (720p + read by an older build, say) no longer wipes client.toml with its pairing + certificates and wake addresses: only that value falls back. The service + keeps its last good host.toml when a hand edit has a typo. +- **Machine list.** A machine that is away can be forgotten from its menu, + and one that left the tailnet over a month ago is forgotten by itself. + Machines are probed all at once, idle ones no longer flash "Relayed via", + Mullvad exit nodes are left out, the nearest relay names the right city, + and a LAN address (saved by older versions too) is never used as a PC's + public wake address. A Mac with Share this Mac off no longer reads as "the + background service is stopped". +- **A desktop that follows its viewer** (a Linux VPS) ends a session nobody + has watched for two minutes, so its prep command's undo puts the desktop + back at its idle size instead of leaving it at the last viewer's 4K. The + Mac relaunches the Desktop for a new size only on such a desktop. +- **Background service.** On a Mac it runs under launchd, is restarted + there after an update (never two copies), and its login item is turned + back on with sharing; the Sharing page says when the macOS firewall or a + disabled login item blocks it. A window no longer restarts a service that + exits on purpose every four seconds, an older window no longer relaunches + itself forever, and a self-update that never serves is rolled back to the + previous version, kept until the new one has proven itself. Panics are + written to the log. `accept_updates = false` in host.toml refuses pushed + updates; pushes are logged with their sender and SHA-256. The wake + listener and "keeping awake" claims are Windows-only (a shared Mac is now + really kept awake while plugged in). +- **Windows setup.** Setup run over SSH starts the engine in the signed-in + user's session instead of one with no desktop, running it again no longer + starts the SYSTEM engine beside yours, install-host.ps1 brings the host + back after replacing it, a BroLink engine move retries and keeps paired + devices, a stale engine archive falls back to the pinned download, Set up + during a stream explains the administrator prompt first, and the audio + fix-up backs off instead of running PowerShell every 30 seconds. +- **Light appearance.** Latch no longer quits at launch on a Mac or PC set + to Light. Screen readers hear dropdown names and values and can adjust + the Bitrate slider, Escape that closes a menu stays in Latch, a menu can + no longer stay stuck open, and Korean and other CJK text is drawn. +- **Releases** include the Linux host for arm64 and x64 + (`latch-host-linux-*.tar.gz` with SHA-256 files), are refused when the + tag does not match the version, and build with locked dependencies. The + relay kit pulls the current Tailscale image on every run, `.env` files + never enter image builds, and logging out of a native Linux desktop + starts a fresh session. +- **Resolutions below 1080p: 900p, 720p and 540p** (1600 × 900, 1280 × 720, + 960 × 540), for a machine that encodes in software or a slow network. + The frame-rate ceiling a small-CPU host reports (30 fps) now holds at + 1080p and rises in proportion for fewer pixels, so 720p gets 60 fps + from a two-core VPS; larger sizes keep 30. A host with a fixed desktop + is still never asked for more than its own size, but a smaller size is + now honoured (scaled down) instead of replaced by the desktop's. + Windows PCs on the Virtual Display Driver need **Repair setup** once so + the driver lists the three new sizes. +- **A new size starts the Desktop again.** Connecting while the PC's + Desktop still ran from an earlier stream resumed it, and a Linux desktop + that resizes itself to the viewer only does so at a launch: a 540p stream + showed the 3024 x 1964 desktop scaled down, with captured mouse movement + crossing it at a third of the speed. The Mac now ends and relaunches the + Desktop whenever it asks for a different size than it last launched it + at (or does not know), and resumes it at the same size. Games and other + apps are never ended for this. +- Captured mouse movement over a desktop of fixed size that the stream + shrinks (a 1080p VPS desktop streamed at 720p) is scaled to the + desktop's pixels, so it keeps the Mac's speed. +- **Pairing with Sunshine from October 2026.** Those engines keep the PIN + request open until the Mac has finished the pairing handshake, and the + Mac only went on with it once Latch Host had answered, so pairing a new + Mac timed out. Latch Host now answers as soon as the engine has taken the + PIN, and answers the Mac's retries for that PIN without asking the + engine again. +- `connect_real` (an ignored test in `crates/client/src/session.rs`) + connects to a machine through the window's own path at a list of sizes, + with optional pointer probes, typing and frame-rate sampling. +- **Clipboard with a Linux desktop.** ⌘C, ⌘X and ⌘V reach a Linux machine + as Ctrl+Insert, Shift+Delete and Shift+Insert, the shortcuts GTK, Qt, + Firefox, Chromium and the terminals all honour. They were Ctrl+C and + Ctrl+V, which in a terminal interrupt the running program and type a + literal instead of copying and pasting. Held modifiers are let go for + the chord, so ⌘ held as Ctrl does not turn a paste into Ctrl+Shift+Insert. + Latch Host on Linux puts pasted text in the selection as well as the + clipboard, since xterm and the VTE terminals paste the selection on + Shift+Insert, and says so in `/v1/clipboard` (`selection`); against an + older Linux host the Mac keeps Ctrl+V for paste, which would otherwise + paste the terminal's last selection. +- **A VPS desktop streams at its own size and a frame rate it can keep.** + Latch Host reports the X screen it captures and, when it encodes in + software on fewer than four cores, a 30 fps ceiling; the Mac asks for + exactly that. "Match screen" used to ask a 1920 × 1080 desktop for the + Mac's size, which Sunshine met by scaling the same pixels up (blurrier, + and more work for the encoder). On a two-core VPS, 60 fps desktop + sessions averaged 33 to 47 encoded frames a second; Sunshine took 136% + CPU for a still desktop at 60 fps and 78% for a busier one at 30. +- On Linux the engine no longer holds a 60 fps floor + (`minimum_fps_target = 0`): X11 capture already delivers a frame every + interval, so the floor could only re-encode the last picture whenever a + frame was done early. Part of the CPU the 30 fps ceiling frees goes to + the `superfast` preset, whose keyframes keep text legible (QP 21 instead + of 38 at 20 Mbps, for 19% more encoding time a frame). Existing + hosts pick both up once (`stream-profile-v4`) while nothing streams, + checked through GameStream's `/serverinfo`, which reports the running + app; `/api/apps` does not. +- The native VPS units give the engine and the X server ten times the CPU + share of other user services (`CPUWeight=1000`). +- **A Mac can be only a viewer.** On a Mac, **Share this Mac** on the + Sharing page (stored as the old start-at-login switch) decides whether + Latch runs in the background. Off, opening Latch no longer starts the + background service or the streaming engine (a service started anyway, + say by an older window, exits at once), turning it off stops both, and + nothing of Latch runs once the window closes. Connecting to other + machines needs neither. + ## 4.1.1 (2026-10-02) - Setting up a Windows PC that a version before 4.1.0 had set up now diff --git a/Cargo.lock b/Cargo.lock index a5c05c9..4888ab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2125,6 +2125,9 @@ dependencies = [ "latch-core", "latch-stream", "latch-ui", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "parking_lot", "rand", "semver", @@ -2176,6 +2179,7 @@ dependencies = [ "tracing-subscriber", "windows 0.58.0", "winresource", + "x11rb", ] [[package]] @@ -2205,6 +2209,7 @@ dependencies = [ name = "latch-ui" version = "4.1.1" dependencies = [ + "ab_glyph", "egui", "latch-core", ] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7cf0a4b..afa43d4 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -30,6 +30,14 @@ egui.workspace = true egui-wgpu.workspace = true semver = "1" +# The versions winit already builds on: nothing new is compiled. Used to +# take ⌘Q and ⌘H back from the app menu during a stream, and to read the +# pasteboard's change count. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" +objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSPasteboard", "NSResponder"] } +objc2-foundation = { version = "0.2", features = ["NSString", "NSThread"] } + [dev-dependencies] toml.workspace = true egui_kittest = { version = "0.31", features = ["wgpu", "eframe"] } diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index 34ed685..e19f2ba 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -43,6 +43,25 @@ const NOTICE_FOR: Duration = Duration::from_secs(12); /// A one-line result, filled in by a worker thread. type Notice = Arc>>; +/// A PC's fixed desktop size and frame-rate ceiling, from its last status. +type HostLimits = (Option<[u32; 2]>, u32); + +/// A question about one machine, waiting for a second click. The machine +/// is its node id: names are not unique on a tailnet. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Confirm { + node_id: String, + name: String, + action: Confirmable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Confirmable { + Power(PowerAction), + /// Forget what Latch saved about it, its pairing included. + Forget, +} + /// The window's pages, one per tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { @@ -95,12 +114,20 @@ pub struct ClientApp { tailscale_ok: Arc, brand: ui::Brand, page: Page, + /// Whether the window is full screen, as the system last reported it. fullscreen: bool, - /// A restart or shutdown waits for a second click. - confirm: Option<(String, PowerAction)>, + /// Full screen or not once streaming is over: what the window was when + /// the stream first connected. Kept across a reconnect. + fullscreen_after: Option, + /// A restart, a shutdown or forgetting a machine waits for a second + /// click. + confirm: Option, notice: Option<(Tone, String, Instant)>, /// After a session ends, offer to sleep this PC. offer_sleep: Option, + /// A power action went to the machine of the current attempt: its end + /// is no failure, and no reason to offer sleep. Cleared if refused. + power_sent: Arc, ended_seen: bool, pending_notice: Option, updates: Arc>, @@ -165,9 +192,11 @@ impl ClientApp { brand: ui::Brand::new(&cc.egui_ctx), page: Page::Machines, fullscreen: false, + fullscreen_after: None, confirm: None, notice: None, offer_sleep: None, + power_sent: Arc::default(), ended_seen: true, pending_notice: None, updates: Arc::default(), @@ -253,9 +282,12 @@ impl ClientApp { self.streamed = false; self.stream_failed = false; self.confirm = None; + self.power_sent.store(false, Ordering::Relaxed); self.last_pc = Some(pc.clone()); self.asked_about_video = false; - *self.video_help.lock() = None; + // A new slot, so an answer still on its way about the last stream + // lands in the old one. + self.video_help = Arc::default(); self.display_at_connect = Self::native_pixels(ctx); self.display_change = None; session::connect(Connect { @@ -284,30 +316,43 @@ impl ClientApp { self.pending_notice = Some(notice); } - /// The first time a connected stream diagnoses its own picture as - /// broken, ask the PC what it can see. One question per stream: the - /// answer does not change while the same capture keeps failing. - fn ask_why_black(&mut self, ctx: &egui::Context, live: &Live) { + /// The first time a connected stream finds its picture black, ask the + /// PC what it can see. Only then: a picture that stopped arriving or + /// cannot be decoded is the network's or this Mac's doing, which the PC + /// cannot explain. One question per stream: the answer does not change + /// while the same capture keeps failing. Every way it ends leaves an + /// answer, so "Asking the PC why…" never stays up. + fn ask_why_black(&mut self, ctx: &egui::Context, live: &Live, os: &str) { + let problem = live.session.stats().video_problem; if self.asked_about_video - || live.session.stats().video_problem.is_none() + || !problem + .as_deref() + .is_some_and(crate::display::is_black_picture) || !live.session.connected() { return; } self.asked_about_video = true; - let (ip, help, ctx) = (live.ip, self.video_help.clone(), ctx.clone()); + let help = self.video_help.clone(); + if !crate::display::can_explain(os) { + *help.lock() = Some(crate::display::no_answer(os)); + return; + } + let (ip, ctx, os) = (live.ip, ctx.clone(), os.to_string()); std::thread::spawn(move || { let answer = match crate::display::ask(ip) { Ok(report) => crate::display::verdict(&report), Err(e) => { tracing::warn!("display report: {e:#}"); - return; + Default::default() } }; - if !answer.message.is_empty() { - *help.lock() = Some(answer); - ctx.request_repaint(); - } + *help.lock() = Some(if answer.message.is_empty() { + crate::display::no_answer(&os) + } else { + answer + }); + ctx.request_repaint(); }); } @@ -347,22 +392,52 @@ impl ClientApp { self.disconnect(); } + /// Ask the machine at `ip` to sleep, restart or shut down. A stream from + /// it, or a connection to it, ends once it has agreed (its host ends + /// the stream itself first), so a refusal leaves the stream up. A + /// connection to any other machine is none of this action's business. fn power(&mut self, ip: std::net::Ipv4Addr, name: &str, action: PowerAction) { let name = name.to_string(); - // End the stream first so Sunshine sees a clean disconnect. - self.disconnect(); - self.notify_later(move || { - std::thread::sleep(Duration::from_millis(600)); - match session::power(ip, action) { - Ok(()) => ( + let ours = self.is_current_target(ip); + if ours { + self.power_sent.store(true, Ordering::Relaxed); + if self.live.lock().is_some() { + self.view.toast( + Tone::Neutral, + format!("Asking {name} to {}…", action.label().to_lowercase()), + ); + } + } + let (live, progress, sent) = ( + self.live.clone(), + self.progress.clone(), + self.power_sent.clone(), + ); + self.notify_later(move || match session::power(ip, action) { + Ok(()) => { + if ours { + end_connection_to(&live, &progress, ip); + } + ( Tone::Success, format!("{name}: {} requested.", action.label().to_lowercase()), - ), - Err(e) => (Tone::Danger, format!("{name}: {e}")), + ) + } + Err(e) => { + if ours { + sent.store(false, Ordering::Relaxed); + } + (Tone::Danger, format!("{name}: {e}")) } }); } + /// The machine this window is streaming from or connecting to. + fn is_current_target(&self, ip: std::net::Ipv4Addr) -> bool { + let live_ip = self.live.lock().as_ref().map(|l| l.ip); + targets(live_ip, &self.progress.lock(), ip) + } + fn test_wake(&mut self, pc: &Pc) { let pc = pc.clone(); self.notify_later(move || match session::wake_test(&pc) { @@ -411,6 +486,7 @@ impl ClientApp { prog.step = Step::Streaming; prog.since = Instant::now(); self.streamed = true; + self.fullscreen_after.get_or_insert(self.fullscreen); if self.cfg.stream.fullscreen { fullscreen = Some(true); } else { @@ -428,7 +504,12 @@ impl ClientApp { } } Event::Failed { stage, code } => { - let cancelled = prog.cancel; + let cancelled = prog.cancel || self.power_sent.load(Ordering::Relaxed); + tracing::warn!( + "{}: the stream failed at the {stage} step (code {code}){}", + live.pc, + if cancelled { " after a stop" } else { "" } + ); self.stream_failed = !cancelled; prog.step = Step::Ended { error: (!cancelled).then_some(format!( @@ -437,8 +518,17 @@ impl ClientApp { }; } Event::Terminated { code, message } => { - let cancelled = prog.cancel; + let cancelled = prog.cancel || self.power_sent.load(Ordering::Relaxed); let error = code != 0 && !cancelled; + if code != 0 { + tracing::warn!( + "{}: the stream ended, code {code}: {message}{}", + live.pc, + if cancelled { " (after a stop)" } else { "" } + ); + } else { + tracing::info!("{}: the stream ended: {message}", live.pc); + } self.stream_failed = error; prog.step = Step::Ended { error: error.then_some(message), @@ -458,7 +548,8 @@ impl ClientApp { } self.view.reset(ctx, Some(live)); *guard = None; - fullscreen = Some(false); + fullscreen = + fullscreen_when_finished(self.reconnect.is_some(), &mut self.fullscreen_after); } else if ended { live.session.stop(); } @@ -468,9 +559,53 @@ impl ClientApp { } } + /// Reconnect when the window has moved to a display of another size + /// (held for a second), if that changes what the stream asks for: it + /// does for Match screen, and not for a fixed size or for a PC whose + /// fixed desktop is smaller than both displays. `asked` is what the + /// stream asked for; `limits` the PC's fixed desktop and frame-rate + /// ceiling, when discovery knows them (without them, it reconnects). + fn follow_display( + &mut self, + ctx: &egui::Context, + (asked, limits): ((u32, u32, u32), Option), + ) { + let size = Self::native_pixels(ctx); + if size == self.display_at_connect || self.reconnect.is_some() { + self.display_change = None; + return; + } + match self.display_change { + Some((candidate, at)) + if candidate == size && at.elapsed() >= Duration::from_secs(1) => + { + let changes = limits.is_none_or(|(capture_size, max_fps)| { + session::request_for(&self.cfg.stream, size, capture_size, max_fps) != asked + }); + if changes { + self.restart_capture = true; + self.reconnect = self.last_pc.clone(); + self.disconnect(); + } else { + // Nothing to ask for anew: this display is the one the + // stream is for now. + self.display_at_connect = size; + self.display_change = None; + } + } + Some((candidate, _)) if candidate == size => {} + _ => self.display_change = Some((size, Instant::now())), + } + } + fn poll_notice(&mut self) { let ready = self.pending_notice.as_ref().and_then(|n| n.lock().take()); if let Some((tone, text)) = ready { + // The machine list is out of sight while a stream runs: a + // refused power action, say, has to be read over the picture. + if self.live.lock().is_some() { + self.view.toast(tone, text.clone()); + } self.notice = Some((tone, text, Instant::now())); self.pending_notice = None; } @@ -514,6 +649,70 @@ impl ClientApp { } } +/// Full screen or not once a stream has finished. A restart, new settings +/// or a new display reconnect at once, so the window stays as it is rather +/// than leaving full screen and coming back; otherwise it goes back to what +/// it was before the stream (`after`), if the stream ever showed. +fn fullscreen_when_finished(reconnecting: bool, after: &mut Option) -> Option { + if reconnecting { + None + } else { + after.take() + } +} + +/// Where to send a power action meant for the machine `node_id`: the +/// listed machine with that id, if it answers and allows it. Never another +/// machine of the same name. +fn power_target(disc: &Discovery, node_id: &str) -> Option { + disc.pcs + .iter() + .find(|p| p.node_id == node_id && !p.remembered && p.online && p.power_allowed()) + .and_then(|p| p.ip) +} + +/// What forgetting a machine does, for the question before it. +fn forget_question(disc: &Discovery, node_id: &str, name: &str) -> String { + let listed = disc + .pcs + .iter() + .any(|p| p.node_id == node_id && !p.remembered); + if listed { + format!("Latch forgets how to wake {name} and its pairing. It stays in this list while it is on your Tailscale network; remove it in Tailscale's admin console to retire it.") + } else { + format!("Latch forgets {name}'s address, how to wake it and its pairing. If it is still on your Tailscale network, it is listed again once Tailscale is back, and pairs again on the next connect.") + } +} + +/// Whether `ip` is the machine of the stream (`live_ip`) or of the +/// connection under way. +fn targets( + live_ip: Option, + progress: &Progress, + ip: std::net::Ipv4Addr, +) -> bool { + live_ip == Some(ip) || (progress.active() && progress.ip == Some(ip)) +} + +/// End the stream from `ip`, or the connection to it, from a worker: what +/// a disconnect does, for that machine only. One lock at a time, as the +/// window takes them. +fn end_connection_to( + live: &Mutex>, + progress: &Mutex, + ip: std::net::Ipv4Addr, +) { + { + let mut p = progress.lock(); + if p.active() && p.ip == Some(ip) { + p.cancel = true; + } + } + if let Some(l) = live.lock().as_ref().filter(|l| l.ip == ip) { + l.session.stop(); + } +} + /// Look for Tailscale's CLI every few seconds on a thread of its own, and /// repaint when the answer changes. fn spawn_tailscale_probe(ok: Arc, ctx: egui::Context) { @@ -528,6 +727,11 @@ fn spawn_tailscale_probe(ok: Arc, ctx: egui::Context) { impl eframe::App for ClientApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // The window's green button (or Globe-F) changes full screen behind + // the app's back: what the system reports is what is true. + if let Some(on) = ctx.input(|i| i.viewport().fullscreen) { + self.fullscreen = on; + } self.poll_live(ctx); self.poll_notice(); @@ -549,20 +753,40 @@ impl eframe::App for ClientApp { .and_then(|p| p.host.as_ref()) .and_then(|h| stream::old_host(&h.version, latest.as_ref())); self.tend_handover(pc_now, &l.pc); - self.ask_why_black(ctx, l); - let help = self.video_help.lock().clone(); + let os = pc_now.map(|p| p.os.clone()).unwrap_or_default(); + self.ask_why_black(ctx, l, &os); + // The PC's answer is about a black picture: it goes under + // that headline and no other. + let black = l + .session + .stats() + .video_problem + .as_deref() + .is_some_and(crate::display::is_black_picture); + let help = self.video_help.lock().clone().filter(|_| black); let env = Env { live: l, cfg: &self.cfg, fullscreen: self.fullscreen, path: pc_now.map(|p| p.path.clone()), - os: pc_now.map(|p| p.os.clone()).unwrap_or_default(), + os, old_host, handover: self.handover.as_ref().map(|h| h.status(&l.pc)), video_help: help, + // Discovery's fresh word on it; without that, nothing. + power_allowed: pc_now.is_some_and(|p| p.power_allowed()), + can_sleep: pc_now.is_some_and(|p| p.can_sleep()), }; let actions = self.view.show(ctx, &env); let (ip, name, input) = (l.ip, l.pc.clone(), l.input.clone()); + // What a reconnect for a new display would ask for, beside + // what this stream asked for. + let display_request = ( + l.requested, + pc_now + .and_then(|p| p.host.as_ref()) + .map(|h| (h.streamer.capture_size, h.streamer.max_fps)), + ); drop(guard); for a in actions { match a { @@ -599,22 +823,7 @@ impl eframe::App for ClientApp { } } } - } - let size = Self::native_pixels(ctx); - if size != self.display_at_connect && self.reconnect.is_none() { - match self.display_change { - Some((candidate, at)) - if candidate == size && at.elapsed() >= Duration::from_secs(1) => - { - self.restart_capture = true; - self.reconnect = self.last_pc.clone(); - self.disconnect(); - } - Some((candidate, _)) if candidate == size => {} - _ => self.display_change = Some((size, Instant::now())), - } - } else { - self.display_change = None; + self.follow_display(ctx, display_request); } self.commit(); return; @@ -645,11 +854,16 @@ impl eframe::App for ClientApp { if let Step::Ended { .. } = &prog.step { if !self.ended_seen { self.ended_seen = true; - if self.cfg.sleep_prompt { + // A reconnect that failed before its stream came back: the + // window leaves full screen as the stream's end would have. + if let Some(on) = self.fullscreen_after.take() { + self.set_fullscreen(ctx, on); + } + if self.cfg.sleep_prompt && !self.power_sent.load(Ordering::Relaxed) { self.offer_sleep = disc .pcs .iter() - .find(|p| p.name == prog.pc && p.power_allowed()) + .find(|p| p.node_id == prog.node_id && p.can_sleep()) .cloned(); } } @@ -812,27 +1026,68 @@ impl ClientApp { } fn confirm_banner(&mut self, ui: &mut egui::Ui, disc: &Discovery) { - let Some((name, action)) = self.confirm.clone() else { + let Some(q) = self.confirm.clone() else { return; }; - let pc = disc.pcs.iter().find(|p| p.name == name).cloned(); - ui::banner( - ui, - Tone::Danger, - &format!("{} {name}?", action.label()), - Some("Programs there close without asking, and anything unsaved is lost."), - |ui| { - if ui::destructive_button(ui, action.label()).clicked() { - if let Some(ip) = pc.as_ref().and_then(|p| p.ip) { - self.power(ip, &name, action); - } - self.confirm = None; - } - if ui::ghost_button(ui, "Cancel").clicked() { - self.confirm = None; + let name = &q.name; + let (title, body, button) = match q.action { + Confirmable::Power(action) => ( + format!("{} {name}?", action.label()), + match action { + PowerAction::Sleep => "Asleep, it can only be woken from its own network, or through a router that forwards wake packets to it.".to_string(), + _ => "Programs there close without asking, and anything unsaved is lost.".to_string(), + }, + action.label(), + ), + Confirmable::Forget => ( + format!("Forget {name}?"), + forget_question(disc, &q.node_id, name), + "Forget", + ), + }; + ui::banner(ui, Tone::Danger, &title, Some(&body), |ui| { + if ui::destructive_button(ui, button).clicked() { + match q.action { + Confirmable::Power(action) => match power_target(disc, &q.node_id) { + Some(ip) => self.power(ip, name, action), + None => { + self.notice = Some(( + Tone::Danger, + format!("Nothing was sent: {name} is offline, or no longer takes power actions from here."), + Instant::now(), + )) + } + }, + Confirmable::Forget => self.forget(&q.node_id, name), } - }, - ); + self.confirm = None; + } + if ui::ghost_button(ui, "Cancel").clicked() { + self.confirm = None; + } + }); + } + + /// Remove what Latch saved about a machine, its pairing included. A + /// remembered row goes at once; a machine still on the tailnet stays + /// listed, unpaired. + fn forget(&mut self, node_id: &str, name: &str) { + let outcome = ClientConfig::update(|c| { + c.forget(node_id); + }); + self.cfg.forget(node_id); + self.discovery + .lock() + .pcs + .retain(|p| !(p.remembered && p.node_id == node_id)); + self.notice = Some(match outcome { + Ok(()) => (Tone::Neutral, format!("Forgot {name}."), Instant::now()), + Err(e) => ( + Tone::Danger, + format!("Couldn't forget {name}: {e}"), + Instant::now(), + ), + }); } fn machine_list( @@ -912,6 +1167,7 @@ impl ClientApp { let g = slot.lock(); let running = g.setup_running; let stopped = g.service_stopped; + let viewer_only = g.viewer_only; let status = g.status.clone(); drop(g); let noun = this_noun(status.as_ref().map(|s| s.os.as_str()).unwrap_or_default()); @@ -924,6 +1180,11 @@ impl ClientApp { .as_ref() .is_some_and(|s| s.streamer.running && s.streamer.api_ok); let (tone, detail) = match &status { + // Off on purpose, not a fault: Share this Mac is off. + None if viewer_only => ( + Tone::Neutral, + "Not shared · it only connects to your other machines".to_string(), + ), None if stopped => ( Tone::Neutral, "Not shared · the background service is stopped".to_string(), @@ -957,22 +1218,37 @@ impl ClientApp { ), }; ui::list_row(ui, Some(tone), &name, &detail, |ui| { - let label = if shared { "Manage" } else { "Set up sharing" }; + // Only ever the Sharing page: nothing starts from the list. + let label = if shared { + "Manage" + } else if viewer_only && status.is_none() { + "Share this Mac…" + } else { + "Set up sharing" + }; if ui::secondary_button(ui, label).clicked() { self.page = Page::Sharing; } }); } - /// The row's "…" menu: wake, power, copy the address. + /// The row's "…" menu: wake, power, copy the address, forget. fn row_menu(&mut self, ui: &mut egui::Ui, pc: &Pc) { let live = !pc.remembered; let wake = live && !pc.online && pc.can_wake(); let test_wake = live && pc.online && pc.host.is_some() && pc.can_wake(); let power = live && pc.online && pc.power_allowed(); - if !(wake || test_wake || power || pc.ip.is_some()) { + // Only a machine that is away: one that answers is re-learned + // within seconds, and forgetting it would only unpair it. + let forget = !pc.online && pc.known.is_some(); + if !(wake || test_wake || power || forget || pc.ip.is_some()) { return; } + let ask = |action| Confirm { + node_id: pc.node_id.clone(), + name: pc.name.clone(), + action, + }; ui::icon_menu(ui, Icon::More, &format!("More for {}", pc.name), |ui| { if wake && ui::menu_item(ui, "Wake").clicked() { self.notice = Some(match session::wake_only(pc) { @@ -994,16 +1270,18 @@ impl ClientApp { if wake || test_wake { ui::menu_separator(ui); } - if ui::menu_item(ui, "Sleep").clicked() { + // Only where something can wake it again: never a Linux + // VPS or a Mac, which would stay asleep. + if pc.can_sleep() && ui::menu_item(ui, "Sleep").clicked() { if let Some(ip) = pc.ip { self.power(ip, &pc.name, PowerAction::Sleep); } } if ui::menu_item(ui, "Restart…").clicked() { - self.confirm = Some((pc.name.clone(), PowerAction::Restart)); + self.confirm = Some(ask(Confirmable::Power(PowerAction::Restart))); } if ui::menu_item(ui, "Shut down…").clicked() { - self.confirm = Some((pc.name.clone(), PowerAction::Shutdown)); + self.confirm = Some(ask(Confirmable::Power(PowerAction::Shutdown))); } } if let Some(ip) = pc.ip { @@ -1019,6 +1297,14 @@ impl ClientApp { )); } } + if forget { + if wake || test_wake || power || pc.ip.is_some() { + ui::menu_separator(ui); + } + if ui::menu_item(ui, "Forget this machine…").clicked() { + self.confirm = Some(ask(Confirmable::Forget)); + } + } }); } @@ -1043,7 +1329,8 @@ impl ClientApp { /// Everything worth knowing about the paths and keys that is not /// urgent, folded away. Urgent key expiry is at the top of the page. fn details(&self, ui: &mut egui::Ui, disc: &Discovery) { - let mut items: Vec<(Tone, String)> = path_warnings(disc) + let release = self.updates.lock().release.clone(); + let mut items: Vec<(Tone, String)> = path_warnings(disc, release.as_ref()) .into_iter() .map(|t| (Tone::Warning, t)) .collect(); @@ -1096,10 +1383,12 @@ impl ClientApp { return; } } - let token = latch_core::update::token(self.cfg.github_token.as_deref()); + // The token is looked up on the handover's own thread: git's + // credential helper may wait on a Keychain prompt, and this thread + // draws the stream. self.handover = Some(Handover::start( release, - token, + self.cfg.github_token.clone(), mac_ip, ip, input, @@ -1305,7 +1594,7 @@ impl ClientApp { ui, &mut self.cfg.cmd_is_ctrl, "Command acts as Ctrl", - Some("So Command-C, V and Z copy, paste and undo on Windows. Off, Command is the Windows key."), + Some("So Command-C, V and Z copy, paste and undo on Windows and Linux. Off, Command is the Windows key. A Mac always gets Command."), ) { self.dirty = true; } @@ -1372,7 +1661,7 @@ impl ClientApp { ui, &mut self.cfg.auto_update, "Keep Latch up to date", - Some("Checks GitHub every few hours, installs new versions of this app, and sends updates to your other machines over Tailscale."), + Some("Checks GitHub every few hours, installs new versions of this app, and sends updates to your Windows PCs over Tailscale."), ) { self.dirty = true; } @@ -1727,8 +2016,9 @@ fn host_audio_problem(discovery: &Mutex, node_id: &str) -> Option Vec { +/// send, runs a host too old to update itself, or runs one this Mac cannot +/// update that is behind `release`. +fn path_warnings(disc: &Discovery, release: Option<&latch_core::update::Release>) -> Vec { let mut out = Vec::new(); for pc in disc.pcs.iter().filter(|p| !p.remembered && p.online) { let nat = pc.host.as_ref().and_then(|h| h.nat.as_ref()); @@ -1752,6 +2042,11 @@ fn path_warnings(disc: &Discovery) -> Vec { if !latch_core::update::host_can_receive_update(&v) { out.push(update::old_host_message(&pc.name, &v)); } + if let Some(t) = + release.and_then(|rel| update::manual_update_message(&pc.name, &h.os, &v, rel)) + { + out.push(t); + } } } } @@ -1887,7 +2182,7 @@ mod tests { pcs: vec![gaming_pc.clone()], ..Default::default() }; - let w = path_warnings(&disc); + let w = path_warnings(&disc, None); assert_eq!(w.len(), 4, "{w:?}"); assert!(w[0].contains("Tokyo relay"), "{}", w[0]); assert!(w[1].contains("software"), "{}", w[1]); @@ -1911,7 +2206,7 @@ mod tests { pcs: vec![gaming_pc.clone()], ..Default::default() }; - assert!(path_warnings(&disc).is_empty()); + assert!(path_warnings(&disc, None).is_empty()); // Nothing is said about a PC that is off. gaming_pc.online = false; gaming_pc.path.direct = Some(false); @@ -1919,7 +2214,115 @@ mod tests { pcs: vec![gaming_pc], ..Default::default() }; - assert!(path_warnings(&disc).is_empty()); + assert!(path_warnings(&disc, None).is_empty()); + } + + fn power_pc(id: &str, os: &str, ip: &str, online: bool) -> Pc { + Pc { + node_id: id.into(), + name: "latch-node".into(), + os: os.into(), + ip: Some(ip.parse().unwrap()), + online, + host: online.then(|| latch_core::api::Status { + power_allowed: true, + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn a_power_action_goes_to_the_machine_asked_for_never_its_namesake() { + let disc = Discovery { + pcs: vec![ + power_pc("old", "windows", "100.64.0.3", false), + power_pc("a", "windows", "100.64.0.1", true), + power_pc("b", "linux", "100.64.0.2", true), + ], + ..Default::default() + }; + let ip = |s: &str| Some(s.parse().unwrap()); + assert_eq!(power_target(&disc, "a"), ip("100.64.0.1")); + assert_eq!(power_target(&disc, "b"), ip("100.64.0.2")); + assert_eq!( + power_target(&disc, "old"), + None, + "asleep, so nothing to send to" + ); + assert_eq!(power_target(&disc, "gone"), None); + let mut refused = disc.clone(); + refused.pcs[1].host.as_mut().unwrap().power_allowed = false; + assert_eq!(power_target(&refused, "a"), None, "its owner said no"); + } + + #[test] + fn sleep_is_offered_only_where_a_wake_packet_brings_it_back() { + assert!(power_pc("a", "windows", "100.64.0.1", true).can_sleep()); + for os in ["linux", "macOS", ""] { + assert!(!power_pc("a", os, "100.64.0.1", true).can_sleep(), "{os:?}"); + } + let mut pc = power_pc("a", "windows", "100.64.0.1", true); + pc.host.as_mut().unwrap().power_allowed = false; + assert!(!pc.can_sleep()); + } + + #[test] + fn a_power_action_ends_only_the_connection_to_its_own_machine() { + let (gaming, den) = ( + "100.64.0.10".parse().unwrap(), + "100.64.0.31".parse().unwrap(), + ); + let mut p = Progress { + step: Step::Waking, + ip: Some(gaming), + ..Default::default() + }; + assert!(targets(None, &p, gaming), "the machine being woken"); + assert!( + !targets(None, &p, den), + "another machine's sleep leaves it be" + ); + assert!(targets(Some(den), &p, den), "the machine streaming"); + p.step = Step::Ended { error: None }; + assert!(!targets(None, &p, gaming), "nothing under way any more"); + } + + #[test] + fn a_reconnect_keeps_full_screen_and_the_end_restores_the_window() { + let mut after = Some(false); + assert_eq!(fullscreen_when_finished(true, &mut after), None); + assert_eq!(after, Some(false), "kept for the stream that follows"); + assert_eq!(fullscreen_when_finished(false, &mut after), Some(false)); + assert_eq!(after, None); + assert_eq!( + fullscreen_when_finished(false, &mut after), + None, + "a stream that never showed changes nothing" + ); + } + + #[test] + fn a_host_this_mac_cannot_update_is_named_once_a_release_is_out() { + let mut stuzha = power_pc("s", "linux", "100.64.0.5", true); + stuzha.name = "stuzha".into(); + let h = stuzha.host.as_mut().unwrap(); + h.version = "4.1.1".into(); + h.os = "linux".into(); + let disc = Discovery { + pcs: vec![stuzha], + ..Default::default() + }; + let rel = latch_core::update::Release { + version: Version::new(4, 1, 2), + tag: "v4.1.2".into(), + prerelease: false, + assets: vec![], + }; + assert!(path_warnings(&disc, None).is_empty()); + let w = path_warnings(&disc, Some(&rel)); + assert_eq!(w.len(), 1, "{w:?}"); + assert!(w[0].contains("stuzha runs Latch Host 4.1.1"), "{}", w[0]); } fn a_relay(online: bool) -> Relay { @@ -2154,6 +2557,7 @@ pub(crate) mod snapshots { api_ok: true, encoder: "nvenc".into(), audio_problem: String::new(), + ..Default::default() }, ..Default::default() } @@ -2397,6 +2801,11 @@ pub(crate) mod snapshots { }); self } + + fn local_share(mut self, share: share::LocalShare) -> Self { + self.local = Some(share); + self + } } pub fn build( @@ -2534,9 +2943,39 @@ pub(crate) mod snapshots { Setup::new(pcs()).step( "Gaming-PC", Step::Pairing { pin: "4821".into() }, - "Latch on that machine isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, then try again.", + "Latch on Gaming-PC isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, or type this PIN into the streaming engine's web page on that machine.", + ), + ), + ( + "pairing-refused-for-now", + Setup::new(pcs()).step( + "Gaming-PC", + Step::Pairing { pin: "4821".into() }, + "Latch on Gaming-PC couldn't enter the PIN yet: the streaming engine did not accept the PIN (is the Mac pairing right now?)", ), ), + ( + "pairing-refused", + failed("Gaming-PC", "Latch on Gaming-PC refused the PIN: not a machine on this PC's Tailscale account."), + ), + ( + "confirm-forget", + Setup::new(remembered()).tweak(|app| { + app.confirm = Some(Confirm { + node_id: "n2".into(), + name: "Office".into(), + action: Confirmable::Forget, + }) + }), + ), + ( + "lobby-mac-not-shared", + Setup::new(pcs()).local_share(share::LocalShare { + service_stopped: true, + viewer_only: true, + ..Default::default() + }), + ), ( "failed", failed("Office", "Office didn't wake up. A wake packet reaches it only from its own network, or through a router that forwards UDP port 9 to it. Its Sharing tab in Latch shows whether Wake-on-LAN is ready."), @@ -2558,7 +2997,11 @@ pub(crate) mod snapshots { ( "confirm-restart", Setup::new(pcs()).tweak(|app| { - app.confirm = Some(("Gaming-PC".into(), PowerAction::Restart)) + app.confirm = Some(Confirm { + node_id: "n1".into(), + name: "Gaming-PC".into(), + action: Confirmable::Power(PowerAction::Restart), + }) }), ), ( @@ -2658,7 +3101,11 @@ pub(crate) mod snapshots { ); assert_eq!( h.state().confirm, - Some(("Gaming-PC".into(), PowerAction::Restart)) + Some(Confirm { + node_id: "n1".into(), + name: "Gaming-PC".into(), + action: Confirmable::Power(PowerAction::Restart), + }) ); assert!( h.query_by_label("Restart").is_some(), @@ -2666,6 +3113,114 @@ pub(crate) mod snapshots { ); } + #[test] + fn a_linux_machine_is_never_offered_sleep() { + let mut h = build(Setup::new(many()), TYPICAL, 1.0, false); + h.get_by_label("More for vps-sanjose").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Restart…").is_some()); + assert!(h.query_by_label("Sleep").is_none(), "nothing could wake it"); + } + + #[test] + fn a_machine_that_is_away_can_be_forgotten_after_a_question() { + // Asleep but still on the tailnet: offered, with what it costs. + let mut h = build(Setup::new(pcs()), TYPICAL, 1.0, false); + h.get_by_label("More for Office").simulate_click(); + h.run_steps(2); + h.get_by_label("Forget this machine…").simulate_click(); + h.run_steps(2); + assert_eq!( + h.state().confirm, + Some(Confirm { + node_id: "n2".into(), + name: "Office".into(), + action: Confirmable::Forget, + }) + ); + assert!(h.query_by_label("Forget Office?").is_some()); + assert!(h + .query_by_label_contains("stays in this list while it is on your Tailscale network") + .is_some()); + // A machine that answers is not offered it: it would only unpair. + let mut h = build(Setup::new(pcs()), TYPICAL, 1.0, false); + h.get_by_label("More for Gaming-PC").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Forget this machine…").is_none()); + // Remembered while Tailscale is off: offered too. + let mut h = build(Setup::new(remembered()), TYPICAL, 1.0, false); + h.get_by_label("More for Office").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Forget this machine…").is_some()); + } + + #[test] + fn a_mac_with_sharing_off_is_not_reported_as_a_stopped_service() { + let h = build( + Setup::new(pcs()).local_share(share::LocalShare { + service_stopped: true, + viewer_only: true, + ..Default::default() + }), + MIN, + 1.0, + false, + ); + assert!(h.query_by_label_contains("service is stopped").is_none()); + assert!(h + .query_by_label_contains("only connects to your other machines") + .is_some()); + assert!(h.query_by_label("Share this Mac…").is_some()); + // Someone pressed Stop: that is what it says. + let h = build( + Setup::new(pcs()).local_share(share::LocalShare { + service_stopped: true, + ..Default::default() + }), + MIN, + 1.0, + false, + ); + assert!(h.query_by_label_contains("service is stopped").is_some()); + } + + #[test] + fn full_screen_is_what_the_system_says_it_is() { + let mut h = build(Setup::new(pcs()), MIN, 1.0, false); + assert!(!h.state().fullscreen); + let root = |h: &mut egui_kittest::Harness<'_, ClientApp>, on| { + h.input_mut() + .viewports + .entry(egui::ViewportId::ROOT) + .or_default() + .fullscreen = Some(on); + h.run_steps(1); + }; + root(&mut h, true); + assert!(h.state().fullscreen, "the green button entered it"); + root(&mut h, false); + assert!(!h.state().fullscreen, "and left it"); + } + + #[test] + fn a_reconnect_that_fails_before_its_stream_leaves_full_screen() { + let setup = Setup::new(pcs()) + .step( + "Gaming-PC", + Step::Ended { + error: Some("timed out".into()), + }, + "", + ) + .tweak(|app| { + app.ended_seen = false; + app.fullscreen = true; + app.fullscreen_after = Some(false); + }); + let h = build(setup, MIN, 1.0, false); + assert_eq!(h.state().fullscreen_after, None, "the window went back"); + } + #[test] fn a_notices_quiet_action_lines_up_with_its_text() { for state in ["failed", "confirm-restart"] { diff --git a/crates/client/src/clipboard.rs b/crates/client/src/clipboard.rs index 264581c..bd45b40 100644 --- a/crates/client/src/clipboard.rs +++ b/crates/client/src/clipboard.rs @@ -6,14 +6,21 @@ //! the PC never reached the Mac. Now a worker polls the PC's clipboard //! every second or so and hands new text to the window, which puts it in //! the Mac's clipboard; and a paste first sends the Mac's text to the PC, -//! then presses Ctrl+V there. Hosts before 3.1 have no such route; the -//! worker notices the 404 and says so once. +//! unless the PC has copied something newer since, then the window presses +//! Ctrl+V there, with anything typed meanwhile held back behind it. Hosts +//! before 3.1 have no such route; the worker notices the 404 and says so +//! once. +//! +//! A Linux desktop is sent the X11 shortcuts instead (see [`Shortcuts`]): +//! in its terminals Ctrl+C interrupts the running program and Ctrl+V types +//! a literal, so ⌘C and ⌘V did harm there instead of copying. +use crate::input::{VK_C, VK_CONTROL, VK_DELETE, VK_INSERT, VK_SHIFT, VK_V, VK_X}; use latch_core::api::{Ack, Clipboard, CLIPBOARD_PATH}; use latch_core::{http, CONTROL_PORT}; use parking_lot::Mutex; use std::net::Ipv4Addr; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -21,29 +28,112 @@ const POLL: Duration = Duration::from_millis(1500); /// After a ⌘C on the PC, Windows needs a moment before the clipboard has /// the new text. const POKE_DELAY: Duration = Duration::from_millis(400); +/// How long a paste right after a ⌘C waits for the PC's new clipboard to +/// be read, before sending the Mac's text anyway. +const POKE_WAIT: Duration = Duration::from_millis(1200); const TIMEOUT: Duration = Duration::from_secs(5); +/// A clipboard shortcut pressed on the Mac and replayed on the PC. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edit { + Copy, + Cut, + Paste, +} + +/// How the PC spells copy, cut and paste. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Shortcuts { + /// What ⌘ stands for there (Ctrl, or the Windows key) with C, X or V: + /// Windows and macOS. + #[default] + Letters, + /// Ctrl+Insert, Shift+Delete and Shift+Insert: the X11 shortcuts GTK, + /// Qt, Firefox, Chromium, Electron and the terminals all honour. Ctrl+C + /// in a terminal interrupts the running program and Ctrl+V types a + /// literal. Shift+Insert pastes the selection in xterm and VTE + /// terminals, which is why Latch Host puts pasted text in both (and + /// why paste stays Ctrl+V with a host that does not say it does). + Insert, +} + +impl Shortcuts { + /// From Tailscale's OS string for the PC. + pub fn for_os(os: &str) -> Self { + if os.eq_ignore_ascii_case("linux") { + Shortcuts::Insert + } else { + Shortcuts::Letters + } + } + + /// The chord for `edit`, given what ⌘ stands for on the PC, and whether + /// it must arrive without other held modifiers: ⌘ held as Ctrl would + /// turn Shift+Insert into Ctrl+Shift+Insert. `selection` is whether the + /// host puts pasted text in the X selection; a Linux host that does not + /// (4.1.1 and older) keeps Ctrl+V for paste, since Shift+Insert in a + /// terminal would paste whatever was last selected there. + pub fn keys(self, edit: Edit, modifier: i16, selection: bool) -> (Vec, bool) { + match (self, edit) { + (Shortcuts::Insert, Edit::Paste) if !selection => (vec![modifier, VK_V], false), + (Shortcuts::Letters, Edit::Copy) => (vec![modifier, VK_C], false), + (Shortcuts::Letters, Edit::Cut) => (vec![modifier, VK_X], false), + (Shortcuts::Letters, Edit::Paste) => (vec![modifier, VK_V], false), + (Shortcuts::Insert, Edit::Copy) => (vec![VK_CONTROL, VK_INSERT], true), + (Shortcuts::Insert, Edit::Cut) => (vec![VK_SHIFT, VK_DELETE], true), + (Shortcuts::Insert, Edit::Paste) => (vec![VK_SHIFT, VK_INSERT], true), + } + } +} + #[derive(Default)] struct Shared { /// Text the PC copied that the window has not taken yet. incoming: Option, - /// The last text either side saw, so nothing echoes back and forth. + /// The last text both sides held, so nothing echoes back and forth. + /// Empty once the PC has copied something that is not text. last: String, last_seq: Option, /// The host answered 404: older than 3.1. unsupported: bool, + /// The host puts pasted text in the X selection too (`Clipboard::selection`). + selection: bool, /// Something the window can show about the last exchange. note: Option<(String, Instant)>, - pokes: u32, + /// ⌘C and ⌘X sent to the PC, and how many of them the poll had seen + /// when its last read finished: while these differ, a copy may be on + /// its way that a paste must not overwrite. + pokes: u64, + polled: u64, + /// The PC has copied something (text, an image, files) that the + /// window has not yet weighed against the Mac's clipboard. + pc_copied: bool, + /// The Mac clipboard's change count when the PC last copied something. + /// While the Mac's count still reads this, the PC's clipboard is the + /// newer of the two and a paste leaves it alone. + pc_newer_than: Option, + /// The newest paste whose text has reached the PC, or failed to. + ready: u64, + /// The newest paste the window stopped waiting for. + gave_up: u64, +} + +/// What a paste needs before the window presses its chord. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Paste { + /// Nothing: the PC already has the right clipboard. + Now, + /// The Mac's text is on its way; [`Sync::paste_ready`] says when. + Wait(u64), } pub struct Sync { ip: Ipv4Addr, shared: Arc>, stop: Arc, - /// Pastes whose chord has been sent, so the window can put a modifier - /// the user is still holding back down afterwards. - done: Arc, + /// Repainted when a paste's text has arrived. + ctx: egui::Context, + pastes: AtomicU64, } impl Sync { @@ -54,6 +144,7 @@ impl Sync { { let shared = shared.clone(); let stop = stop.clone(); + let ctx = ctx.clone(); std::thread::Builder::new() .name("clipboard".into()) .spawn(move || poll_loop(ip, shared, stop, ctx)) @@ -63,17 +154,11 @@ impl Sync { ip, shared, stop, - done: Arc::default(), + ctx, + pastes: AtomicU64::new(0), } } - /// How many pastes have had their chord sent. The chord releases the - /// modifier it pressed; if ⌘ is still physically down when this moves, - /// the window presses the modifier again. - pub fn pastes_done(&self) -> u32 { - self.done.load(Ordering::Acquire) - } - /// Text copied on the PC since the last call, once. pub fn take_incoming(&self) -> Option { self.shared.lock().incoming.take() @@ -83,6 +168,12 @@ impl Sync { self.shared.lock().unsupported } + /// Whether the host has said it puts pasted text in the X selection, + /// which is what makes Shift+Insert paste it in a terminal. + pub fn pastes_to_selection(&self) -> bool { + self.shared.lock().selection + } + /// A short line for the toolbar, while it is fresh. pub fn note(&self) -> Option { let sh = self.shared.lock(); @@ -98,44 +189,195 @@ impl Sync { self.shared.lock().pokes += 1; } - /// Put `text` in the PC's clipboard, then run `then` (the paste chord) - /// on the worker thread. If the PC already has this text, `then` runs - /// at once; if the host cannot take it, `then` still runs, so the PC - /// pastes whatever it has rather than nothing. - pub fn push(&self, text: String, then: impl FnOnce() + Send + 'static) { - let (same, unsupported) = { - let sh = self.shared.lock(); - (sh.last == text, sh.unsupported) - }; - let done = self.done.clone(); - if same || unsupported || text.is_empty() { - then(); - done.fetch_add(1, Ordering::AcqRel); - return; + /// Called every frame with a way to read the Mac clipboard's change + /// count: when the PC has copied something since, that count is what + /// the PC's copy is newer than. Read on the window's thread, after + /// text from the PC has been handed over but before the Mac has + /// written it, so that write counts as a change too (harmless: the + /// Mac then holds the text the PC has). + pub fn note_mac_clipboard(&self, count: impl FnOnce() -> Option) { + let mut sh = self.shared.lock(); + if std::mem::take(&mut sh.pc_copied) { + sh.pc_newer_than = count(); } - let shared = self.shared.clone(); - let ip = self.ip; + } + + /// Start a paste of `text`, the Mac's clipboard, whose change count is + /// `mac_count`. The PC is sent the text first unless it already has it, + /// or has copied something itself since the Mac last changed (an image + /// or files there must not be replaced by older text from here), or the + /// host cannot take it; then the PC pastes what it has. An empty + /// `text` (an image or nothing on the Mac) is never sent. + pub fn push(&self, text: String, mac_count: Option) -> Paste { + let fitted = Clipboard::fit(&text, 0); + let sh = self.shared.lock(); + if !sh.needs(&fitted, mac_count) { + return Paste::Now; + } + let id = self.pastes.fetch_add(1, Ordering::Relaxed) + 1; + // A ⌘C just sent may not have been read yet: wait for that read + // before deciding, or the Mac's older text would replace it. + let poke = (sh.polled != sh.pokes).then_some((sh.pokes, sh.last_seq)); + drop(sh); + let (shared, stop, ctx, ip) = ( + self.shared.clone(), + self.stop.clone(), + self.ctx.clone(), + self.ip, + ); std::thread::spawn(move || { - match send(ip, &text) { - Ok(()) => { - let mut sh = shared.lock(); - sh.last = text; - sh.note = Some(("Clipboard sent to the PC".into(), Instant::now())); + if let Some((pokes, seq)) = poke { + let until = Instant::now() + POKE_WAIT; + while shared.lock().polled < pokes + && Instant::now() < until + && !stop.load(Ordering::Relaxed) + { + std::thread::sleep(Duration::from_millis(50)); + } + if shared.lock().last_seq != seq { + // The PC copied something after all: it pastes that. + shared.lock().finish(id); + ctx.request_repaint(); + return; } - Err(Fail::Unsupported) => shared.lock().unsupported = true, - Err(Fail::Other(e)) => { - tracing::warn!("clipboard to PC: {e}"); - shared.lock().note = Some((format!("Clipboard not sent: {e}"), Instant::now())); + } + let before = shared.lock().sending(&fitted); + let result = send(ip, &fitted); + let mut sh = shared.lock(); + match result { + Ok(()) => sh.sent(&fitted, id), + Err(e) => { + sh.not_sent(&fitted, before); + match e { + Fail::Unsupported => sh.unsupported = true, + Fail::Other(e) => { + tracing::warn!("clipboard to PC: {e}"); + sh.note = Some((format!("Clipboard not sent: {e}"), Instant::now())); + } + } } } - then(); - done.fetch_add(1, Ordering::AcqRel); + sh.finish(id); + drop(sh); + ctx.request_repaint(); }); + Paste::Wait(id) + } + + /// Whether paste `id` is ready for its chord: its text has reached the + /// PC, or failed to. + pub fn paste_ready(&self, id: u64) -> bool { + self.shared.lock().ready >= id + } + + /// The window has stopped waiting for paste `id` and pressed its chord + /// anyway (or dropped it). + pub fn give_up(&self, id: u64) { + let mut sh = self.shared.lock(); + sh.gave_up = sh.gave_up.max(id); } pub fn stop(&self) { self.stop.store(true, Ordering::Relaxed); } + + /// Mark paste `id` as arrived, as its worker would. + #[cfg(test)] + pub(crate) fn finish_for_test(&self, id: u64) { + self.shared.lock().finish(id); + } +} + +impl Shared { + /// Whether a paste of `fitted` has to send it to the PC first. + fn needs(&self, fitted: &Clipboard, mac_count: Option) -> bool { + let pc_newer = mac_count.is_some() && self.pc_newer_than == mac_count; + !(fitted.text.is_empty() + || self.unsupported + || pc_newer + || same_text(&self.last, &fitted.text)) + } + + /// `fitted` is about to be written on the PC: count it as the last text + /// now, so the poll does not take the PC reading it back for a copy + /// made there. Returns what `last` was, for [`Shared::not_sent`]. + fn sending(&mut self, fitted: &Clipboard) -> String { + std::mem::replace(&mut self.last, fitted.text.clone()) + } + + fn sent(&mut self, fitted: &Clipboard, id: u64) { + self.pc_newer_than = None; + let text = if self.gave_up >= id { + "The clipboard reached the PC after the paste: paste again for it" + } else if fitted.truncated { + "Sent the first 32 KB of the clipboard to the PC" + } else { + "Clipboard sent to the PC" + }; + self.note = Some((text.into(), Instant::now())); + } + + fn not_sent(&mut self, fitted: &Clipboard, before: String) { + if self.last == fitted.text { + self.last = before; + } + } + + fn finish(&mut self, id: u64) { + self.ready = self.ready.max(id); + } + + /// Take in one read of the PC's clipboard. True when there is + /// something new for the window: text to put in the Mac's clipboard, + /// or a copy of something else to weigh against it. + fn observe(&mut self, c: Clipboard, primed: &mut bool) -> bool { + self.selection = c.selection; + if !*primed { + // The PC's existing clipboard is not a copy that happened + // during this stream. Seed last/seq so we do not overwrite + // whatever the Mac was holding. + *primed = true; + self.last_seq = Some(c.seq); + if !c.text.is_empty() { + self.last = c.text; + } + return false; + } + if self.last_seq == Some(c.seq) { + return false; + } + self.last_seq = Some(c.seq); + if !c.text.is_empty() && same_text(&c.text, &self.last) { + // Our own paste, read back. + return false; + } + self.pc_copied = true; + if c.text.is_empty() { + // An image or files: nothing to bring over, but what the PC + // holds is no longer the last text either side had. + self.last.clear(); + return true; + } + self.last = c.text.clone(); + self.incoming = Some(c.text); + self.note = Some(( + if c.truncated { + "Copied from the PC (cut at 32 KB)".to_string() + } else { + "Copied from the PC".to_string() + }, + Instant::now(), + )); + true + } +} + +/// The same text, whatever its line endings: a Windows PC reads back +/// what it was sent with CRLF turned into LF. +fn same_text(a: &str, b: &str) -> bool { + a == b + || ((a.contains('\r') || b.contains('\r')) + && a.replace("\r\n", "\n") == b.replace("\r\n", "\n")) } enum Fail { @@ -143,9 +385,8 @@ enum Fail { Other(String), } -fn send(ip: Ipv4Addr, text: &str) -> Result<(), Fail> { - let body = - serde_json::to_string(&Clipboard::fit(text, 0)).map_err(|e| Fail::Other(e.to_string()))?; +fn send(ip: Ipv4Addr, fitted: &Clipboard) -> Result<(), Fail> { + let body = serde_json::to_string(fitted).map_err(|e| Fail::Other(e.to_string()))?; let r = http::request( (ip, CONTROL_PORT), "POST", @@ -180,7 +421,7 @@ fn fetch(ip: Ipv4Addr) -> Result { fn poll_loop(ip: Ipv4Addr, shared: Arc>, stop: Arc, ctx: egui::Context) { let mut failures = 0u32; - let mut pokes_seen = 0u32; + let mut pokes_seen = 0u64; let mut primed = false; while !stop.load(Ordering::Relaxed) { // Sleep in small steps so a poke or a stop is noticed quickly. @@ -197,37 +438,13 @@ fn poll_loop(ip: Ipv4Addr, shared: Arc>, stop: Arc, ct break; } pokes_seen = shared.lock().pokes; - match fetch(ip) { + let result = fetch(ip); + shared.lock().polled = pokes_seen; + match result { Ok(c) => { failures = 0; - let mut sh = shared.lock(); - if !primed { - // The PC's existing clipboard is not a copy that happened - // during this stream. Seed last/seq so we do not overwrite - // whatever the Mac was holding. - primed = true; - sh.last_seq = Some(c.seq); - if !c.text.is_empty() { - sh.last = c.text; - } - continue; - } - if sh.last_seq != Some(c.seq) { - sh.last_seq = Some(c.seq); - if !c.text.is_empty() && c.text != sh.last { - sh.last = c.text.clone(); - sh.incoming = Some(c.text); - sh.note = Some(( - if c.truncated { - "Copied from the PC (cut at 32 KB)".to_string() - } else { - "Copied from the PC".to_string() - }, - Instant::now(), - )); - drop(sh); - ctx.request_repaint(); - } + if shared.lock().observe(c, &mut primed) { + ctx.request_repaint(); } } Err(Fail::Unsupported) => { @@ -247,8 +464,11 @@ fn poll_loop(ip: Ipv4Addr, shared: Arc>, stop: Arc, ct } } -/// Whether the window should offer a paste at all: `Event::Paste` carries -/// text only when the Mac's clipboard holds some. +/// Whether a paste's text is worth sending. `Event::Paste` carries text +/// only when the Mac's clipboard holds some; a ⌘V with an image or +/// nothing there is seen by its V key's release instead (see the stream +/// view) and pastes with empty text, which is never sent: the PC pastes +/// what it has. pub fn worth_sending(text: &str) -> bool { !text.is_empty() } @@ -258,20 +478,75 @@ mod tests { use super::*; #[test] - fn a_push_of_the_same_text_runs_the_paste_at_once() { - let s = Sync { + fn a_linux_pc_gets_the_insert_shortcuts_and_the_rest_keep_the_letters() { + use crate::input::VK_LWIN; + assert_eq!(Shortcuts::for_os("linux"), Shortcuts::Insert); + assert_eq!(Shortcuts::for_os("windows"), Shortcuts::Letters); + assert_eq!(Shortcuts::for_os("macOS"), Shortcuts::Letters); + assert_eq!(Shortcuts::for_os(""), Shortcuts::Letters); + let linux = Shortcuts::Insert; + assert_eq!( + linux.keys(Edit::Copy, VK_LWIN, true), + (vec![VK_CONTROL, VK_INSERT], true) + ); + assert_eq!( + linux.keys(Edit::Cut, VK_CONTROL, true), + (vec![VK_SHIFT, VK_DELETE], true) + ); + assert_eq!( + linux.keys(Edit::Paste, VK_CONTROL, true), + (vec![VK_SHIFT, VK_INSERT], true) + ); + // A host that leaves the selection alone: Shift+Insert would paste + // the terminal's last selection, so paste stays Ctrl+V. Copy and + // cut need nothing from the host. + assert_eq!( + linux.keys(Edit::Paste, VK_CONTROL, false), + (vec![VK_CONTROL, VK_V], false) + ); + assert_eq!( + linux.keys(Edit::Copy, VK_CONTROL, false), + (vec![VK_CONTROL, VK_INSERT], true) + ); + let windows = Shortcuts::Letters; + assert_eq!( + windows.keys(Edit::Copy, VK_CONTROL, true), + (vec![VK_CONTROL, VK_C], false) + ); + assert_eq!( + windows.keys(Edit::Paste, VK_LWIN, false), + (vec![VK_LWIN, VK_V], false) + ); + } + + fn sync() -> Sync { + Sync { ip: Ipv4Addr::LOCALHOST, shared: Arc::default(), stop: Arc::new(AtomicBool::new(true)), - done: Arc::default(), - }; + ctx: egui::Context::default(), + pastes: AtomicU64::new(0), + } + } + + fn pc(text: &str, seq: u64) -> Clipboard { + Clipboard { + text: text.into(), + seq, + ..Default::default() + } + } + + #[test] + fn a_paste_the_pc_already_has_is_pressed_at_once() { + let s = sync(); s.shared.lock().last = "hello".into(); - let ran = Arc::new(AtomicBool::new(false)); - let r = ran.clone(); - assert_eq!(s.pastes_done(), 0); - s.push("hello".into(), move || r.store(true, Ordering::Relaxed)); - assert!(ran.load(Ordering::Relaxed)); - assert_eq!(s.pastes_done(), 1); + assert_eq!(s.push("hello".into(), None), Paste::Now); + assert_eq!( + s.push(String::new(), None), + Paste::Now, + "an image on the Mac" + ); assert!(worth_sending("x")); assert!(worth_sending(" \n"), "whitespace is valid clipboard text"); assert!(!worth_sending("")); @@ -279,5 +554,109 @@ mod tests { s.shared.lock().incoming = Some("from pc".into()); assert_eq!(s.take_incoming().as_deref(), Some("from pc")); assert!(s.take_incoming().is_none()); + // Paste ids count up; a paste is ready once its id is reached. + s.shared.lock().finish(2); + assert!(s.paste_ready(1) && s.paste_ready(2) && !s.paste_ready(3)); + } + + #[test] + fn files_or_an_image_copied_on_the_pc_are_pasted_there_while_the_mac_is_unchanged() { + let mut sh = Shared::default(); + let mut primed = false; + assert!(!sh.observe(pc("old pc text", 1), &mut primed)); + // The PC copies files: no text, a new sequence number. + assert!(sh.observe(pc("", 2), &mut primed)); + assert!(sh.incoming.is_none()); + assert!(sh.pc_copied); + // The window weighs it against the Mac clipboard's change count. + let s = sync(); + *s.shared.lock() = sh; + s.note_mac_clipboard(|| Some(41)); + assert!(!s.shared.lock().pc_copied); + let mac = Clipboard::fit("mac text", 0); + let sh = s.shared.lock(); + assert!( + !sh.needs(&mac, Some(41)), + "the Mac has not changed: paste the files" + ); + assert!( + sh.needs(&mac, Some(42)), + "the Mac copied since: send its text" + ); + assert!(sh.needs(&mac, None), "no change count: as before"); + drop(sh); + // Copying the old text again after the files is a copy too. + let mut sh = s.shared.lock(); + assert!(sh.observe(pc("old pc text", 3), &mut primed)); + assert_eq!(sh.incoming.as_deref(), Some("old pc text")); + } + + #[test] + fn text_copied_on_the_pc_comes_over_once() { + let mut sh = Shared::default(); + let mut primed = false; + sh.observe(pc("", 1), &mut primed); + assert!(sh.observe(pc("copied there", 2), &mut primed)); + assert_eq!(sh.incoming.take().as_deref(), Some("copied there")); + assert_eq!(sh.last, "copied there"); + assert!(!sh.observe(pc("copied there", 2), &mut primed), "same seq"); + // The Mac now holds that text: a paste of it sends nothing. + assert!(!sh.needs(&Clipboard::fit("copied there", 0), None)); + } + + #[test] + fn the_pc_reading_back_a_paste_is_not_a_copy() { + let mut sh = Shared::default(); + let mut primed = false; + sh.observe(pc("before", 1), &mut primed); + let fitted = Clipboard::fit("from the mac", 0); + let before = sh.sending(&fitted); + assert_eq!(before, "before"); + sh.sent(&fitted, 1); + assert!(!sh.observe(pc("from the mac", 2), &mut primed)); + assert!(sh.incoming.is_none() && !sh.pc_copied); + // A Windows PC reads CRLF back as LF. + let crlf = Clipboard::fit("a\r\nb", 0); + sh.sending(&crlf); + sh.sent(&crlf, 2); + assert!(!sh.observe(pc("a\nb", 3), &mut primed)); + assert!(sh.incoming.is_none()); + assert!(!sh.needs(&Clipboard::fit("a\nb", 0), None)); + // A send that failed puts the old text back, so the next paste of + // the same text tries again. + let fitted = Clipboard::fit("new", 0); + let before = sh.sending(&fitted); + sh.not_sent(&fitted, before); + assert_eq!(sh.last, "a\r\nb"); + assert!(sh.needs(&fitted, None)); + } + + #[test] + fn a_long_paste_is_cut_and_the_cut_copy_never_comes_back() { + let long = "x".repeat(latch_core::api::CLIPBOARD_MAX_BYTES + 100); + let fitted = Clipboard::fit(&long, 0); + assert!(fitted.truncated); + let mut sh = Shared::default(); + let mut primed = false; + sh.observe(pc("", 1), &mut primed); + sh.sending(&fitted); + sh.sent(&fitted, 1); + assert!(sh.note.as_ref().unwrap().0.contains("first 32 KB")); + // The PC reads back the cut text under a new number. + assert!(!sh.observe(pc(&fitted.text, 2), &mut primed)); + assert!(sh.incoming.is_none(), "the Mac keeps its whole text"); + // Pasting the same long text again sends nothing new. + assert!(!sh.needs(&Clipboard::fit(&long, 0), None)); + } + + #[test] + fn a_paste_the_window_gave_up_on_says_so_when_it_lands() { + let s = sync(); + s.give_up(3); + let mut sh = s.shared.lock(); + sh.sent(&Clipboard::fit("x", 0), 3); + assert!(sh.note.as_ref().unwrap().0.contains("paste again")); + sh.sent(&Clipboard::fit("x", 0), 4); + assert_eq!(sh.note.as_ref().unwrap().0, "Clipboard sent to the PC"); } } diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 8c781f0..ae62034 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -8,6 +8,9 @@ pub const FILE: &str = "client.toml"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Resolution { + P540, + P720, + P900, P1080, P1440, P2160, @@ -16,12 +19,26 @@ pub enum Resolution { } impl Resolution { + /// Every choice, in the order the list shows them. + pub const ALL: [Resolution; 7] = [ + Resolution::Native, + Resolution::P540, + Resolution::P720, + Resolution::P900, + Resolution::P1080, + Resolution::P1440, + Resolution::P2160, + ]; + /// This choice applied to a screen of `native` pixels: the standard /// 16:9 sizes are exactly that, and "Match screen" is the screen /// itself. See `latch_core::screens`. pub fn pixels(self, native: (u32, u32)) -> (u32, u32) { - let [p1080, p1440, p2160] = latch_core::screens::STANDARD; + let [p540, p720, p900, p1080, p1440, p2160] = latch_core::screens::STANDARD; match self { + Resolution::P540 => p540, + Resolution::P720 => p720, + Resolution::P900 => p900, Resolution::P1080 => p1080, Resolution::P1440 => p1440, Resolution::P2160 => p2160, @@ -92,6 +109,9 @@ impl Preset { impl Resolution { pub fn label(self) -> &'static str { match self { + Resolution::P540 => "540p", + Resolution::P720 => "720p", + Resolution::P900 => "900p", Resolution::P1080 => "1080p", Resolution::P1440 => "1440p", Resolution::P2160 => "4K", @@ -175,6 +195,12 @@ pub struct KnownPc { pub tailscale_ip: Option, /// When the PC was last seen online (Unix seconds). pub last_seen_unix: Option, + /// The size its Desktop was last launched at from this machine, so the + /// first connect after Latch reopens can rejoin it at that size. + pub desktop_size: Option<[u32; 2]>, + /// The tailnet it was seen on, so a machine is only ever forgotten for + /// leaving the tailnet this machine is on now. + pub tailnet: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -259,6 +285,13 @@ impl ClientConfig { latch_core::config::update(FILE, edit) } + /// Forget a machine: its addresses, how to wake it, and its pairing. + /// One still on the tailnet is listed again, unpaired and unwakeable + /// until it has been seen online. + pub fn forget(&mut self, node_id: &str) -> bool { + self.pcs.remove(node_id).is_some() + } + pub fn forget_pin_on_mismatch(&mut self, node_id: &str, err: &anyhow::Error) -> bool { if !latch_stream::nvhttp::is_pin_mismatch(err) { return false; @@ -308,6 +341,9 @@ mod tests { assert_eq!(Resolution::P1080.pixels((3024, 1964)), (1920, 1080)); assert_eq!(Resolution::P1440.pixels((3024, 1964)), (2560, 1440)); assert_eq!(Resolution::P2160.pixels((1920, 1080)), (3840, 2160)); + assert_eq!(Resolution::P900.pixels((3024, 1964)), (1600, 900)); + assert_eq!(Resolution::P720.pixels((3024, 1964)), (1280, 720)); + assert_eq!(Resolution::P540.pixels((3024, 1964)), (960, 540)); assert_eq!( Resolution::Native.describe((3024, 1964)), "Match screen · 3024 × 1964" @@ -316,13 +352,9 @@ mod tests { Resolution::P1080.describe((3024, 1964)), "1080p · 1920 × 1080" ); + assert_eq!(Resolution::P720.describe((3024, 1964)), "720p · 1280 × 720"); let modes = latch_core::screens::stream_modes(); - for r in [ - Resolution::P1080, - Resolution::P1440, - Resolution::P2160, - Resolution::Native, - ] { + for r in Resolution::ALL { for &screen in latch_core::screens::SCREENS { assert!( modes.contains(&r.pixels(screen)), @@ -339,6 +371,8 @@ mod tests { let c: ClientConfig = toml::from_str("[stream]\nfps = 90\n").unwrap(); assert_eq!(c.stream.quality, Quality::Auto); assert_eq!(c.stream.fps, 90); + let c: ClientConfig = toml::from_str("[stream]\nresolution = \"P720\"\n").unwrap(); + assert_eq!(c.stream.resolution, Resolution::P720); let mut st = StreamSettings::default(); assert_eq!(st.preset(), Some(Preset::Balanced)); st.apply_preset(Preset::Smooth); @@ -364,6 +398,36 @@ mod tests { assert_eq!(back, c); } + /// A build without 720p (4.1.1, or a branch cut before it) reading a + /// file this one wrote: the size goes back to its default, and the + /// machines, their certificates and wake addresses all stay. + #[test] + fn a_size_this_build_does_not_know_keeps_the_machines() { + let text = "github_token = \"t\"\n\ + [stream]\nquality = \"Custom\"\nresolution = \"P9999\"\nfps = 90\ncodec = \"Hevc\"\n\ + [pcs.n1]\nname = \"Hermes\"\nmac = \"02:00:00:00:00:01\"\n\ + public_ip = \"116.125.113.36\"\nserver_cert = \"3082\"\n\ + [pcs.n2]\nname = \"stuzha\"\nlast_seen_unix = \"bad\"\n"; + let (c, dropped): (ClientConfig, _) = latch_core::config::parse_lenient(text).unwrap(); + assert_eq!( + dropped, + ["pcs.n2.last_seen_unix", "stream.resolution"], + "only what cannot be read" + ); + assert_eq!(c.stream.resolution, Resolution::Native); + assert_eq!(c.stream.quality, Quality::Custom); + assert_eq!(c.stream.fps, 90); + assert_eq!(c.stream.codec, Codec::Hevc); + assert_eq!(c.github_token.as_deref(), Some("t")); + assert_eq!(c.pcs["n1"].server_cert.as_deref(), Some("3082")); + assert_eq!(c.pcs["n1"].mac.as_deref(), Some("02:00:00:00:00:01")); + assert_eq!(c.pcs["n1"].public_ip.as_deref(), Some("116.125.113.36")); + assert_eq!(c.pcs["n2"].name, "stuzha"); + assert_eq!(c.pcs["n2"].last_seen_unix, None); + // Text that is not TOML at all is still an error (and set aside). + assert!(latch_core::config::parse_lenient::("[stream").is_err()); + } + #[test] fn every_preset_survives_a_reload() { for p in Preset::ALL { @@ -408,4 +472,29 @@ mod tests { assert!(!c.forget_pin_on_mismatch("n", &generic)); assert_eq!(c.pcs["n"].server_cert.as_deref(), Some("3082")); } + + #[test] + fn a_forgotten_machine_takes_its_pairing_and_nothing_else() { + let mut c = ClientConfig::default(); + for id in ["artemis", "stuzha"] { + c.pcs.insert( + id.into(), + KnownPc { + name: id.into(), + server_cert: Some("3082".into()), + desktop_size: Some([1280, 720]), + ..Default::default() + }, + ); + } + assert!(c.forget("artemis")); + assert!(!c.forget("artemis"), "gone already"); + assert_eq!(c.pcs.keys().collect::>(), ["stuzha"]); + assert_eq!(c.pcs["stuzha"].server_cert.as_deref(), Some("3082")); + // A file without the newer fields still reads. + let back: ClientConfig = + toml::from_str("[pcs.n]\nname = \"Gaming-PC\"\nserver_cert = \"3082\"\n").unwrap(); + assert_eq!(back.pcs["n"].desktop_size, None); + assert_eq!(back.pcs["n"].tailnet, None); + } } diff --git a/crates/client/src/display.rs b/crates/client/src/display.rs index a7396fb..9571c69 100644 --- a/crates/client/src/display.rs +++ b/crates/client/src/display.rs @@ -33,6 +33,33 @@ pub struct Help { pub busy: bool, } +/// Whether a stream's video problem is the black picture, the one problem +/// the PC can be asked about. Video that never came, stopped coming or +/// cannot be decoded is the network's doing or this Mac's. +pub fn is_black_picture(problem: &str) -> bool { + problem.contains("black picture") +} + +/// Whether the PC can say why its picture is black: only a Windows host +/// reports its displays. An OS not known yet is asked anyway. +pub fn can_explain(os: &str) -> bool { + os.is_empty() || os.eq_ignore_ascii_case("windows") +} + +/// The answer when there is none to be had, so the notice stops saying it +/// is asking. +pub fn no_answer(os: &str) -> Help { + Help { + message: if can_explain(os) { + "The PC couldn't say why. Restarting the stream, or the PC, is the next thing to try." + } else { + "Only a Windows PC can be asked why its picture is black. Restarting the stream is the next thing to try." + } + .into(), + ..Default::default() + } +} + pub fn ask(ip: Ipv4Addr) -> Result { http::get_json::((ip, CONTROL_PORT), "/v1/display", REPORT_TIMEOUT) } @@ -279,6 +306,32 @@ mod tests { assert!(!h.hdr_is_on); } + #[test] + fn only_a_black_picture_is_asked_about_and_every_ask_ends_in_an_answer() { + assert!(is_black_picture("The PC is sending a black picture: the connection and the video are healthy, but every frame is blank. Asking the PC why…")); + for other in [ + "Video cannot be decoded: decode failed (-12909). Try restarting the stream or selecting H.264 in Settings.", + "Video stopped arriving from the PC. Try restarting the stream or lowering Quality.", + "Connected, but no video has arrived from the PC. Check its display, or restart the stream.", + ] { + assert!(!is_black_picture(other), "{other}"); + } + assert!(can_explain("windows") && can_explain("")); + assert!(!can_explain("linux") && !can_explain("macOS")); + // What a Linux or Mac host answers: nothing to go on. + let elsewhere = serde_json::json!({ + "windows": {"supported": false}, + "advanced_color": {"supported": false}, + "sunshine": {}, + }); + assert_eq!(verdict(&elsewhere).message, ""); + for os in ["windows", "linux"] { + let h = no_answer(os); + assert!(!h.message.is_empty() && !h.hdr_is_on, "{os}"); + assert!(!h.message.contains("Asking"), "{}", h.message); + } + } + #[test] fn a_report_that_says_nothing_yields_no_verdict() { assert_eq!(verdict(&serde_json::json!({})), Help::default()); diff --git a/crates/client/src/handover.rs b/crates/client/src/handover.rs index a18e430..91189b9 100644 --- a/crates/client/src/handover.rs +++ b/crates/client/src/handover.rs @@ -1,5 +1,6 @@ //! Getting a new Latch Host onto a PC whose host is too old to take -//! `/v1/update`, with nobody at the PC: the stream itself is the way in. +//! `/v1/update` (before 3.1, so one still named BroLink), with nobody at +//! the PC: the stream itself is the way in. //! //! The Mac serves the new `latch-host.exe` and a small PowerShell script //! on its own Tailscale address, to that one PC only, for a few minutes. @@ -15,7 +16,7 @@ use crate::input::{self, press_chord}; use anyhow::{anyhow, Context, Result}; use latch_core::http; use latch_core::update::{self, Release}; -use latch_core::HANDOVER_PORT; +use latch_core::{legacy, HANDOVER_PORT}; use latch_stream::Input; use parking_lot::Mutex; use semver::Version; @@ -58,10 +59,12 @@ pub struct Handover { impl Handover { /// Serve `release`'s host (fetching it first) and type the install /// command on the PC at `pc_ip` through `input`. `mac_ip` is this Mac's - /// Tailscale address, the only one the PC can reach. + /// Tailscale address, the only one the PC can reach. `configured_token` + /// is the one in Settings; without it, git's is asked for here, off + /// the window's thread. pub fn start( release: Option, - token: Option, + configured_token: Option, mac_ip: Ipv4Addr, pc_ip: Ipv4Addr, input: Input, @@ -79,6 +82,7 @@ impl Handover { std::thread::Builder::new() .name("handover".into()) .spawn(move || { + let token = update::token(configured_token.as_deref()); let outcome = run( release, token.as_deref(), @@ -150,8 +154,10 @@ pub fn command(mac_ip: Ipv4Addr) -> String { format!("powershell -ep bypass -c \"irm http://{mac_ip}:{HANDOVER_PORT}/u.ps1|iex\"") } -/// The script the PC runs. It finds the running host by its process, or -/// the usual install folder, and replaces that file. +/// The script the PC runs. It finds the running host by its process, under +/// either name, or the file where an install put it, and replaces that file +/// where it is: a host this old is a BroLink one, and its firewall rules +/// name that path. A fresh folder is made only when there is none. pub fn script(mac_ip: Ipv4Addr, version: &Version, sha256_hex: &str) -> String { format!( r#"# Latch Host {version}: installed from your Mac through the stream. @@ -159,7 +165,8 @@ $ErrorActionPreference = 'Stop' $src = 'http://{mac_ip}:{port}' $host.UI.RawUI.WindowTitle = 'Latch Host update' Write-Host "Installing Latch Host {version} from your Mac..." -$p = (Get-Process latch-host -ErrorAction SilentlyContinue | Select-Object -First 1).Path +$p = (Get-Process latch-host, {old_process} -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1).Path +if (-not $p) {{ foreach ($c in (Join-Path $env:LOCALAPPDATA 'Latch\latch-host.exe'), (Join-Path $env:LOCALAPPDATA '{old_exe}')) {{ if (Test-Path $c) {{ $p = $c; break }} }} }} if (-not $p) {{ $p = Join-Path $env:LOCALAPPDATA 'Latch\latch-host.exe' }} $dir = Split-Path -Parent $p New-Item -ItemType Directory -Force -Path $dir | Out-Null @@ -170,7 +177,7 @@ if ($h -ne '{sha}') {{ Remove-Item "$p.new" -Force; throw "the download did not Write-Host "Stopping the old service" try {{ Invoke-RestMethod -Method Post -Uri http://127.0.0.1:47850/v1/quit -ContentType 'application/json' -Body '{{}}' -TimeoutSec 3 | Out-Null }} catch {{}} Start-Sleep -Milliseconds 800 -Get-Process latch-host -ErrorAction SilentlyContinue | Stop-Process -Force +Get-Process latch-host, {old_process} -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Milliseconds 400 if (Test-Path $p) {{ Move-Item $p "$p.old" -Force }} Move-Item "$p.new" $p -Force @@ -184,6 +191,8 @@ Start-Sleep 5 mac_ip = mac_ip, port = HANDOVER_PORT, sha = sha256_hex.to_uppercase(), + old_process = legacy::WINDOWS_HOST_PROCESS, + old_exe = legacy::WINDOWS_HOST_EXE, ) } @@ -332,8 +341,23 @@ mod tests { let s = script(ip, &Version::new(3, 1, 0), "abcdef"); assert!(s.contains("$src = 'http://100.64.0.20:47851'"), "{s}"); assert!(s.contains("-ne 'ABCDEF'"), "the hash is compared uppercase"); - assert!(s.contains("Get-Process latch-host"), "{s}"); + // A host this old runs as BroLink: found under either name, and + // replaced where it is, so its firewall rules still apply. + assert!(s.contains("Get-Process latch-host, brolink-host"), "{s}"); + assert!( + s.contains( + "Get-Process latch-host, brolink-host -ErrorAction SilentlyContinue | Stop-Process" + ), + "both are stopped: {s}" + ); + assert!(s.contains("'BroLink\\brolink-host.exe'"), "{s}"); assert!(s.contains("Latch\\latch-host.exe"), "{s}"); + let found = s.find("Test-Path $c").unwrap(); + let fresh = s.rfind("if (-not $p) { $p = Join-Path").unwrap(); + assert!( + found < fresh, + "a new folder only when no install was found: {s}" + ); assert!(s.contains("/v1/quit"), "{s}"); assert!(s.contains("--background"), "{s}"); assert!(!s.contains("ghp_"), "no token in the script"); diff --git a/crates/client/src/input.rs b/crates/client/src/input.rs index 611d537..62dea03 100644 --- a/crates/client/src/input.rs +++ b/crates/client/src/input.rs @@ -13,9 +13,11 @@ pub const VK_SHIFT: i16 = 0x10; pub const VK_CONTROL: i16 = 0x11; pub const VK_MENU: i16 = 0x12; pub const VK_LWIN: i16 = 0x5B; +pub const VK_INSERT: i16 = 0x2D; pub const VK_DELETE: i16 = 0x2E; pub const VK_ESCAPE: i16 = 0x1B; pub const VK_TAB: i16 = 0x09; +pub const VK_CAPITAL: i16 = 0x14; pub const VK_RETURN: i16 = 0x0D; pub const VK_SNAPSHOT: i16 = 0x2C; pub const VK_C: i16 = 0x43; @@ -36,7 +38,7 @@ pub fn vk(key: Key) -> Option { Backspace => 0x08, Enter => 0x0D, Space => 0x20, - Insert => 0x2D, + Insert => VK_INSERT, Delete => VK_DELETE, Home => 0x24, End => 0x23, @@ -52,7 +54,10 @@ pub fn vk(key: Key) -> Option { Backtick => 0xC0, Minus => 0xBD, Period => 0xBE, - Plus | Equals => 0xBB, + Equals => 0xBB, + // egui's Plus comes from the keypad's + key alone (the main row's + // key is Equals): VK_ADD types "+" there, VK_OEM_PLUS typed "=". + Plus => 0x6B, Quote => 0xDE, Num0 => 0x30, Num1 => 0x31, @@ -128,19 +133,158 @@ pub fn button(b: PointerButton) -> Option { }) } +/// Where keyboard and mouse events go: the PC, through Moonlight, or in +/// tests a recording, so the order and masks the PC would see can be +/// checked. +pub trait Sink { + /// `vk` is a Windows virtual-key code; `mask` the `MODIFIER_*` bits. + fn key(&self, vk: i16, down: bool, mask: c_char); + /// `b` is one of the `BUTTON_*` codes. + fn button(&self, b: i32, down: bool); + /// An absolute position in a `w` by `h` picture. + fn position(&self, x: i16, y: i16, w: i16, h: i16); + /// Relative movement, while the mouse is captured. + fn motion(&self, dx: i16, dy: i16); + /// In 1/120ths of a wheel click; positive is up and right. + fn scroll(&self, vertical: i16, horizontal: i16); +} + +impl Sink for Input { + fn key(&self, vk: i16, down: bool, mask: c_char) { + Input::key(self, vk, down, mask); + } + fn button(&self, b: i32, down: bool) { + self.mouse_button(b, down); + } + fn position(&self, x: i16, y: i16, w: i16, h: i16) { + self.mouse_position(x, y, w, h); + } + fn motion(&self, dx: i16, dy: i16) { + self.mouse_move(dx, dy); + } + fn scroll(&self, vertical: i16, horizontal: i16) { + Input::scroll(self, vertical, horizontal); + } +} + +/// What a [`Recorder`] saw, in order. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Sent { + Key(i16, bool, c_char), + Button(i32, bool), + Position(i16, i16, i16, i16), + Motion(i16, i16), + Scroll(i16, i16), +} + +/// A [`Sink`] that keeps what it is sent, and says whether the stream is +/// connected. Like [`Input`], it drops everything while it is not. +#[cfg(test)] +#[derive(Debug, Default)] +pub struct Recorder { + pub sent: std::cell::RefCell>, + pub connected: std::cell::Cell, +} + +#[cfg(test)] +impl Recorder { + fn record(&self, s: Sent) { + if self.connected.get() { + self.sent.borrow_mut().push(s); + } + } + + pub fn take(&self) -> Vec { + std::mem::take(&mut self.sent.borrow_mut()) + } + + /// Only the key events, as (key, down). + pub fn keys(&self) -> Vec<(i16, bool)> { + self.take() + .into_iter() + .filter_map(|s| match s { + Sent::Key(vk, down, _) => Some((vk, down)), + _ => None, + }) + .collect() + } +} + +#[cfg(test)] +impl Sink for Recorder { + fn key(&self, vk: i16, down: bool, mask: c_char) { + self.record(Sent::Key(vk, down, mask)); + } + fn button(&self, b: i32, down: bool) { + self.record(Sent::Button(b, down)); + } + fn position(&self, x: i16, y: i16, w: i16, h: i16) { + self.record(Sent::Position(x, y, w, h)); + } + fn motion(&self, dx: i16, dy: i16) { + self.record(Sent::Motion(dx, dy)); + } + fn scroll(&self, vertical: i16, horizontal: i16) { + self.record(Sent::Scroll(vertical, horizontal)); + } +} + +/// One key event as Moonlight takes it: the key, down or up, and the +/// modifier mask sent with it. +type KeyEvent = (i16, bool, c_char); + +/// The modifier keys, in the order their mask bits are listed. +const MODIFIERS: [i16; 4] = [VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN]; + +fn send(out: &(impl Sink + ?Sized), events: &[KeyEvent]) { + for &(vk, down, mask) in events { + out.key(vk, down, mask); + } +} + /// Press `keys` in order and release them in reverse, with `mask` for the -/// modifiers already held. Usable from any thread, which is what a paste -/// that first has to reach the PC's clipboard needs. -pub fn press_chord(input: &Input, keys: &[i16], mask: c_char) { +/// modifiers already held. Usable from any thread with an [`Input`], which +/// is what typing on the PC from a worker needs. +pub fn press_chord(out: &(impl Sink + ?Sized), keys: &[i16], mask: c_char) { + send(out, &chord_events(keys, mask)); +} + +fn chord_events(keys: &[i16], mask: c_char) -> Vec { + let mut events = Vec::new(); let mut held = Vec::new(); for &k in keys { held.push(k); - input.key(k, true, mask | mask_of(&held)); + events.push((k, true, mask | mask_of(&held))); } for &k in keys.iter().rev() { held.retain(|&h| h != k); - input.key(k, false, mask | mask_of(&held)); + events.push((k, false, mask | mask_of(&held))); + } + events +} + +/// The events that send `keys` with no other modifier down: modifiers in +/// `mask` outside `keys` are released first, so ⌘ held as Ctrl does not +/// turn Shift+Insert into Ctrl+Shift+Insert, and pressed again after. +/// `press` is the part of `keys` to press: those not already held. +fn alone_events(keys: &[i16], press: &[i16], mask: c_char) -> Vec { + let others: Vec = MODIFIERS + .into_iter() + .filter(|&vk| mask & mask_of(&[vk]) != 0 && !keys.contains(&vk)) + .collect(); + let mut mask = mask; + let mut events = Vec::new(); + for &vk in &others { + mask &= !mask_of(&[vk]); + events.push((vk, false, mask)); + } + events.extend(chord_events(press, mask)); + for &vk in &others { + mask |= mask_of(&[vk]); + events.push((vk, true, mask)); } + events } fn mask_of(keys: &[i16]) -> c_char { @@ -169,29 +313,29 @@ pub struct Held { } impl Held { - pub fn key(&mut self, input: &Input, vk: i16, down: bool) { + pub fn key(&mut self, out: &(impl Sink + ?Sized), vk: i16, down: bool) { if down { self.keys.insert(vk); } else { self.keys.remove(&vk); } - input.key(vk, down, self.mask()); + out.key(vk, down, self.mask()); } - pub fn button(&mut self, input: &Input, b: i32, down: bool) { + pub fn button(&mut self, out: &(impl Sink + ?Sized), b: i32, down: bool) { if down { self.buttons.insert(b); } else { self.buttons.remove(&b); } - input.mouse_button(b, down); + out.button(b, down); } /// Turn modifier changes into key presses. Cmd maps to Ctrl or the /// Windows key. - pub fn modifiers(&mut self, input: &Input, now: Modifiers, cmd_is_ctrl: bool) { + pub fn modifiers(&mut self, out: &(impl Sink + ?Sized), now: Modifiers, cmd_is_ctrl: bool) { for (vk, down) in self.modifier_changes(now, cmd_is_ctrl) { - self.key(input, vk, down); + self.key(out, vk, down); } } @@ -222,12 +366,12 @@ impl Held { .fold(0, |mask, (_, bit)| mask | bit) } - pub fn release_all(&mut self, input: &Input) { + pub fn release_all(&mut self, out: &(impl Sink + ?Sized)) { for k in std::mem::take(&mut self.keys) { - input.key(k, false, 0); + out.key(k, false, 0); } for b in std::mem::take(&mut self.buttons) { - input.mouse_button(b, false); + out.button(b, false); } } @@ -235,9 +379,20 @@ impl Held { /// already down (⌘ held while ⌘C, ⌘V follow each other) is left down: /// releasing it here would make the next shortcut in the same hold /// arrive without its modifier. - pub fn chord(&mut self, input: &Input, keys: &[i16]) { + pub fn chord(&mut self, out: &(impl Sink + ?Sized), keys: &[i16]) { let fresh = self.chord_plan(keys); - press_chord(input, &fresh, self.mask()); + press_chord(out, &fresh, self.mask()); + } + + /// [`Held::chord`] for a chord that must arrive with no other modifier: + /// held modifiers it does not use are let go for it and pressed again + /// after, so the PC ends up holding what the user holds. + pub fn chord_alone(&mut self, out: &(impl Sink + ?Sized), keys: &[i16]) { + send(out, &self.chord_alone_events(keys)); + } + + fn chord_alone_events(&self, keys: &[i16]) -> Vec { + alone_events(keys, &self.chord_plan(keys), self.mask()) } /// The keys of `keys` a chord would have to press: those not held. @@ -331,6 +486,47 @@ mod tests { assert_eq!(mask_of(&[VK_C]), 0); } + #[test] + fn a_chord_alone_lets_go_of_other_modifiers_and_restores_them() { + // ⌘ is down as Ctrl. Copy on Linux is Ctrl+Insert: Ctrl stays down. + let mut held = Held::default(); + held.keys.insert(VK_CONTROL); + assert_eq!( + held.chord_alone_events(&[VK_CONTROL, VK_INSERT]), + vec![ + (VK_INSERT, true, MODIFIER_CTRL), + (VK_INSERT, false, MODIFIER_CTRL) + ] + ); + // Cut is Shift+Delete: Ctrl+Shift+Delete would be another shortcut + // (Firefox clears history), so Ctrl goes up for it and back down. + assert_eq!( + held.chord_alone_events(&[VK_SHIFT, VK_DELETE]), + vec![ + (VK_CONTROL, false, 0), + (VK_SHIFT, true, MODIFIER_SHIFT), + (VK_DELETE, true, MODIFIER_SHIFT), + (VK_DELETE, false, MODIFIER_SHIFT), + (VK_SHIFT, false, 0), + (VK_CONTROL, true, MODIFIER_CTRL), + ] + ); + // ⌘ as the Windows key: Super+Ctrl+Insert would copy nothing. + let mut held = Held::default(); + held.keys.insert(VK_LWIN); + assert_eq!( + held.chord_alone_events(&[VK_CONTROL, VK_INSERT]), + vec![ + (VK_LWIN, false, 0), + (VK_CONTROL, true, MODIFIER_CTRL), + (VK_INSERT, true, MODIFIER_CTRL), + (VK_INSERT, false, MODIFIER_CTRL), + (VK_CONTROL, false, 0), + (VK_LWIN, true, MODIFIER_META), + ] + ); + } + #[test] fn letters_digits_and_symbols_map_to_us_layout_codes() { assert_eq!(vk(Key::A), Some(0x41)); @@ -339,5 +535,8 @@ mod tests { assert_eq!(vk(Key::F12), Some(0x7B)); assert_eq!(vk(Key::Colon), vk(Key::Semicolon)); assert_eq!(vk(Key::Copy), None, "clipboard keys are handled as events"); + // The keypad's + is VK_ADD; the main row's =/+ key is VK_OEM_PLUS. + assert_eq!(vk(Key::Plus), Some(0x6B)); + assert_eq!(vk(Key::Equals), Some(0xBB)); } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index aeb3c0c..ee47aaa 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod display; pub mod handover; pub mod input; +pub mod macos; pub mod path; pub mod session; pub mod settings; diff --git a/crates/client/src/macos.rs b/crates/client/src/macos.rs new file mode 100644 index 0000000..e86889a --- /dev/null +++ b/crates/client/src/macos.rs @@ -0,0 +1,145 @@ +//! The two things the stream needs from AppKit itself. +//! +//! winit gives every Mac app a standard menu whose Hide (⌘H), Hide Others +//! (⌥⌘H) and Quit (⌘Q) items take those keys before the window sees them: +//! mid-stream, ⌘Q meant for the PC (Ctrl+Q, quit an app there) quit Latch +//! and ⌘H hid it. While the stream has the keyboard those items keep their +//! place in the menu but lose their shortcuts. +//! +//! And the pasteboard's change count, which tells whether the Mac's +//! clipboard has changed since the PC last copied something. And whether +//! Caps Lock is on: egui has neither a key nor a modifier for it. +//! +//! Elsewhere both do nothing. + +/// The app menu items whose shortcuts the stream takes over. +#[cfg(any(target_os = "macos", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Item { + Hide, + HideOthers, + Quit, +} + +/// The key an item answers to: none while the stream owns the keys. +#[cfg(any(target_os = "macos", test))] +fn equivalent(item: Item, stream_owns_keys: bool) -> &'static str { + match (item, stream_owns_keys) { + (_, true) => "", + (Item::Hide | Item::HideOthers, false) => "h", + (Item::Quit, false) => "q", + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{equivalent, Item}; + use objc2::sel; + use objc2_app_kit::{NSApplication, NSEvent, NSEventModifierFlags, NSPasteboard}; + use objc2_foundation::{MainThreadMarker, NSString}; + use std::cell::Cell; + + thread_local! { + /// What the menu was last set to, so AppKit is only asked on a change. + static OWNED: Cell> = const { Cell::new(None) }; + } + + pub fn stream_owns_keys(owned: bool) { + // Menus belong to the main thread; tests run elsewhere. + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + if OWNED.get() == Some(owned) { + return; + } + OWNED.set(Some(owned)); + let app = NSApplication::sharedApplication(mtm); + // SAFETY: plain AppKit getters and setters on the main thread. + unsafe { + let Some(bar) = app.mainMenu() else { return }; + let Some(menu) = bar.itemAtIndex(0).and_then(|i| i.submenu()) else { + return; + }; + for i in 0..menu.numberOfItems() { + let Some(entry) = menu.itemAtIndex(i) else { + continue; + }; + let item = match entry.action() { + Some(a) if a == sel!(hide:) => Item::Hide, + Some(a) if a == sel!(hideOtherApplications:) => Item::HideOthers, + Some(a) if a == sel!(terminate:) => Item::Quit, + _ => continue, + }; + entry.setKeyEquivalent(&NSString::from_str(equivalent(item, owned))); + } + } + } + + pub fn pasteboard_changes() -> Option { + // SAFETY: NSPasteboard is usable from any thread; this only reads + // its change count. + Some(unsafe { NSPasteboard::generalPasteboard().changeCount() }) + } + + pub fn caps_lock() -> Option { + // SAFETY: a class getter for the modifier keys' state right now, + // usable from any thread. + let flags = unsafe { NSEvent::modifierFlags_class() }; + Some(flags.contains(NSEventModifierFlags::NSEventModifierFlagCapsLock)) + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + pub fn stream_owns_keys(_owned: bool) {} + + pub fn pasteboard_changes() -> Option { + None + } + + pub fn caps_lock() -> Option { + None + } +} + +/// While `owned`, ⌘Q, ⌘H and ⌥⌘H go to the stream (and so to the PC) +/// instead of quitting or hiding Latch. Cheap to call every frame. +pub fn stream_owns_keys(owned: bool) { + imp::stream_owns_keys(owned); +} + +/// The Mac clipboard's change count: it moves whenever anything is copied. +/// None where there is no such count. +pub fn pasteboard_changes() -> Option { + imp::pasteboard_changes() +} + +/// Whether Caps Lock is on on this Mac. None where that is not known. +pub fn caps_lock() -> Option { + imp::caps_lock() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_stream_takes_quit_and_hide_and_gives_them_back() { + for item in [Item::Hide, Item::HideOthers, Item::Quit] { + assert_eq!(equivalent(item, true), ""); + } + assert_eq!(equivalent(Item::Hide, false), "h"); + // Hide Others keeps its Option bit in the item's modifier mask. + assert_eq!(equivalent(Item::HideOthers, false), "h"); + assert_eq!(equivalent(Item::Quit, false), "q"); + // Off the main thread (as here) it does nothing, and says so safely. + stream_owns_keys(true); + stream_owns_keys(false); + } + + #[test] + fn caps_lock_is_known_on_a_mac() { + // Read through the window server, from any thread. + assert_eq!(caps_lock().is_some(), cfg!(target_os = "macos")); + } +} diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index ef00e62..32dbdc9 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -4,15 +4,16 @@ use crate::clipboard; use crate::config::{ClientConfig, Codec, KnownPc, StreamSettings}; use crate::path::{self, Path}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use latch_core::api::{Ack, NatReport, PinRequest, PowerAction, PowerRequest, Status}; use latch_core::{http, tailscale, wake, CONTROL_PORT, SUNSHINE_PORT}; +use latch_stream::nvhttp::PinNotEntered; use latch_stream::session::Server; use latch_stream::{Client, Event, FrameSlot, Identity, Input, Session, Settings}; use parking_lot::Mutex; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::Receiver; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -63,6 +64,18 @@ impl Pc { pub fn power_allowed(&self) -> bool { self.host.as_ref().is_some_and(|h| h.power_allowed) } + /// Sleep is offered only for a Windows PC, which a wake packet brings + /// back. A Linux machine (a cloud VPS above all) or a Mac would stay + /// asleep with nothing here able to wake it. + pub fn can_sleep(&self) -> bool { + self.power_allowed() && sleep_offered(&self.os) + } +} + +/// Whether Latch offers to put a machine with Tailscale's OS string `os` +/// to sleep. +pub fn sleep_offered(os: &str) -> bool { + os.eq_ignore_ascii_case("windows") } /// A tailnet node tagged `tag:relay`: a candidate peer relay, never a PC. @@ -104,6 +117,11 @@ pub struct Discovery { pub relays: Vec, /// Relay servers this device is granted to use, from the debug command. pub peer_relay_servers: PeerRelayServers, + /// Every node Tailscale lists on this tailnet, asleep or not, relays and + /// phones included; `None` when Tailscale could not say. + pub listed: Option>, + /// The tailnet this machine is on, when Tailscale names it. + pub tailnet: Option, } /// Rescan the tailnet every few seconds and remember what each PC needs to @@ -182,49 +200,119 @@ fn spawn_peer_relay_probe(slot: Arc>) { }); } +/// A saved machine that has been off the tailnet this long is forgotten. +const FORGET_AFTER: u64 = 30 * 86_400; + /// Save what a scan taught about each PC: address, wake details, when it -/// was last seen. +/// was last seen. Forget the ones that have left the tailnet. fn learn(scan: &Discovery) { if let Err(e) = ClientConfig::update(|learned| { - for pc in &scan.pcs { - if pc.remembered { - continue; - } - let entry = learned.pcs.entry(pc.node_id.clone()).or_default(); - entry.name = pc.name.clone(); - if !pc.os.is_empty() { - entry.os = pc.os.clone(); - } - if let Some(ip) = pc.ip { - entry.tailscale_ip = Some(ip.to_string()); - } - if pc.online { - // Coarse on purpose: the file is rewritten only when this moves. - let now = latch_core::dates::now_unix(); - if entry - .last_seen_unix - .is_none_or(|t| now.saturating_sub(t) > 600) - { - entry.last_seen_unix = Some(now); - } + learn_into(learned, scan, latch_core::dates::now_unix()); + }) { + tracing::warn!("could not save what was learned: {e:#}"); + } +} + +fn learn_into(learned: &mut ClientConfig, scan: &Discovery, now: u64) { + for pc in &scan.pcs { + if pc.remembered { + continue; + } + let entry = learned.pcs.entry(pc.node_id.clone()).or_default(); + entry.name = pc.name.clone(); + if !pc.os.is_empty() { + entry.os = pc.os.clone(); + } + if let Some(ip) = pc.ip { + entry.tailscale_ip = Some(ip.to_string()); + } + if scan.tailnet.is_some() { + entry.tailnet = scan.tailnet.clone(); + } + if pc.online { + // Coarse on purpose: the file is rewritten only when this moves. + if entry + .last_seen_unix + .is_none_or(|t| now.saturating_sub(t) > 600) + { + entry.last_seen_unix = Some(now); } - if let Some(h) = &pc.host { - if h.mac.is_some() { - entry.mac = h.mac.clone(); - entry.lan_ip = h.lan_ip.clone(); - } + } + if let Some(h) = &pc.host { + if h.mac.is_some() { + entry.mac = h.mac.clone(); + entry.lan_ip = h.lan_ip.clone(); } - if let Some(p) = scan_public(pc) { - entry.public_ip = Some(p.to_string()); + } + if let Some(p) = scan_public(pc) { + entry.public_ip = Some(p.to_string()); + } + } + if let Some(listed) = &scan.listed { + for id in retired(&learned.pcs, listed, scan.tailnet.as_deref(), now) { + if let Some(k) = learned.pcs.remove(&id) { + tracing::info!("forgot {} ({id}): it has left the tailnet", k.name); } } - }) { - tracing::warn!("could not save what was learned: {e:#}"); } } +/// Saved machines that have left the tailnet this machine is on: Tailscale +/// no longer lists them (it lists every machine there, asleep or not) and +/// nothing has seen them for [`FORGET_AFTER`]. A machine saved on another +/// tailnet is kept for when this one goes back; one saved before tailnets +/// were recorded counts as this tailnet's only when another saved machine +/// is listed here. An empty listing proves nothing and forgets nothing. +fn retired( + pcs: &BTreeMap, + listed: &BTreeSet, + tailnet: Option<&str>, + now: u64, +) -> Vec { + if listed.is_empty() { + return Vec::new(); + } + let here = pcs.keys().any(|id| listed.contains(id)); + pcs.iter() + .filter(|(id, k)| { + let ours = match (k.tailnet.as_deref(), tailnet) { + (Some(theirs), Some(current)) => theirs == current, + (Some(_), None) => false, + (None, _) => here, + }; + !listed.contains(*id) + && ours + && k.last_seen_unix + .is_none_or(|t| now.saturating_sub(t) >= FORGET_AFTER) + }) + .map(|(id, _)| id.clone()) + .collect() +} + fn scan_public(pc: &Pc) -> Option { - pc.known.as_ref()?.public_ip.as_deref()?.parse().ok() + saved_public(pc.known.as_ref()?.public_ip.as_ref()) +} + +/// A saved public address, unless it is not one: older versions saved +/// whatever address Tailscale last reached the machine on, which can be its +/// LAN address, and a wake packet sent there goes nowhere. +fn saved_public(s: Option<&String>) -> Option { + s?.parse() + .ok() + .filter(|ip| latch_core::tailscale::is_public_v4(*ip)) +} + +/// Probe every peer at once: each can take two timeouts when nothing +/// answers, and a tailnet of a dozen machines would otherwise make one +/// refresh of the list take half a minute. +fn probe_all(cfg: &ClientConfig, peers: &[&tailscale::Node]) -> Vec { + std::thread::scope(|s| { + let probes: Vec<_> = peers + .iter() + .map(|&n| s.spawn(move || probe_peer(cfg, n))) + .collect(); + probes.into_iter().filter_map(|p| p.join().ok()).collect() + }) } pub fn scan(cfg: &ClientConfig) -> Discovery { @@ -252,16 +340,19 @@ pub fn scan(cfg: &ClientConfig) -> Discovery { }; peers.push(&local); } - let mut pcs: Vec = peers.into_iter().map(|n| probe_peer(cfg, n)).collect(); + let mut pcs = probe_all(cfg, &peers); // A tag:relay node that also runs Latch (a VPS desktop on the same // box as the packet relay) belongs in the machine list too. - for n in st.relay_peers() { - let pc = probe_peer(cfg, n); - if pc.host.is_some() || pc.sunshine { - pcs.push(pc); - } - } + pcs.extend( + probe_all(cfg, &st.relay_peers()) + .into_iter() + .filter(|pc| pc.host.is_some() || pc.sunshine), + ); pcs.sort_by(|a, b| a.name.cmp(&b.name)); + let mut listed: BTreeSet = st.peer.values().map(|n| n.id.clone()).collect(); + if std::env::var_os("LATCH_DEV_LOCAL").is_some() { + listed.insert("local".into()); + } let relays = st .relay_peers() .into_iter() @@ -281,6 +372,12 @@ pub fn scan(cfg: &ClientConfig) -> Discovery { self_ip: st.self_node.ipv4(), relays, peer_relay_servers: PeerRelayServers::default(), + listed: Some(listed), + tailnet: st + .current_tailnet + .as_ref() + .map(|t| t.name.clone()) + .filter(|n| !n.is_empty()), } } @@ -398,9 +495,29 @@ pub enum Step { }, } +impl Step { + /// For the log: the step, without the PIN a pairing carries. + pub fn name(&self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Waking => "waking", + Self::Waiting => "waiting", + Self::Pairing { .. } => "pairing", + Self::Launching => "launching", + Self::Connecting => "connecting", + Self::Streaming => "streaming", + Self::Ended { .. } => "ended", + } + } +} + #[derive(Debug, Clone)] pub struct Progress { pub pc: String, + /// The machine's Tailscale node id; names are not unique. + pub node_id: String, + /// Its Tailscale address, which power actions are sent to. + pub ip: Option, pub step: Step, pub detail: String, pub since: Instant, @@ -417,6 +534,8 @@ impl Default for Progress { fn default() -> Self { Self { pc: String::new(), + node_id: String::new(), + ip: None, step: Step::Idle, detail: String::new(), since: Instant::now(), @@ -454,12 +573,18 @@ pub struct Live { pub started: Instant, pub codec: &'static str, pub requested: (u32, u32, u32), + /// The PC's desktop, when it is a fixed size other than the stream's + /// (the stream is that desktop scaled down). Relative mouse movement + /// lands in its pixels, not the stream's. + pub desktop: Option<(u32, u32)>, /// The settings the stream was started with, after Auto had its say. pub settings: StreamSettings, /// The path as measured right before connecting. pub path: Path, /// Clipboard both ways, through Latch Host on the PC. pub clipboard: clipboard::Sync, + /// How the PC spells copy, cut and paste. + pub shortcuts: clipboard::Shortcuts, } impl Drop for Live { @@ -481,6 +606,14 @@ pub struct Target { pub server_cert: Option>, /// What discovery last knew about the path; re-measured at connect. pub path: Path, + /// Tailscale's OS string for the PC. + pub os: String, + /// The PC's fixed capture size and frame-rate ceiling, from its last + /// status; see [`fit_to_host`]. + pub capture_size: Option<[u32; 2]>, + pub max_fps: u32, + /// The size its Desktop was last launched at from this Mac, as saved. + pub desktop_size: Option<(u32, u32)>, } impl Target { @@ -496,15 +629,131 @@ impl Target { .and_then(|k| k.mac.as_deref()) .and_then(wake::MacAddr::parse), lan_ip: parse(k.and_then(|k| k.lan_ip.as_ref())), - public_ip: parse(k.and_then(|k| k.public_ip.as_ref())), + public_ip: saved_public(k.and_then(|k| k.public_ip.as_ref())), server_cert: k .and_then(|k| k.server_cert.as_deref()) .and_then(latch_stream::nvhttp::unhex), path: pc.path.clone(), + os: pc.os.clone(), + capture_size: pc.host.as_ref().and_then(|h| h.streamer.capture_size), + max_fps: pc.host.as_ref().map_or(0, |h| h.streamer.max_fps), + desktop_size: k.and_then(|k| k.desktop_size).map(|[w, h]| (w, h)), }) } } +/// What to ask a PC for, from what was wanted: its own capture size when +/// that is fixed and the wanted size is larger in either direction +/// (Sunshine would only scale the same desktop up, blurring it and costing +/// the encoder more; a smaller size is scaled down and is cheaper to +/// encode), and no more frames a second than it keeps up with. The host's +/// ceiling is for its capture size, or 1920 × 1080 when that is not fixed; +/// a smaller picture gets a ceiling higher in proportion to the pixels it +/// saves, since software encoding time follows the pixel count. A larger +/// one keeps the host's ceiling. Implausible sizes are ignored. +pub fn fit_to_host( + wanted: (u32, u32), + fps: u32, + capture_size: Option<[u32; 2]>, + max_fps: u32, +) -> (u32, u32, u32) { + let fixed = fixed_desktop(capture_size); + let (w, h) = match fixed { + Some(c) if wanted.0 > c.0 || wanted.1 > c.1 => c, + _ => wanted, + }; + let fps = if max_fps > 0 { + let (rw, rh) = fixed.unwrap_or((1920, 1080)); + let budget = u64::from(max_fps) * u64::from(rw) * u64::from(rh); + let ceiling = (budget / (u64::from(w) * u64::from(h)).max(1)).max(max_fps.into()); + fps.min(u32::try_from(ceiling).unwrap_or(u32::MAX)) + } else { + fps + }; + (w, h, fps) +} + +/// A host's fixed capture size, when it reported a plausible one. +fn fixed_desktop(capture_size: Option<[u32; 2]>) -> Option<(u32, u32)> { + match capture_size { + Some([w, h]) if (320..=8192).contains(&w) && (240..=8192).contains(&h) => Some((w, h)), + _ => None, + } +} + +/// What a stream with `settings` asks the PC for on a display of `native` +/// pixels, after Auto has had its say and the PC's limits theirs: width, +/// height and frame rate. A connect asks for exactly this. +pub fn request_for( + settings: &StreamSettings, + native: (u32, u32), + capture_size: Option<[u32; 2]>, + max_fps: u32, +) -> (u32, u32, u32) { + let s = path::effective(settings); + fit_to_host(s.resolution.pixels(native), s.fps, capture_size, max_fps) +} + +/// The size each PC's Desktop was last launched at from here, by node. The +/// same is kept in client.toml ([`KnownPc::desktop_size`]) for the next +/// time this app opens. +static LAUNCHED: Mutex> = Mutex::new(BTreeMap::new()); + +/// Whether the PC's desktop takes the size of whoever launches it: a Linux +/// desktop whose host reports no fixed size, where a prep command resizes +/// the X screen at each launch. A Windows PC switches its display on a +/// resume too, a Mac never does, and a fixed X screen cannot. +fn follows_viewer(os: &str, capture_size: Option<[u32; 2]>) -> bool { + os.eq_ignore_ascii_case("linux") && fixed_desktop(capture_size).is_none() +} + +/// Whether the running app has to be ended before this launch. Another app +/// always does. The Desktop does when asked to (a broken capture), and on +/// a desktop that follows the viewer when it may be running at another +/// size: resuming keeps the PC's display as the launch left it, since the +/// prep command runs only at a launch, so a stream at a new size would be +/// the old desktop scaled, with the mouse moving over the old one. A +/// Desktop this Mac never launched is of unknown size. +fn must_relaunch( + current_game: u32, + app_id: u32, + desktop: bool, + restart_capture: bool, + follows_viewer: bool, + launched_at: Option<(u32, u32)>, + size: (u32, u32), +) -> bool { + current_game != 0 + && (current_game != app_id + || (desktop && (restart_capture || (follows_viewer && launched_at != Some(size))))) +} + +/// A cancelled attempt ends the app on the PC only when it started it; one +/// it rejoined (a game left running, a desktop someone else watches) is +/// left as it was found, as a disconnect leaves it. +fn ends_app_on_cancel(resumed: bool) -> bool { + !resumed +} + +fn abandon(client: &Client<'_>, resumed: bool) { + if ends_app_on_cancel(resumed) { + let _ = client.quit(); + } +} + +/// Keep the size the PC's Desktop was launched at across restarts of this +/// app, so the first connect afterwards resumes it rather than ending and +/// launching it again at the same size. +fn remember_desktop_size(node_id: &str, (w, h): (u32, u32)) { + if let Err(e) = ClientConfig::update(|cfg| { + if let Some(pc) = cfg.pcs.get_mut(node_id) { + pc.desktop_size = Some([w, h]); + } + }) { + tracing::warn!("could not save the desktop's size: {e:#}"); + } +} + pub struct Connect { pub target: Target, pub settings: StreamSettings, @@ -527,6 +776,8 @@ pub fn connect(c: Connect) { } *p = Progress { pc: c.target.name.clone(), + node_id: c.target.node_id.clone(), + ip: Some(c.target.ip), generation, ..Default::default() }; @@ -544,12 +795,25 @@ pub fn connect(c: Connect) { tracing::info!("cancelled: {e:#}"); p.set(Step::Ended { error: None }, ""); } - Err(e) => p.set( - Step::Ended { - error: Some(format!("{e}")), - }, - "", - ), + Err(e) => { + // The step it got to, and every cause: the banner alone + // leaves nothing to look at afterwards. + tracing::warn!( + "couldn't connect to {} ({}): {e:#}", + c.target.name, + if p.detail.is_empty() { + p.step.name() + } else { + p.detail.as_str() + } + ); + p.set( + Step::Ended { + error: Some(format!("{e:#}")), + }, + "", + ) + } } c.ctx.request_repaint(); }); @@ -625,6 +889,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { report(Step::Launching, "Checking pairing…".into()); let identity = Identity::load_or_create(&latch_core::config::data_dir()?.join("identity"))?; let mut client = Client::new(&identity, IpAddr::V4(t.ip), t.server_cert.clone())?; + let asking_paired = || format!("Asking {} whether this Mac is paired", t.name); let mut info = match client.server_info() { Ok(i) => i, Err(e) if latch_stream::nvhttp::is_pin_mismatch(&e) => { @@ -638,19 +903,21 @@ fn run(c: &Connect, generation: u64) -> Result<()> { tracing::warn!("could not forget the PC's old certificate: {save_error:#}"); } client = Client::new(&identity, IpAddr::V4(t.ip), None)?; - retry(8, &c.progress, generation, || client.server_info())? + retry(8, &c.progress, generation, || client.server_info()) + .with_context(asking_paired)? + } + Err(e) => { + tracing::info!("{}: {e:#}; asking again", t.name); + retry(8, &c.progress, generation, || client.server_info()) + .with_context(asking_paired)? } - Err(_) => retry(8, &c.progress, generation, || client.server_info())?, }; if !info.paired || client.server_cert().is_none() { let pin = format!("{:04}", rand::random::() % 10_000); report(Step::Pairing { pin: pin.clone() }, String::new()); - let der = client.pair_cancellable(&pin, &latch_core::config::machine_name(), || { - submit_pin(t.ip, &pin, &c.progress, &c.ctx); - stale(&c.progress, generation) - })?; + let der = pair(&mut client, &pin, c, generation)?; remember_cert(&t.node_id, &t.name, &der); - info = client.server_info()?; + info = client.server_info().with_context(asking_paired)?; if !info.paired { bail!("{} did not accept the pairing", t.name); } @@ -663,15 +930,25 @@ fn run(c: &Connect, generation: u64) -> Result<()> { // carry. report(Step::Launching, "Measuring the path…".into()); let path = measure_path(&t.node_id, t.ip, &t.path); - let settings = path::effective(&c.settings); + let mut settings = path::effective(&c.settings); + let wanted = settings.resolution.pixels(c.native); + let (w, h, fps) = request_for(&c.settings, c.native, t.capture_size, t.max_fps); + settings.fps = fps; tracing::info!( - "path to {}: {} · {}", + "path to {}: {} · {}{}", t.name, path.label(), - settings.describe() + settings.describe(), + if (w, h) != wanted { + format!(" · {w} × {h}, the size {} draws", t.name) + } else { + String::new() + } ); report(Step::Launching, "Starting the stream…".into()); - let apps = client.app_list()?; + let apps = client + .app_list() + .with_context(|| format!("Asking {} what it can stream", t.name))?; c.progress.lock().apps = apps.iter().map(|a| a.title.clone()).collect(); let app = apps .iter() @@ -679,17 +956,33 @@ fn run(c: &Connect, generation: u64) -> Result<()> { .or_else(|| apps.iter().find(|a| a.title == "Desktop")) .or_else(|| apps.first()) .ok_or_else(|| anyhow!("{} offers nothing to stream", t.name))?; - if info.current_game != 0 - && (info.current_game != app.id - || (c.restart_capture && app.title.eq_ignore_ascii_case("Desktop"))) - { - client.quit()?; + let desktop = app.title.eq_ignore_ascii_case("Desktop"); + // Nothing on the PC has been touched yet: a cancel while the path was + // measured must not end what runs there. + if stale(&c.progress, generation) { + bail!("cancelled"); + } + let launched_at = LAUNCHED.lock().get(&t.node_id).copied().or(t.desktop_size); + if must_relaunch( + info.current_game, + app.id, + desktop, + c.restart_capture, + follows_viewer(&t.os, t.capture_size), + launched_at, + (w, h), + ) { + client + .quit() + .with_context(|| format!("Stopping {}'s previous stream", t.name))?; let stopped = Instant::now(); loop { if stale(&c.progress, generation) { bail!("cancelled"); } - info = client.server_info()?; + info = client + .server_info() + .with_context(|| format!("Asking {} whether its stream stopped", t.name))?; if info.current_game == 0 { break; } @@ -702,26 +995,37 @@ fn run(c: &Connect, generation: u64) -> Result<()> { std::thread::sleep(Duration::from_millis(250)); } } - let (w, h) = settings.resolution.pixels(c.native); - let fps = settings.fps; + if stale(&c.progress, generation) { + bail!("cancelled"); + } + // Rejoining what already runs there: a cancel from here on leaves it + // running, as a disconnect would. Only an app this attempt started is + // ended again. + let resumed = info.current_game != 0; let ri_key: [u8; 16] = rand::random(); let ri_id: u32 = rand::random(); let mut ri_iv = [0u8; 16]; ri_iv[..4].copy_from_slice(&ri_id.to_be_bytes()); - let rtsp = client.launch( - app.id, - w, - h, - fps, - settings.bitrate_kbps, - &ri_key, - ri_id, - info.current_game != 0, - )?; + let rtsp = client + .launch( + app.id, + w, + h, + fps, + settings.bitrate_kbps, + &ri_key, + ri_id, + resumed, + ) + .with_context(|| format!("Asking {} to start the stream", t.name))?; + if desktop { + LAUNCHED.lock().insert(t.node_id.clone(), (w, h)); + remember_desktop_size(&t.node_id, (w, h)); + } // 5. Connect. if stale(&c.progress, generation) { - let _ = client.quit(); + abandon(&client, resumed); bail!("cancelled"); } let hevc = wants_hevc(settings.codec, info.codec_mode_support); @@ -751,7 +1055,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { ); if stale(&c.progress, generation) { drop(session); - let _ = client.quit(); + abandon(&client, resumed); bail!("cancelled"); } let input = session.input(); @@ -766,9 +1070,11 @@ fn run(c: &Connect, generation: u64) -> Result<()> { started: Instant::now(), codec: if hevc { "HEVC" } else { "H.264" }, requested: (w, h, fps), + desktop: fixed_desktop(t.capture_size).filter(|&d| d != (w, h)), settings, path, clipboard: clipboard::Sync::spawn(t.ip, c.ctx.clone()), + shortcuts: clipboard::Shortcuts::for_os(&t.os), }); report(Step::Connecting, "Connecting…".into()); Ok(()) @@ -806,41 +1112,272 @@ fn retry( Err(last) } -/// Hand the PIN to Latch Host on the PC, which types it into Sunshine. If -/// there is no Latch Host, the PIN stays on screen for someone at the PC. -fn submit_pin(ip: Ipv4Addr, pin: &str, progress: &Mutex, ctx: &egui::Context) { - let req = PinRequest { - pin: pin.into(), - name: latch_core::config::machine_name(), +/// Pair with the PC: the handshake on this thread, and the PIN handed to +/// Latch Host on the PC beside its first request, on another. The +/// handshake goes on the moment the PC answers, however the PIN got in; +/// Cancel works throughout. +fn pair(client: &mut Client<'_>, pin: &str, c: &Connect, generation: u64) -> Result> { + let t = &c.target; + let hand_off = Arc::new(PinHandOff::new( + &t.name, + pin, + c.progress.clone(), + generation, + c.ctx.clone(), + )); + let mut offered = false; + let result = client.pair_cancellable(pin, &latch_core::config::machine_name(), |answered| { + if answered { + hand_off.finish(); + return false; + } + if !offered { + offered = true; + let (h, host) = (hand_off.clone(), SocketAddr::from((t.ip, CONTROL_PORT))); + std::thread::spawn(move || h.run(host)); + } + stale(&c.progress, generation) || hand_off.refused.lock().is_some() + }); + // However the handshake ended, the PIN is not offered again. + hand_off.stop.store(true, Ordering::Relaxed); + result.map_err(|e| { + pairing_error( + e, + &t.name, + hand_off.refused.lock().take(), + hand_off.why_not.lock().take(), + ) + }) +} + +/// What to say when pairing with `pc` failed: Latch Host's refusal when it +/// refused for good, why the PIN never went in when nobody entered it in +/// time, else the handshake's own error. +fn pairing_error( + e: anyhow::Error, + pc: &str, + refused: Option, + why_not: Option, +) -> anyhow::Error { + if let Some(reason) = refused { + return anyhow!("Latch on {pc} refused the PIN: {reason}."); + } + if e.downcast_ref::().is_some() { + return match why_not { + Some(why) => anyhow!("Pairing with {pc} timed out: {why}."), + None => anyhow!("Pairing with {pc} timed out: nobody entered the PIN there."), + }; + } + e.context(format!("Pairing with {pc}")) +} + +/// How long the PIN is offered to Latch Host on the PC, and how long a host +/// that cannot log in to its engine is given to manage it (it checks again +/// every few seconds, and lags an engine that has just started). +const PIN_OFFER_FOR: Duration = Duration::from_secs(75); +const ENGINE_LOGIN_GRACE: Duration = Duration::from_secs(15); + +/// What Latch Host on the PC made of one PIN hand-off. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PinReply { + /// The engine has the PIN. + Taken, + /// Not taken, for a reason that can pass: the engine is not ready for + /// it yet, or refused it this time. + Retry(String), + /// The host cannot log in to its engine, so it cannot enter any PIN + /// until that is fixed (or the engine has finished starting). + NoEngine(String), + /// The host has no way to take a PIN: it is older than `/v1/pin`. + NoRoute, + /// Refused for good: this Mac may not ask, or the request is wrong. + Refused(String), + /// Nothing answered. + Unreachable(String), +} + +/// Sort Latch Host's reply to `POST /v1/pin`. It answers every refusal with +/// an error status and its reason in the body, so the body is read +/// whatever the status says. +fn pin_reply(reply: Result) -> PinReply { + let r = match reply { + Ok(r) => r, + Err(e) => return PinReply::Unreachable(format!("{e:#}")), }; - // Pairing draws no spinner, so a new reason has to ask for its frame. - let show = |detail: String| { - let mut p = progress.lock(); - if p.detail != detail { + let ack = r.parse::().ok(); + if r.status == 200 && ack.as_ref().is_some_and(|a| a.ok) { + return PinReply::Taken; + } + let reason = ack + .and_then(|a| a.error) + .filter(|e| !e.trim().is_empty()) + .unwrap_or_else(|| format!("HTTP {}", r.status)); + match r.status { + 404 => PinReply::NoRoute, + 400 | 403 | 422 => PinReply::Refused(reason), + _ if reason.starts_with("Latch cannot log in to the streaming engine") => { + PinReply::NoEngine(reason) + } + _ => PinReply::Retry(reason), + } +} + +/// Handing the PIN to Latch Host on the PC, which enters it into the +/// engine there. It runs on its own thread beside the first pairing +/// request and stops when that request is answered (the PIN went in, +/// however it got there), when the attempt is cancelled, when the host +/// refuses for good, or after [`PIN_OFFER_FOR`]. Whatever stops it, the +/// PIN stays on screen for someone at the PC until the request gives up. +struct PinHandOff { + pc: String, + request: PinRequest, + progress: Arc>, + generation: u64, + ctx: egui::Context, + /// The first pairing request is over: stop offering the PIN. + stop: AtomicBool, + /// Latch Host refused the PIN for good, and why. + refused: Mutex>, + /// Why the PIN has not gone in through Latch Host, for the message if + /// nobody enters it at all. + why_not: Mutex>, +} + +impl PinHandOff { + fn new( + pc: &str, + pin: &str, + progress: Arc>, + generation: u64, + ctx: egui::Context, + ) -> Self { + Self { + pc: pc.to_string(), + request: PinRequest { + pin: pin.into(), + name: latch_core::config::machine_name(), + }, + progress, + generation, + ctx, + stop: AtomicBool::new(false), + refused: Mutex::new(None), + why_not: Mutex::new(None), + } + } + + fn done(&self) -> bool { + self.stop.load(Ordering::Relaxed) || stale(&self.progress, self.generation) + } + + /// The first request is answered: stop, and take down any warning, + /// which is no longer true. + fn finish(&self) { + self.stop.store(true, Ordering::Relaxed); + self.show(String::new()); + } + + /// Pairing draws no spinner, so a new line has to ask for its frame. + fn show(&self, detail: String) { + let mut p = self.progress.lock(); + if p.generation == self.generation && p.detail != detail { p.detail = detail; drop(p); - ctx.request_repaint(); + self.ctx.request_repaint(); } - }; - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(75) { - if progress.lock().cancel { - return; + } + + /// Sleep `d` in short steps; false once it is time to stop. + fn wait(&self, d: Duration) -> bool { + let until = Instant::now() + d; + while Instant::now() < until { + if self.done() { + return false; + } + std::thread::sleep(Duration::from_millis(50)); } - match http::post_json::<_, Ack>((ip, CONTROL_PORT), "/v1/pin", &req, Duration::from_secs(1)) - { - Ok(a) if a.ok => return, - Ok(a) => { - let reason = a.error.unwrap_or_else(|| "PIN not accepted yet".into()); - tracing::info!("PIN not accepted yet: {reason}"); - show(reason); + !self.done() + } + + fn run(&self, host: SocketAddr) { + let pc = &self.pc; + let body = serde_json::to_string(&self.request).unwrap_or_default(); + let start = Instant::now(); + let mut pause = Duration::from_millis(800); + let mut no_engine_since: Option = None; + let mut refusals = 0; + while start.elapsed() < PIN_OFFER_FOR && !self.done() { + // Latch Host answers once the engine has the PIN: up to a + // little over half a second with an engine that holds the post. + let reply = pin_reply(http::request( + host, + "POST", + "/v1/pin", + Some(&body), + Duration::from_secs(3), + )); + if self.done() { + return; } - Err(e) => { - tracing::info!("Latch Host did not take the PIN: {e}"); - show("Latch on that machine isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, then try again.".into()); + if !matches!(reply, PinReply::Refused(_)) { + refusals = 0; } + let why = |s: String| *self.why_not.lock() = Some(s); + match reply { + PinReply::Taken => { + // Should the engine still not answer, this is why. + why("Latch there entered the PIN, but the streaming engine never finished pairing".into()); + self.show(String::new()); + return; + } + PinReply::Retry(reason) => { + tracing::info!("{pc} did not take the PIN yet: {reason}"); + self.show(format!( + "Latch on {pc} couldn't enter the PIN yet: {reason}" + )); + why(format!("Latch there couldn't enter the PIN ({reason})")); + } + PinReply::NoEngine(reason) => { + let since = *no_engine_since.get_or_insert_with(Instant::now); + tracing::info!("{pc} did not take the PIN: {reason}"); + why("Latch there can't log in to its streaming engine; run setup there".into()); + if since.elapsed() >= ENGINE_LOGIN_GRACE { + self.show(format!("Latch on {pc} can't log in to its streaming engine, so it can't enter the PIN. Run setup there, or type this PIN into the streaming engine's web page on that machine.")); + return; + } + self.show(format!( + "Latch on {pc} can't log in to its streaming engine yet." + )); + } + PinReply::NoRoute => { + tracing::info!("{pc}: Latch Host there has no PIN route"); + why("Latch there is too old to enter the PIN; update it there".into()); + self.show(format!("Latch on {pc} is too old to enter the PIN. Type it into the streaming engine's web page on that machine, or update Latch there.")); + return; + } + PinReply::Refused(reason) => { + // Twice in a row: a refusal can also be a moment when + // the host could not ask Tailscale who this Mac is. + refusals += 1; + tracing::info!("{pc} refused the PIN: {reason}"); + if refusals >= 2 { + *self.refused.lock() = Some(reason); + return; + } + } + PinReply::Unreachable(e) => { + tracing::info!("Latch Host on {pc} did not take the PIN: {e}"); + self.show(format!("Latch on {pc} isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, or type this PIN into the streaming engine's web page on that machine.")); + why("Latch there isn't answering, so it couldn't enter the PIN".into()); + } + } + // A refusal that keeps coming costs the host two engine calls + // and a log line each time: ask less often as it goes on. + if !self.wait(pause) { + return; + } + pause = (pause * 2).min(Duration::from_secs(4)); } - std::thread::sleep(Duration::from_millis(800)); } } @@ -896,6 +1433,533 @@ pub fn wake_only(pc: &Pc) -> Result { mod tests { use super::*; + #[test] + fn a_saved_lan_address_is_not_taken_for_the_public_one() { + let saved = |s: &str| saved_public(Some(&s.to_string())); + assert_eq!( + saved("116.125.113.36"), + Some("116.125.113.36".parse().unwrap()) + ); + assert_eq!(saved("192.168.55.57"), None); + assert_eq!(saved("10.0.2.15"), None); + assert_eq!(saved("100.106.23.40"), None, "a Tailscale address"); + assert_eq!(saved("not an address"), None); + assert_eq!(saved_public(None), None); + } + + #[test] + fn a_desktop_running_at_another_size_is_launched_again() { + const DESKTOP: u32 = 7; + let p720 = (1280, 720); + let follows = true; + // Nothing running: a plain launch. + assert!(!must_relaunch(0, DESKTOP, true, true, follows, None, p720)); + // Launched from here at this size: resumed. + assert!(!must_relaunch( + DESKTOP, + DESKTOP, + true, + false, + follows, + Some(p720), + p720 + )); + // At another size, or at a size this Mac does not know: launched + // again, so the PC's display follows the new size. + assert!(must_relaunch( + DESKTOP, + DESKTOP, + true, + false, + follows, + Some((3024, 1964)), + p720 + )); + assert!(must_relaunch( + DESKTOP, DESKTOP, true, false, follows, None, p720 + )); + // A broken capture, or another app, always. + assert!(must_relaunch( + DESKTOP, + DESKTOP, + true, + true, + follows, + Some(p720), + p720 + )); + assert!(must_relaunch( + 9, + DESKTOP, + true, + false, + follows, + Some(p720), + p720 + )); + // A game is never ended for a size change. + assert!(!must_relaunch(9, 9, false, false, follows, None, p720)); + assert!(!must_relaunch(9, 9, false, true, follows, None, p720)); + // A desktop that does not follow the viewer (Windows switches its + // display on a resume, a Mac and a fixed X screen never do) is not + // ended for its size, known or not; still for a broken capture. + for launched_at in [None, Some((3024, 1964))] { + assert!(!must_relaunch( + DESKTOP, + DESKTOP, + true, + false, + false, + launched_at, + p720 + )); + } + assert!(must_relaunch( + DESKTOP, DESKTOP, true, true, false, None, p720 + )); + } + + #[test] + fn only_a_linux_desktop_without_a_fixed_size_follows_the_viewer() { + assert!(follows_viewer("linux", None)); + assert!(follows_viewer("Linux", Some([0, 0])), "nonsense is no size"); + assert!(!follows_viewer("linux", Some([1920, 1080]))); + assert!(!follows_viewer("windows", None)); + assert!(!follows_viewer("macOS", None)); + assert!(!follows_viewer("", None), "an old host says no OS"); + } + + #[test] + fn a_cancel_ends_only_an_app_this_attempt_started() { + assert!(ends_app_on_cancel(false), "launched fresh, or relaunched"); + assert!(!ends_app_on_cancel(true), "rejoined: left as it was found"); + } + + #[test] + fn the_request_depends_on_the_display_only_where_the_setting_does() { + use crate::config::{Quality, Resolution}; + let (laptop, monitor) = ((3024, 1964), (2560, 1440)); + let custom = |resolution| StreamSettings { + quality: Quality::Custom, + resolution, + fps: 60, + ..Default::default() + }; + // A fixed size asks for the same whichever display the window is on. + let p1080 = custom(Resolution::P1080); + assert_eq!( + request_for(&p1080, laptop, None, 0), + request_for(&p1080, monitor, None, 0) + ); + // Match screen follows the display... + let native = custom(Resolution::Native); + assert_ne!( + request_for(&native, laptop, None, 0), + request_for(&native, monitor, None, 0) + ); + // ...unless the PC's fixed desktop clamps both to its own size. + let vps = Some([1920, 1080]); + assert_eq!( + request_for(&native, laptop, vps, 30), + request_for(&native, monitor, vps, 30) + ); + // Auto is Match screen. + let auto = StreamSettings::default(); + assert_eq!(auto.quality, Quality::Auto); + assert_eq!(request_for(&auto, laptop, None, 0).0, 3024); + assert_eq!(request_for(&auto, monitor, None, 0).0, 2560); + } + + fn known(tailnet: Option<&str>, seen_days_ago: Option) -> KnownPc { + KnownPc { + name: "pc".into(), + server_cert: Some("3082".into()), + tailnet: tailnet.map(str::to_string), + last_seen_unix: seen_days_ago.map(|d| NOW - d * 86_400), + ..Default::default() + } + } + + const NOW: u64 = 1_792_000_000; + + #[test] + fn a_machine_that_left_the_tailnet_a_month_ago_is_forgotten() { + let here = Some("daniel@example.com"); + let mut pcs = BTreeMap::new(); + pcs.insert("hermes".to_string(), known(here, Some(90))); + pcs.insert("artemis".to_string(), known(None, Some(31))); + pcs.insert("recent".to_string(), known(here, Some(2))); + pcs.insert("never-seen".to_string(), known(here, None)); + pcs.insert("work".to_string(), known(Some("work.example"), Some(400))); + // Hermes is listed (asleep for months, still on the tailnet); a + // relay listed by Tailscale but not among the PCs is listed too. + let listed: BTreeSet = ["hermes", "relay"].map(String::from).into(); + let gone = retired(&pcs, &listed, here, NOW); + assert_eq!(gone, ["artemis", "never-seen"], "{gone:?}"); + // Nothing listed, or nothing to say it is this tailnet: nothing goes. + assert!(retired(&pcs, &BTreeSet::new(), here, NOW).is_empty()); + let elsewhere: BTreeSet = ["someone-else"].map(String::from).into(); + assert_eq!( + retired(&pcs, &elsewhere, Some("other.example"), NOW), + Vec::::new(), + "on another tailnet, this one's machines are kept" + ); + // A Tailscale that names no tailnet forgets only unmarked entries, + // and only when a saved machine is listed. + assert_eq!(retired(&pcs, &listed, None, NOW), ["artemis"]); + } + + #[test] + fn learning_marks_the_tailnet_and_a_failed_scan_forgets_nothing() { + let mut cfg = ClientConfig::default(); + cfg.pcs.insert("artemis".into(), known(None, Some(40))); + cfg.pcs.insert("hermes".into(), known(None, Some(1))); + let hermes = Pc { + node_id: "hermes".into(), + name: "Hermes".into(), + ..Default::default() + }; + // Tailscale down: the list is what was saved, and nothing changes. + let down = Discovery { + error: Some("Tailscale is stopped".into()), + pcs: remembered(&cfg), + ..Default::default() + }; + let before = cfg.clone(); + learn_into(&mut cfg, &down, NOW); + assert_eq!(cfg, before); + // Tailscale up: Hermes is listed and marked; artemis is not. + let up = Discovery { + pcs: vec![hermes], + listed: Some(["hermes".to_string()].into()), + tailnet: Some("daniel@example.com".into()), + ..Default::default() + }; + learn_into(&mut cfg, &up, NOW); + assert_eq!(cfg.pcs.keys().collect::>(), ["hermes"]); + assert_eq!( + cfg.pcs["hermes"].tailnet.as_deref(), + Some("daniel@example.com") + ); + assert_eq!(cfg.pcs["hermes"].server_cert.as_deref(), Some("3082")); + } + + #[test] + fn the_saved_desktop_size_reaches_the_target() { + let pc = Pc { + ip: Some("100.64.0.9".parse().unwrap()), + known: Some(KnownPc { + desktop_size: Some([1280, 720]), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + Target::from_pc(&pc).unwrap().desktop_size, + Some((1280, 720)) + ); + } + + #[test] + fn a_pin_reply_is_read_whatever_its_status() { + let reply = |status: u16, body: &str| { + pin_reply(Ok(http::Response { + status, + body: body.into(), + })) + }; + let ack = |e: &str| serde_json::to_string(&Ack::err(e)).unwrap(); + assert_eq!( + reply(200, &serde_json::to_string(&Ack::ok()).unwrap()), + PinReply::Taken + ); + assert_eq!( + reply( + 502, + &ack("pairing_id must contain exactly 32 hexadecimal characters") + ), + PinReply::Retry("pairing_id must contain exactly 32 hexadecimal characters".into()) + ); + assert_eq!( + reply( + 502, + &ack("Latch cannot log in to the streaming engine on this PC; run setup there") + ), + PinReply::NoEngine( + "Latch cannot log in to the streaming engine on this PC; run setup there".into() + ) + ); + assert_eq!( + reply(403, &ack("not a machine on this PC's Tailscale account")), + PinReply::Refused("not a machine on this PC's Tailscale account".into()) + ); + assert_eq!( + reply(400, &ack("the PIN is four digits")), + PinReply::Refused("the PIN is four digits".into()) + ); + assert_eq!(reply(404, "not found"), PinReply::NoRoute); + assert_eq!(reply(502, ""), PinReply::Retry("HTTP 502".into())); + assert_eq!( + reply(200, &ack("not yet")), + PinReply::Retry("not yet".into()) + ); + assert!(matches!( + pin_reply(Err(anyhow!("timed out"))), + PinReply::Unreachable(e) if e == "timed out" + )); + } + + #[test] + fn a_failed_pairing_says_why_in_the_hosts_words() { + let say = |e: anyhow::Error, refused: Option<&str>, why: Option<&str>| { + format!( + "{:#}", + pairing_error(e, "stuzha", refused.map(Into::into), why.map(Into::into)) + ) + }; + assert_eq!( + say( + anyhow!("pairing cancelled"), + Some("not a machine on this PC's Tailscale account"), + None + ), + "Latch on stuzha refused the PIN: not a machine on this PC's Tailscale account." + ); + assert_eq!( + say( + anyhow!(PinNotEntered), + None, + Some("Latch there isn't answering, so it couldn't enter the PIN") + ), + "Pairing with stuzha timed out: Latch there isn't answering, so it couldn't enter the PIN." + ); + assert_eq!( + say(anyhow!(PinNotEntered), None, None), + "Pairing with stuzha timed out: nobody entered the PIN there." + ); + assert_eq!( + say(anyhow!("pairing failed at the challenge step"), None, None), + "Pairing with stuzha: pairing failed at the challenge step" + ); + // The step and the cause, not just the cause. + let e = anyhow!("timed out").context("Asking stuzha to start the stream"); + assert_eq!( + format!("{e:#}"), + "Asking stuzha to start the stream: timed out" + ); + } + + /// A Latch Host stand-in that answers every request the same way, and + /// counts them. + fn pin_host(status: u16, body: String) -> (SocketAddr, Arc) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let n = count.clone(); + std::thread::spawn(move || { + http::serve_with_peer_check( + listener, + |_| true, + move |_, _| { + n.fetch_add(1, Ordering::SeqCst); + http::Response { + status, + body: body.clone(), + } + }, + ) + }); + (addr, count) + } + + fn hand_off() -> Arc { + let progress = Arc::new(Mutex::new(Progress { + generation: 7, + ..Default::default() + })); + Arc::new(PinHandOff::new( + "stuzha", + "1234", + progress, + 7, + egui::Context::default(), + )) + } + + fn start(h: &Arc, host: SocketAddr) -> std::thread::JoinHandle<()> { + let h = h.clone(); + std::thread::spawn(move || h.run(host)) + } + + /// Wait until `done` holds, no longer than five seconds: what a test + /// waits for comes within milliseconds, but a loaded CI runner (or + /// Windows, which takes a second to refuse a connection) is slower. + fn soon(done: impl Fn() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + while !done() { + if Instant::now() > deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } + true + } + + /// Wait for the hand-off thread, no longer than `within`. + fn ends_within(t: std::thread::JoinHandle<()>, within: Duration) -> bool { + let deadline = Instant::now() + within; + while !t.is_finished() { + if Instant::now() > deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } + true + } + + #[test] + fn a_hosts_reason_is_shown_and_the_hand_off_stops_when_told() { + let (host, _) = pin_host( + 502, + serde_json::to_string(&Ack::err("engine says no")).unwrap(), + ); + let h = hand_off(); + let t = start(&h, host); + let want = "Latch on stuzha couldn't enter the PIN yet: engine says no"; + assert!( + soon(|| h.progress.lock().detail == want), + "{}", + h.progress.lock().detail + ); + assert!(!t.is_finished(), "a passing refusal is offered again"); + h.finish(); + assert!(ends_within(t, Duration::from_secs(3))); + assert_eq!(h.progress.lock().detail, "", "the warning goes with it"); + assert!(h.refused.lock().is_none()); + } + + #[test] + fn a_refusal_for_good_ends_the_hand_off_with_its_reason() { + let (host, asked) = pin_host( + 403, + serde_json::to_string(&Ack::err("not a machine on this PC's Tailscale account")) + .unwrap(), + ); + let h = hand_off(); + let t = start(&h, host); + assert!(ends_within(t, Duration::from_secs(3))); + assert_eq!(asked.load(Ordering::SeqCst), 2, "asked twice, then no more"); + assert_eq!( + h.refused.lock().as_deref(), + Some("not a machine on this PC's Tailscale account") + ); + } + + #[test] + fn a_taken_pin_ends_the_hand_off_at_once() { + let (host, asked) = pin_host(200, serde_json::to_string(&Ack::ok()).unwrap()); + let h = hand_off(); + assert!(ends_within(start(&h, host), Duration::from_millis(500))); + assert_eq!(asked.load(Ordering::SeqCst), 1); + assert_eq!(h.progress.lock().detail, ""); + } + + #[test] + fn only_silence_reads_as_isnt_answering_and_cancel_stops_it() { + // Port 0 can never be connected to; a port freed by dropping a + // listener can be taken by a test running beside this one. + let closed: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let h = hand_off(); + let t = start(&h, closed); + assert!( + soon(|| h.progress.lock().detail.contains("isn't answering")), + "{}", + h.progress.lock().detail + ); + h.progress.lock().cancel = true; + assert!(ends_within(t, Duration::from_secs(3))); + } + + #[test] + fn a_fixed_desktop_is_asked_for_at_its_own_size_and_frame_rate() { + // A VPS draws 1920 × 1080 and encodes in software on two cores. + let vps = (Some([1920, 1080]), 30); + assert_eq!( + fit_to_host((2284, 1208), 60, vps.0, vps.1), + (1920, 1080, 30) + ); + assert_eq!( + fit_to_host((1920, 1080), 30, vps.0, vps.1), + (1920, 1080, 30) + ); + assert_eq!( + fit_to_host((3840, 2160), 24, vps.0, vps.1), + (1920, 1080, 24) + ); + assert_eq!( + fit_to_host((2560, 1080), 30, vps.0, vps.1), + (1920, 1080, 30), + "wider than the desktop is scaling up too" + ); + // A smaller size is scaled down, and fewer pixels keep up with more + // frames: 720p is 2.25 times fewer than 1080p. + assert_eq!(fit_to_host((1280, 720), 60, vps.0, vps.1), (1280, 720, 60)); + assert_eq!(fit_to_host((1600, 900), 60, vps.0, vps.1), (1600, 900, 43)); + assert_eq!(fit_to_host((960, 540), 144, vps.0, vps.1), (960, 540, 120)); + // A desktop that takes the viewer's size: the ceiling is for 1080p. + let follows = (None, 30); + assert_eq!( + fit_to_host((1280, 720), 60, follows.0, follows.1), + (1280, 720, 60) + ); + assert_eq!( + fit_to_host((1920, 1080), 60, follows.0, follows.1), + (1920, 1080, 30) + ); + assert_eq!( + fit_to_host((3024, 1964), 60, follows.0, follows.1), + (3024, 1964, 30), + "a larger picture keeps the host's ceiling" + ); + // A Windows PC matches the viewer's size itself and has no ceiling. + assert_eq!(fit_to_host((2284, 1208), 60, None, 0), (2284, 1208, 60)); + // Nonsense from a host is ignored. + assert_eq!( + fit_to_host((1920, 1080), 60, Some([0, 0]), 0), + (1920, 1080, 60) + ); + assert_eq!( + fit_to_host((1920, 1080), 60, Some([100_000, 1080]), 0), + (1920, 1080, 60) + ); + } + + #[test] + fn a_target_carries_the_hosts_limits_and_os() { + let pc = Pc { + node_id: "n".into(), + name: "artemis".into(), + os: "linux".into(), + ip: Some("100.64.0.9".parse().unwrap()), + online: true, + host: Some(Status { + streamer: latch_core::api::Streamer { + capture_size: Some([1920, 1080]), + max_fps: 30, + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + let t = Target::from_pc(&pc).unwrap(); + assert_eq!(t.os, "linux"); + assert_eq!(t.capture_size, Some([1920, 1080])); + assert_eq!(t.max_fps, 30); + let old = Target::from_pc(&Pc { host: None, ..pc }).unwrap(); + assert_eq!((old.capture_size, old.max_fps), (None, 0)); + } + #[test] fn update_reservation_blocks_connect_in_every_idle_state() { for step in [Step::Idle, Step::Ended { error: None }] { @@ -988,6 +2052,132 @@ mod tests { } /// `cargo test -p latch-client scan_real -- --ignored --nocapture` + /// Connects to `LATCH_TEST_PC` (its Tailscale name) through the + /// window's own path, once for each of `LATCH_TEST_RESOLUTIONS` in turn + /// ("540p,720p,1080p,native"), with this Mac's identity and pairing, and + /// leaves the PC's Desktop running as the window does. At each size it + /// puts the PC's pointer at the stream's centre and a quarter in, then + /// moves it 100 stream pixels right, running `LATCH_TEST_PROBE` (a shell + /// command, say one that prints the pointer and the display size over + /// ssh) after each step. + #[test] + #[ignore = "needs Tailscale and a paired PC"] + fn connect_real() { + use crate::config::{Quality, Resolution}; + let _ = tracing_subscriber::fmt().with_target(false).try_init(); + let name = std::env::var("LATCH_TEST_PC").expect("LATCH_TEST_PC"); + let probe = || { + let Ok(cmd) = std::env::var("LATCH_TEST_PROBE") else { + return; + }; + std::thread::sleep(Duration::from_millis(400)); + let out = std::process::Command::new("sh") + .args(["-c", &cmd]) + .output() + .expect("probe"); + eprint!(" probe: {}", String::from_utf8_lossy(&out.stdout)); + }; + let list = std::env::var("LATCH_TEST_RESOLUTIONS").unwrap_or_else(|_| "720p".into()); + for label in list.split(',') { + let resolution = Resolution::ALL + .into_iter() + .find(|r| r.label() == label || (label == "native" && *r == Resolution::Native)) + .unwrap_or_else(|| panic!("no resolution {label}")); + // Afresh each time, as the window does, so a pairing made by the + // first connect is used by the next. + let cfg = ClientConfig::load(); + let discovery = scan(&cfg); + let pc = discovery + .pcs + .iter() + .find(|p| p.name == name) + .unwrap_or_else(|| panic!("{name} is not on the tailnet")); + let c = Connect { + target: Target::from_pc(pc).expect("target"), + settings: StreamSettings { + quality: Quality::Custom, + resolution, + fps: 60, + bitrate_kbps: 10_000, + ..cfg.stream.clone() + }, + restart_capture: false, + native: (3024, 1964), + progress: Arc::default(), + live: Arc::default(), + ctx: egui::Context::default(), + }; + let generation = NEXT_CONNECT.fetch_add(1, Ordering::Relaxed); + c.progress.lock().generation = generation; + run(&c, generation).expect("connect"); + let live = c.live.lock().take().expect("a stream"); + let start = Instant::now(); + loop { + match live.events.recv_timeout(Duration::from_secs(20)) { + Ok(Event::Connected) => break, + Ok(Event::Failed { .. } | Event::Terminated { .. }) | Err(_) => { + panic!("no stream at {label}") + } + Ok(_) => {} + } + } + let (w, h, fps) = live.requested; + eprintln!( + "{label}: asked {w}x{h} at {fps} fps, desktop {:?}, connected in {:?}", + live.desktop, + start.elapsed() + ); + std::thread::sleep(Duration::from_secs(2)); + let (iw, ih) = (w as i16, h as i16); + eprintln!(" pointer to the centre, {}x{}", w / 2, h / 2); + live.input.mouse_position(iw / 2, ih / 2, iw, ih); + probe(); + eprintln!(" pointer to a quarter in, {}x{}", w / 4, h / 4); + live.input.mouse_position(iw / 4, ih / 4, iw, ih); + probe(); + eprintln!(" 100 stream pixels right, in steps of 10"); + for _ in 0..10 { + live.input.mouse_move(10, 0); + std::thread::sleep(Duration::from_millis(16)); + } + probe(); + eprintln!(" 100 stream pixels right at once"); + live.input.mouse_move(100, 0); + probe(); + // Letters, digits and spaces as key presses, then Return. + if let Ok(line) = std::env::var("LATCH_TEST_TYPE") { + eprintln!(" typing {line:?}"); + for c in line.chars().map(|c| c.to_ascii_uppercase()) { + assert!(c.is_ascii_alphanumeric() || c == ' ', "can't type {c:?}"); + for down in [true, false] { + live.input.key(c as i16, down, 0); + std::thread::sleep(Duration::from_millis(15)); + } + } + for down in [true, false] { + live.input.key(crate::input::VK_RETURN, down, 0); + } + probe(); + } + let seconds: u64 = + std::env::var("LATCH_TEST_SECONDS").map_or(0, |s| s.parse().expect("seconds")); + for second in 1..=seconds { + std::thread::sleep(Duration::from_secs(1)); + let s = live.session.stats(); + eprintln!( + " {second:>2}s {:.1} fps, {:.1} Mbps, loss {:.1}%, host {:.1} ms", + s.fps, s.mbps, s.loss_pct, s.host_ms + ); + } + live.session.stop(); + let t = Instant::now(); + while !live.session.finished() && t.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(20)); + } + drop(live); + } + } + #[test] #[ignore = "needs Tailscale"] fn scan_real_tailnet() { diff --git a/crates/client/src/settings.rs b/crates/client/src/settings.rs index b9d0a38..d21b18a 100644 --- a/crates/client/src/settings.rs +++ b/crates/client/src/settings.rs @@ -62,16 +62,11 @@ pub fn stream_controls(ui: &mut egui::Ui, s: &mut StreamSettings, native: (u32, ui::setting_row( ui, "Resolution", - Some("Match screen fills this display exactly. The 16:9 sizes leave bars on a display of another shape."), + Some("Match screen fills this display exactly. The 16:9 sizes leave bars on a display of another shape. Below 1080p, a PC that encodes in software keeps a higher frame rate."), |ui| { let mut resolution = s.resolution; ui::select(ui, "stream-resolution", resolution.describe(native), 200.0, |ui| { - for r in [ - Resolution::Native, - Resolution::P1080, - Resolution::P1440, - Resolution::P2160, - ] { + for r in Resolution::ALL { ui::select_option(ui, &mut resolution, r, r.describe(native)); } }); diff --git a/crates/client/src/share.rs b/crates/client/src/share.rs index 17b4cd2..8ee5b34 100644 --- a/crates/client/src/share.rs +++ b/crates/client/src/share.rs @@ -14,6 +14,9 @@ pub struct LocalShare { pub setup_result: Option>, /// Someone stopped the background service from the Sharing page. pub service_stopped: bool, + /// The owner turned Share this Mac off: this Mac only connects to the + /// others, and its service is off on purpose. + pub viewer_only: bool, } pub type Slot = Arc>; diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 6d5d416..827baa6 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -10,14 +10,14 @@ //! menus and the panels are only laid out while they show, and every //! timed notice asks for a bounded, slow repaint only while it is up. -use crate::clipboard; +use crate::clipboard::{self, Edit}; use crate::config::{ClientConfig, StreamSettings}; -use crate::input::{self, press_chord, Held}; +use crate::input::{self, Held, Sink}; use crate::path::Path; use crate::session::Live; use crate::video; use egui::{ - Align2, Color32, CursorIcon, Event, Frame, Id, Margin, Pos2, Rect, RichText, Vec2, + Align2, Color32, CursorIcon, Event, Frame, Id, Margin, Modifiers, Pos2, Rect, RichText, Vec2, ViewportCommand, }; use latch_core::api::PowerAction; @@ -36,6 +36,9 @@ const STATUS: f32 = 24.0; const TOAST_FOR: Duration = Duration::from_secs(5); /// How long the host-key hint stays up when a stream starts. const HINT_FOR: Duration = Duration::from_secs(6); +/// How long a paste's chord, and what is typed after it, wait for its text +/// to reach the PC before they go anyway. +const PASTE_WAIT: Duration = Duration::from_secs(2); /// Each side of an overlay frame (its inner margin) and the picture it /// leaves visible beyond that. const OVERLAY_PAD: f32 = 12.0; @@ -58,16 +61,35 @@ pub fn host_key() -> (&'static [&'static str], &'static str) { /// What to call the other machine in the toolbar: its power menu reads /// "PC" for Windows, as the lobby's messages expect. fn machine_noun(os: &str) -> &'static str { - let os = os.to_ascii_lowercase(); - if os.starts_with("windows") { + if os.to_ascii_lowercase().starts_with("windows") { "PC" - } else if os.starts_with("macos") || os.starts_with("darwin") { + } else if is_mac_os(os) { "Mac" } else { "Machine" } } +/// Whether the machine's menu has anything in it: power actions its owner +/// allows, or an update for an old host. +fn has_power_items(env: &Env<'_>) -> bool { + env.power_allowed || env.old_host.is_some() +} + +/// What a power action costs, said before it is sent to `pc`. +fn power_question(action: PowerAction, pc: &str) -> String { + match action { + PowerAction::Sleep => format!("The stream ends once {pc} agrees. Asleep, it can only be woken from its own network, or through a router that forwards wake packets to it."), + PowerAction::Restart | PowerAction::Shutdown => format!("The stream ends once {pc} agrees. Programs there close without asking, and anything unsaved is lost."), + } +} + +/// Whether Tailscale's OS string names a Mac. +fn is_mac_os(os: &str) -> bool { + let os = os.to_ascii_lowercase(); + os.starts_with("macos") || os.starts_with("darwin") +} + #[derive(Debug, Clone, PartialEq)] pub enum Action { Disconnect, @@ -100,6 +122,10 @@ pub struct Env<'a> { pub handover: Option, /// What the PC said about a black picture, once it has answered. pub video_help: Option, + /// Latch Host there takes power actions from this Mac. + pub power_allowed: bool, + /// Sleep is offered: something can wake the machine again. + pub can_sleep: bool, } struct Toast { @@ -128,16 +154,75 @@ pub struct View { toasts: Vec, /// When the stream started, for the host-key hint. hint_since: Option, - /// Pastes seen through `clipboard.pastes_done()`. - pastes_seen: u32, /// Ctrl+Alt was down when capture last toggled; ignore further edges /// until both keys are up. `release_all` clears `Held.modifiers`, so /// deriving "was down" from that retriggered the toggle every frame. capture_chord_held: bool, + /// A paste whose text is still on its way to the PC. + pending: Option, + /// Where the pointer went in free mode, not yet sent. + moved_to: Option, + /// Since V was last let go: a paste was made, and ⌘ was down. A ⌘V + /// egui swallowed (the Mac's clipboard holds no text) shows only as a V + /// let go that the PC never saw pressed, with ⌘ seen and no paste. + pasted_since_v_up: bool, + cmd_since_v_up: bool, + /// The other machine is a Mac: ⌘ is Command there, whatever the + /// "Command acts as Ctrl" setting says for Windows and Linux. + host_mac: bool, + /// The Mac's Caps Lock as the PC was last brought in line with it (see + /// [`caps_tap`]); None until the stream first has the keys. + caps: Option, + /// Caps Lock as a test sets it, in place of the Mac's. + #[cfg(test)] + caps_now: Option, /// A video problem to show in place of the session's, for rendering /// the recovery overlay without a broken capture. #[cfg(test)] problem: Option, + /// Where input goes in tests instead of the PC, and whether the + /// stream counts as connected. + #[cfg(test)] + tap: Option>, +} + +/// A paste whose text is on its way to the PC's clipboard. Its chord, and +/// everything typed or clicked after it, wait for the text; a key let go +/// before the chord would otherwise run a command on the old line (⌘V +/// then Return, 300 ms away from a PC in Korea). +struct PendingPaste { + id: u64, + until: Instant, + queued: Vec, +} + +/// Input held back behind a paste. +enum Queued { + /// An event, with where it was going when it arrived. + Event(Event, At), + /// The modifiers held at the end of a frame: egui has no events for + /// Shift, Ctrl, Option or ⌘ on their own. + Modifiers(Modifiers), + /// A key pressed and let go, for one egui never reports (Caps Lock). + Tap(i16), +} + +/// Where an event was going when it arrived. +#[derive(Debug, Clone, Copy)] +struct At { + keys_to_pc: bool, + over_stream: bool, +} + +/// What this frame's input is sent through, and what it needs to know. +struct Link<'a, S: Sink + ?Sized> { + ctx: &'a egui::Context, + live: &'a Live, + cfg: &'a ClientConfig, + out: &'a S, + connected: bool, + video: Rect, + ppp: f32, } impl Default for View { @@ -158,10 +243,19 @@ impl Default for View { poor_hinted: false, toasts: Vec::new(), hint_since: None, - pastes_seen: 0, capture_chord_held: false, + pending: None, + moved_to: None, + pasted_since_v_up: false, + cmd_since_v_up: false, + host_mac: false, + caps: None, + #[cfg(test)] + caps_now: None, #[cfg(test)] problem: None, + #[cfg(test)] + tap: None, } } } @@ -195,9 +289,31 @@ impl View { /// Release everything and let the cursor go; called when the stream /// ends or the window loses focus. pub fn reset(&mut self, ctx: &egui::Context, live: Option<&Live>) { - if let Some(l) = live { - self.held.release_all(&l.input); + self.reset_with(ctx, live, live.map(|l| &l.input)); + } + + fn reset_with( + &mut self, + ctx: &egui::Context, + live: Option<&Live>, + out: Option<&S>, + ) { + // A paste still waiting goes, with what was held back behind it: + // none of it has reached the PC, and pasting the PC's old clipboard + // after the user has gone would be worse than nothing. + if let Some(p) = self.pending.take() { + if let Some(l) = live { + l.clipboard.give_up(p.id); + } + } + self.moved_to = None; + self.pasted_since_v_up = false; + self.cmd_since_v_up = false; + if let Some(out) = out { + self.held.release_all(out); } + crate::macos::stream_owns_keys(false); + ctx.options_mut(|o| o.zoom_with_keyboard = true); self.set_captured(ctx, false); self.bar_shown = false; self.confirm = None; @@ -225,7 +341,7 @@ impl View { }; ctx.send_viewport_cmd(ViewportCommand::CursorGrab(grab)); // The cursor is hidden through `set_cursor_icon(None)` alone, - // every frame the pointer is on the picture. A + // every frame the pointer is in the stream viewport. A // `CursorVisible(true)` here would show it behind egui's // back: egui only re-applies an icon when it changes, so the // Mac cursor would stay on top of the PC's until the pointer @@ -236,6 +352,7 @@ impl View { pub fn show(&mut self, ctx: &egui::Context, env: &Env<'_>) -> Vec { let live = env.live; let mut actions = Vec::new(); + self.host_mac = is_mac_os(&env.os); let screen = ctx.screen_rect(); let stats = live.session.stats(); let (vw, vh) = if stats.width > 0 { @@ -268,7 +385,7 @@ impl View { ), )); } - if bottom_gap >= STATUS || self.stats { + if self.stats { self.status_line(ui, screen, video, env, &stats, bottom_gap >= STATUS); } }); @@ -442,6 +559,10 @@ impl View { ui.add_space(space::SM); let rows = [ Kv::new("Resolution", format!("{} × {}", stats.width, stats.height)), + Kv::new( + "Requested resolution", + format!("{} × {}", live.requested.0, live.requested.1), + ), Kv::new( "Frame rate", format!("{:.1} of {} fps", stats.fps, live.requested.2), @@ -566,7 +687,7 @@ impl View { self.keys_menu(ui, live, cfg, actions); self.stats_button(ui); self.fullscreen_button(ui, env, actions); - self.power_menu(ui, env, actions); + self.power_menu(ui, env); } if ui::ghost_button(ui, "Stream settings").clicked() { self.held.release_all(&live.input); @@ -596,10 +717,7 @@ impl View { .font(theme::semibold(theme::text::TITLE)) .color(P.text), ); - ui::muted( - ui, - "The stream ends first. Programs there close without asking, and anything unsaved is lost.", - ); + ui::muted(ui, power_question(action, &live.pc)); ui.add_space(space::XS); ui.horizontal(|ui| { if ui::destructive_button(ui, action.label()).clicked() { @@ -675,8 +793,10 @@ impl View { self.mouse_items(ui, ctx, live, cfg, actions); ui::menu_label(ui, "Send keys"); self.key_items(ui, live, cfg, actions); - ui::menu_label(ui, machine_noun(&env.os)); - self.power_items(ui, env, actions); + if has_power_items(env) { + ui::menu_label(ui, machine_noun(&env.os)); + self.power_items(ui, env); + } }); } @@ -704,24 +824,33 @@ impl View { } } - fn power_menu(&mut self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { - ui::menu_button(ui, machine_noun(&env.os), |ui| { - self.power_items(ui, env, actions) - }); + fn power_menu(&mut self, ui: &mut egui::Ui, env: &Env<'_>) { + if !has_power_items(env) { + return; + } + ui::menu_button(ui, machine_noun(&env.os), |ui| self.power_items(ui, env)); } - fn power_items(&mut self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { - if ui::menu_item(ui, "Sleep").clicked() { - actions.push(Action::Power(PowerAction::Sleep)); - } - if ui::menu_item(ui, "Restart…").clicked() { - self.confirm = Some(PowerAction::Restart); - } - if ui::menu_item(ui, "Shut down…").clicked() { - self.confirm = Some(PowerAction::Shutdown); + /// Sleep, Restart and Shut down where the machine's owner allows them, + /// each asked about first: a slip in this menu must not end the stream + /// or put to sleep a machine that nothing here can wake. The stream + /// stays up until the machine has agreed. + fn power_items(&mut self, ui: &mut egui::Ui, env: &Env<'_>) { + if env.power_allowed { + if env.can_sleep && ui::menu_item(ui, "Sleep…").clicked() { + self.confirm = Some(PowerAction::Sleep); + } + if ui::menu_item(ui, "Restart…").clicked() { + self.confirm = Some(PowerAction::Restart); + } + if ui::menu_item(ui, "Shut down…").clicked() { + self.confirm = Some(PowerAction::Shutdown); + } } if let Some((running, _)) = &env.old_host { - ui::menu_separator(ui); + if env.power_allowed { + ui::menu_separator(ui); + } let label = format!("Update Latch Host… (runs {running})"); if ui::menu_item_enabled(ui, env.handover.is_none(), &label) .on_hover_text("Installs the newest Latch Host through this stream, so later updates arrive by themselves.") @@ -785,8 +914,10 @@ impl View { } if cfg!(target_os = "macos") { ui::menu_separator(ui); - if ui::menu_choice(ui, "Command acts as Ctrl", cfg.cmd_is_ctrl) - .on_hover_text("On: Command-C, V and Z copy, paste and undo there. Off: Command is the Windows key.") + if self.host_mac { + ui::menu_note(ui, "Command is Command on another Mac."); + } else if ui::menu_choice(ui, "Command acts as Ctrl", cfg.cmd_is_ctrl) + .on_hover_text("On: Command-C, V and Z copy, paste and undo there. Off: Command is the Windows key. A Mac there always gets Command.") .clicked() { actions.push(Action::ToggleCmd); @@ -836,7 +967,7 @@ impl View { && cfg.capture_mouse { actions.push(Action::MouseCapture(false)); - self.release(ctx, live); + self.release(ctx, &live.input); } } @@ -989,31 +1120,43 @@ impl View { } /// Capture the mouse and take the toolbar away: the picture is all. - fn grab(&mut self, ctx: &egui::Context, live: &Live) { - self.held.release_all(&live.input); + fn grab(&mut self, ctx: &egui::Context, out: &(impl Sink + ?Sized)) { + self.held.release_all(out); self.motion = (0.0, 0.0); + // A captured mouse sends every click to the PC, while egui still + // sees them at the frozen pointer: a panel, question or menu left + // open would take them too ("Apply and reconnect", "Restart"). + // Closing them is Cancel. + self.settings = None; + self.confirm = None; + self.confirm_install = false; + ctx.memory_mut(|m| m.close_popup()); self.set_captured(ctx, true); self.bar_shown = false; } /// Give the mouse back. - fn release(&mut self, ctx: &egui::Context, live: &Live) { - self.held.release_all(&live.input); + fn release(&mut self, ctx: &egui::Context, out: &(impl Sink + ?Sized)) { + self.held.release_all(out); self.set_captured(ctx, false); } /// Ctrl+Alt, the host key. From the picture it frees the mouse and /// drops the toolbar; from the toolbar it hides it and, when capture /// is on, takes the mouse back. - fn host_key(&mut self, ctx: &egui::Context, live: &Live, cfg: &ClientConfig) { + fn host_key(&mut self, ctx: &egui::Context, cfg: &ClientConfig, out: &(impl Sink + ?Sized)) { self.hint_since = None; + // Everything goes up first, whichever way this goes: while the + // chord is down every event is swallowed, a key let go meanwhile + // included, and it would stay down on the PC. + self.held.release_all(out); if self.captured || !self.bar_shown { - self.release(ctx, live); + self.release(ctx, out); self.bar_shown = true; } else { self.bar_shown = false; if cfg.capture_mouse { - self.grab(ctx, live); + self.grab(ctx, out); } } } @@ -1030,7 +1173,7 @@ impl View { // it. The click-to-capture path stays for re-capturing after a free. let focused = ctx.input(|i| i.focused); if capture && focused { - self.grab(ctx, live); + self.grab(ctx, &live.input); } self.hint_since = Some(Instant::now()); } @@ -1044,7 +1187,34 @@ impl View { video: Rect, bar: Option, ) { - let input = &live.input; + #[cfg(test)] + if let Some(tap) = self.tap.clone() { + let connected = tap.connected.get(); + return self.forward(ctx, live, cfg, video, bar, &*tap, connected); + } + self.forward( + ctx, + live, + cfg, + video, + bar, + &live.input, + live.input.connected(), + ); + } + + /// [`View::input`], sending through `out`. + #[allow(clippy::too_many_arguments)] + fn forward( + &mut self, + ctx: &egui::Context, + live: &Live, + cfg: &ClientConfig, + video: Rect, + bar: Option, + out: &S, + connected: bool, + ) { let (events, modifiers, pointer, focused) = ctx.input(|i| { ( i.events.clone(), @@ -1060,120 +1230,112 @@ impl View { ctx.layer_id_at(p) .is_some_and(|layer| layer.order > egui::Order::Background) }); - let over_video = pointer_on_stream( - pointer.is_some_and(|p| video.contains(p)), + // The black bars are part of the stream too. Only Latch's controls + // should take the pointer away from the remote machine. + let over_stream = pointer_on_stream( + pointer.is_some_and(|p| ctx.screen_rect().contains(p)), over_bar, popup, overlay, over_overlay, ); - if self.captured && !popup && !overlay { + // While the keys belong to the PC, egui must not hold a focus: Tab + // would move it to the Stats panel or a notice's button, and every + // key after would stay in Latch. With the toolbar up, Tab walks + // the toolbar and its panels as it should; before the first + // picture it reaches Cancel. + let keys_belong_to_pc = self.captured || (bar.is_none() && live.frames.seq() > 0); + if keys_belong_to_pc && !popup && !overlay { ctx.memory_mut(|m| { if let Some(id) = m.focused() { m.surrender_focus(id); } }); } - let keys_to_pc = - focused && !ctx.wants_keyboard_input() && !popup && !overlay && input.connected(); + let keys_to_pc = focused && !ctx.wants_keyboard_input() && !popup && !overlay && connected; + // ⌘Q and ⌘H belong to the PC too while it has the keys, and so do + // ⌘+, ⌘- and ⌘0: egui would zoom Latch's own window on them while + // they zoomed the browser on the PC. + crate::macos::stream_owns_keys(keys_to_pc); + if ctx.options(|o| o.zoom_with_keyboard) == keys_to_pc { + ctx.options_mut(|o| o.zoom_with_keyboard = !keys_to_pc); + } let ppp = ctx.pixels_per_point(); let motion_scale = { let stats = live.session.stats(); - let stream_w = if stats.width > 0 { - stats.width as f32 + let stream = if stats.width > 0 && stats.height > 0 { + (stats.width, stats.height) } else { - live.requested.0 as f32 + (live.requested.0, live.requested.1) }; if video.width() >= 1.0 { - stream_w / video.width() + stream.0 as f32 / video.width() * desktop_per_stream_pixel(live.desktop, stream) } else { ppp } }; + let link = Link { + ctx, + live, + cfg, + out, + connected, + video, + ppp, + }; + live.clipboard + .note_mac_clipboard(crate::macos::pasteboard_changes); if !focused { - if self.held.any_down() || self.captured { - self.reset(ctx, Some(live)); + if self.held.any_down() || self.captured || self.pending.is_some() { + self.reset_with(ctx, Some(live), Some(out)); } return; } + // A paste whose text has arrived goes before anything this frame. + self.settle_paste(&link, false); + // Caps Lock, which egui never reports, as a tap of the PC's own. + let (caps, tap) = caps_tap(self.caps, self.mac_caps(), keys_to_pc); + self.caps = caps; + if tap { + self.dispatch(&link, Queued::Tap(input::VK_CAPITAL)); + } // Ctrl+Alt is Latch's host key. It is read before the keys-to-PC // gate so it works even if an overlay took focus, and while it is // held nothing is forwarded: the PC must not see a held chord after - // the toolbar opens (games and the Start menu react to one). + // the toolbar opens (games and the Start menu react to one), nor a + // lone Alt when Control is let go first (Windows opens the menu bar). + // Before the stream connects it can still free a captured mouse. let ctrl_alt = modifiers.ctrl && modifiers.alt; - if ctrl_alt { - if !self.capture_chord_held && input.connected() { + if ctrl_alt || (self.capture_chord_held && (modifiers.ctrl || modifiers.alt)) { + if ctrl_alt && !self.capture_chord_held && (connected || self.captured) { self.capture_chord_held = true; - self.host_key(ctx, live, cfg); + self.settle_paste(&link, true); + if connected { + self.host_key(ctx, cfg, out); + } else { + self.release(ctx, out); + } } return; } self.capture_chord_held = false; - if keys_to_pc { - self.held.modifiers(input, modifiers, cfg.cmd_is_ctrl); - // A paste's chord releases the modifier it pressed once the text - // has reached the PC. If ⌘ is still held here, press it again - // on the PC, so ⌘V ⌘V in one hold pastes twice. - let done = live.clipboard.pastes_done(); - if done != self.pastes_seen { - self.pastes_seen = done; - for vk in [input::VK_CONTROL, input::VK_LWIN] { - if self.held.is_down(vk) { - input.key(vk, true, self.held.mask()); - } - } - } - } else if self.held.any_down() { - self.held.release_all(input); + if !keys_to_pc && self.held.any_down() { + self.held.release_all(out); } - if (over_video || self.captured) && input.connected() { + if (over_stream || self.captured) && connected { ctx.set_cursor_icon(CursorIcon::None); } - let mut position: Option = None; + let at = At { + keys_to_pc, + over_stream, + }; for ev in events { match ev { - Event::Key { - physical_key, - key, - pressed, - repeat, - .. - } if keys_to_pc && (pressed || !repeat) => { - // OS autorepeat arrives as extra Downs with `repeat`. - // Sunshine does not generate them, so holding a key - // would otherwise type once. Releases never repeat. - if let Some(vk) = physical_key.and_then(input::vk).or_else(|| input::vk(key)) { - self.held.key(input, vk, pressed); - } - } - Event::Copy if keys_to_pc => { - self.clipboard_chord(live, input::VK_C, cfg); - live.clipboard.poke(); - } - Event::Cut if keys_to_pc => { - self.clipboard_chord(live, input::VK_X, cfg); - live.clipboard.poke(); - } - Event::Paste(text) if keys_to_pc => { - // The Mac's text first, then the paste: the PC pastes - // what was copied here, not what it had. The chord is - // sent whole: by the time the text has crossed the - // network a quick tap has let go of ⌘. - let keys = vec![self.clipboard_modifier(cfg), input::VK_V]; - let mask = self.held.mask(); - let handle = input.clone(); - let text = if clipboard::worth_sending(&text) { - text - } else { - String::new() - }; - live.clipboard - .push(text, move || press_chord(&handle, &keys, mask)); - } - Event::PointerMoved(p) if !self.captured && over_video => position = Some(p), + // The pointer is never held back behind a paste. + Event::PointerMoved(p) if !self.captured && over_stream => self.moved_to = Some(p), Event::MouseMoved(d) if self.captured => { // Deltas arrive in points; the PC moves in stream pixels. // Scale by the picture's size here so a hand movement @@ -1182,67 +1344,231 @@ impl View { self.motion.0 += d.x * motion_scale; self.motion.1 += d.y * motion_scale; } - Event::PointerButton { - button, pressed, .. - } if self.captured || over_video || (!pressed && self.held.any_down()) => { - // A click on the picture puts the toolbar away and, when - // capture is on, takes the mouse. The click itself still - // goes to the PC: what was clicked on is what was meant. - if pressed && over_video && !self.captured { - self.bar_shown = false; - if cfg.capture_mouse { - self.grab(ctx, live); - } - } - if let Some(b) = input::button(button) { - self.held.button(input, b, pressed); - } - } - Event::MouseWheel { unit, delta, .. } if self.captured || over_video => { - let scale = match unit { - egui::MouseWheelUnit::Point => 3.0, - egui::MouseWheelUnit::Line => 120.0, - egui::MouseWheelUnit::Page => 1200.0, - }; - self.scroll.0 += delta.x * scale; - self.scroll.1 += delta.y * scale; + Event::MouseWheel { unit, delta, .. } if self.captured || over_stream => { + let (h, v) = wheel_to_host(unit, delta); + self.scroll.0 += h; + self.scroll.1 += v; } - Event::WindowFocused(false) => self.reset(ctx, Some(live)), - _ => {} + Event::WindowFocused(false) => self.reset_with(ctx, Some(live), Some(out)), + ev => self.dispatch(&link, Queued::Event(ev, at)), } } - if let Some(p) = position { - let x = ((p.x - video.left()) * ppp).round() as i16; - let y = ((p.y - video.top()) * ppp).round() as i16; - input.mouse_position( - x, - y, - (video.width() * ppp).round() as i16, - (video.height() * ppp).round() as i16, - ); + // egui reports a modifier pressed or let go on its own only in its + // state at the end of the frame. + if keys_to_pc { + self.dispatch(&link, Queued::Modifiers(modifiers)); } - if self.captured && (self.motion.0.abs() >= 1.0 || self.motion.1.abs() >= 1.0) { - let dx = self.motion.0.trunc(); - let dy = self.motion.1.trunc(); - self.motion.0 -= dx; - self.motion.1 -= dy; - input.mouse_move(dx as i16, dy as i16); + if let Some(p) = self.moved_to.take() { + self.send_position(&link, p); } + self.flush_motion(out); if self.scroll.0.abs() >= 1.0 || self.scroll.1.abs() >= 1.0 { let h = self.scroll.0.trunc(); let v = self.scroll.1.trunc(); self.scroll.0 -= h; self.scroll.1 -= v; - input.scroll( + out.scroll( v.clamp(-32000.0, 32000.0) as i16, h.clamp(-32000.0, 32000.0) as i16, ); } + if let Some(p) = &self.pending { + ctx.request_repaint_after(p.until.saturating_duration_since(Instant::now())); + } + } + + /// Send `item` now, or hold it behind a paste still on its way. + fn dispatch(&mut self, link: &Link<'_, S>, item: Queued) { + match &mut self.pending { + Some(p) => p.queued.push(item), + None => self.handle(link, item), + } + } + + /// Send one event to the PC, as it stood when it arrived. + fn handle(&mut self, link: &Link<'_, S>, item: Queued) { + let out = link.out; + let cmd_is_ctrl = self.cmd_is_ctrl(link.cfg); + let (ev, at) = match item { + Queued::Modifiers(m) => { + self.cmd_since_v_up |= m.command; + self.held.modifiers(out, m, cmd_is_ctrl); + return; + } + Queued::Tap(vk) => { + self.held.key(out, vk, true); + self.held.key(out, vk, false); + return; + } + Queued::Event(ev, at) => (ev, at), + }; + match ev { + Event::Key { + physical_key, + key, + pressed, + repeat, + modifiers: m, + } if at.keys_to_pc && (pressed || !repeat) => { + // The host key never reaches the PC, even when it was + // pressed and let go inside one frame. + if m.ctrl && m.alt { + return; + } + // Each key goes with the modifiers held when it was pressed: + // a Shift or ⌘ let go in the same frame came after it. + self.cmd_since_v_up |= m.command; + self.held.modifiers(out, m, cmd_is_ctrl); + // OS autorepeat arrives as extra Downs with `repeat`. + // Sunshine does not generate them, so holding a key + // would otherwise type once. Releases never repeat. + let Some(vk) = physical_key.and_then(input::vk).or_else(|| input::vk(key)) else { + return; + }; + let seen = self.held.is_down(vk); + if key == egui::Key::V && !pressed { + let pasted = std::mem::take(&mut self.pasted_since_v_up); + let cmd = std::mem::take(&mut self.cmd_since_v_up); + if swallowed_paste(seen, pasted, cmd) { + // A paste from an image or nothing on the Mac: the + // PC pastes what it has. It goes on the key's + // release, the first sign of it. + self.paste(link, String::new()); + } + } + if !pressed && !seen { + // The PC never saw it go down: egui took the press + // for ⌘C, ⌘X or ⌘V, or it was let go already. + return; + } + self.held.key(out, vk, pressed); + } + Event::Copy if at.keys_to_pc => { + self.clipboard_chord(link, Edit::Copy); + link.live.clipboard.poke(); + } + Event::Cut if at.keys_to_pc => { + self.clipboard_chord(link, Edit::Cut); + link.live.clipboard.poke(); + } + Event::Paste(text) if at.keys_to_pc => self.paste(link, text), + Event::PointerButton { + pos, + button, + pressed, + modifiers: m, + } if self.captured || at.over_stream || (!pressed && self.held.any_down()) => { + if at.keys_to_pc && !(m.ctrl && m.alt) { + self.held.modifiers(out, m, cmd_is_ctrl); + } + // The click lands where it was made: the pointer goes + // there first, before a capture would zero the motion. + if self.captured { + self.flush_motion(out); + } else { + self.moved_to = None; + self.send_position(link, pos); + } + // A click on the picture puts the toolbar away and, when + // capture is on, takes the mouse. The click itself still + // goes to the PC: what was clicked on is what was meant. + // Not before the stream connects: the mouse would be held + // with no way out but the host key, over the Cancel button. + if pressed && at.over_stream && !self.captured && link.connected { + self.bar_shown = false; + if link.cfg.capture_mouse { + self.grab(link.ctx, out); + } + } + if let Some(b) = input::button(button) { + self.held.button(out, b, pressed); + } + } + _ => {} + } + } + + /// The Mac's clipboard first, then the paste: the PC pastes what was + /// copied here, not what it had. While the text travels, the chord and + /// everything after it wait (see [`PendingPaste`]). + fn paste(&mut self, link: &Link<'_, S>, text: String) { + self.pasted_since_v_up = true; + let text = if clipboard::worth_sending(&text) { + text + } else { + String::new() + }; + match link + .live + .clipboard + .push(text, crate::macos::pasteboard_changes()) + { + clipboard::Paste::Now => self.clipboard_chord(link, Edit::Paste), + clipboard::Paste::Wait(id) => { + self.pending = Some(PendingPaste { + id, + until: Instant::now() + PASTE_WAIT, + queued: Vec::new(), + }); + } + } + } + + /// Press a waiting paste's chord once its text is on the PC, or `now`, + /// or once it has waited long enough (the PC then pastes what it has), + /// and send what was held back behind it, in order. + fn settle_paste(&mut self, link: &Link<'_, S>, now: bool) { + let Some(p) = &self.pending else { return }; + let ready = link.live.clipboard.paste_ready(p.id); + if !(ready || now || Instant::now() >= p.until) { + return; + } + let Some(p) = self.pending.take() else { return }; + if !ready { + link.live.clipboard.give_up(p.id); + } + self.clipboard_chord(link, Edit::Paste); + for item in p.queued { + self.dispatch(link, item); + } + } + + /// Send a pointer position in the picture: over the black bars, the + /// nearest pixel of the picture (see [`mouse_position_in_video`]). + fn send_position(&self, link: &Link<'_, S>, p: Pos2) { + let (x, y, width, height) = mouse_position_in_video(p, link.video, link.ppp); + link.out.position(x, y, width, height); + } + + /// Send the whole pixels of captured movement, keeping the fraction. + fn flush_motion(&mut self, out: &(impl Sink + ?Sized)) { + if self.captured && (self.motion.0.abs() >= 1.0 || self.motion.1.abs() >= 1.0) { + let dx = self.motion.0.trunc(); + let dy = self.motion.1.trunc(); + self.motion.0 -= dx; + self.motion.1 -= dy; + out.motion(dx as i16, dy as i16); + } + } + + /// Whether Caps Lock is on on this Mac (what a test set, in tests). + fn mac_caps(&self) -> Option { + #[cfg(test)] + if self.tap.is_some() { + return self.caps_now; + } + crate::macos::caps_lock() + } + + /// Whether ⌘ goes to the PC as Ctrl. A Mac there gets Command whatever + /// the setting, which is about Windows and Linux. + fn cmd_is_ctrl(&self, cfg: &ClientConfig) -> bool { + cfg.cmd_is_ctrl && !self.host_mac } /// What ⌘ stands for in a clipboard shortcut. fn clipboard_modifier(&self, cfg: &ClientConfig) -> i16 { - if cfg!(target_os = "macos") && !cfg.cmd_is_ctrl { + if self.host_mac || (cfg!(target_os = "macos") && !cfg.cmd_is_ctrl) { input::VK_LWIN } else { input::VK_CONTROL @@ -1251,23 +1577,80 @@ impl View { /// egui swallows Cmd/Ctrl+C/X/V into clipboard events; replay them as /// the key chord the PC expects. - fn clipboard_chord(&mut self, live: &Live, vk: i16, cfg: &ClientConfig) { - let modifier = self.clipboard_modifier(cfg); - self.held.chord(&live.input, &[modifier, vk]); + fn clipboard_chord(&mut self, link: &Link<'_, S>, edit: Edit) { + let (keys, alone) = link.live.shortcuts.keys( + edit, + self.clipboard_modifier(link.cfg), + link.live.clipboard.pastes_to_selection(), + ); + if alone { + self.held.chord_alone(link.out, &keys); + } else { + self.held.chord(link.out, &keys); + } } } -/// Whether a pointer in the picture should be sent to the PC. A real egui +/// Whether a V let go was a ⌘V egui swallowed: the PC never saw V go +/// down, no paste came from it, and ⌘ was down since the last V. +fn swallowed_paste(v_seen_down: bool, pasted: bool, cmd: bool) -> bool { + !v_seen_down && !pasted && cmd +} + +/// Caps Lock kept in step with the Mac's: `seen` is the Mac's state the PC +/// was last brought in line with, `now` the Mac's. The PC's own state can't +/// be read, so the first one seen while the stream has the keys is taken as +/// matching; after that each change reaches the PC as one tap of Caps Lock, +/// including one made while Latch or another app had the keys, once the +/// stream has them again. Returns the new `seen`, and whether to tap. +fn caps_tap(seen: Option, now: Option, keys_to_pc: bool) -> (Option, bool) { + match (seen, now) { + (_, None) => (seen, false), + _ if !keys_to_pc => (seen, false), + (None, now) => (now, false), + (Some(s), Some(n)) => (Some(n), s != n), + } +} + +/// A wheel or trackpad scroll as the PC takes it, in 1/120ths of a click: +/// (horizontal, vertical). egui's +x moves the content right, which is +/// scrolling left; Moonlight's horizontal scroll, like Windows' and +/// Linux's, is positive to the right. Vertical agrees: +y is up. +fn wheel_to_host(unit: egui::MouseWheelUnit, delta: Vec2) -> (f32, f32) { + let scale = match unit { + egui::MouseWheelUnit::Point => 3.0, + egui::MouseWheelUnit::Line => 120.0, + egui::MouseWheelUnit::Page => 1200.0, + }; + (-delta.x * scale, delta.y * scale) +} + +/// Free mouse mode maps the bars to the nearest pixel in the picture. +fn mouse_position_in_video(p: Pos2, video: Rect, ppp: f32) -> (i16, i16, i16, i16) { + let width = ((video.width() * ppp).round() as i16).max(2); + let height = ((video.height() * ppp).round() as i16).max(2); + // Rect's far edges are inclusive, but pixels are zero-based. Moonlight + // sends width - 1 and height - 1 as the coordinate limits on the wire. + let x = ((p.x - video.left()) * ppp) + .round() + .clamp(0.0, f32::from(width - 1)) as i16; + let y = ((p.y - video.top()) * ppp) + .round() + .clamp(0.0, f32::from(height - 1)) as i16; + (x, y, width, height) +} + +/// Whether a pointer in the stream viewport should be sent to the PC. A real egui /// popup (More) sets `popup`; a hand-rolled `Area` at `Order::Background` /// would not, and clicks would leak through. fn pointer_on_stream( - in_video: bool, + in_viewport: bool, over_bar: bool, popup: bool, overlay: bool, over_overlay: bool, ) -> bool { - in_video && !over_bar && !popup && !overlay && !over_overlay + in_viewport && !over_bar && !popup && !overlay && !over_overlay } /// Whether `host_version` is too old for `/v1/update`, with the release @@ -1293,6 +1676,19 @@ pub fn fit(area: Rect, aspect: f32) -> Rect { Rect::from_center_size(area.center(), Vec2::new(w.floor(), h.floor())) } +/// How many of the PC's desktop pixels one stream pixel stands for. 1 +/// unless the desktop is a fixed size the stream shrinks, keeping its +/// proportions, to fit inside; relative mouse movement lands in the +/// desktop's pixels, so it is scaled by this too. +fn desktop_per_stream_pixel(desktop: Option<(u32, u32)>, stream: (u32, u32)) -> f32 { + match desktop { + Some((dw, dh)) if stream.0 > 0 && stream.1 > 0 => { + (dw as f32 / stream.0 as f32).max(dh as f32 / stream.1 as f32) + } + _ => 1.0, + } +} + /// The quality profile a stream runs at, named as Settings names it. fn profile_label(s: &StreamSettings) -> String { match (s.quality, s.preset()) { @@ -1358,6 +1754,23 @@ pub(crate) mod tests { 16.0 / 9.0, ); assert_eq!(tall.width(), 800.0); + let matched = fit(screen, 3024.0 / 1964.0); + assert!((matched.height() - screen.height()).abs() < 0.1); + assert!((matched.width() - screen.width()).abs() < 0.1); + } + + #[test] + fn mouse_movement_crosses_a_scaled_desktop_at_the_desktops_scale() { + assert_eq!(desktop_per_stream_pixel(None, (1280, 720)), 1.0); + // 720p of a fixed 1080p desktop: each stream pixel is 1.5 of its. + assert_eq!( + desktop_per_stream_pixel(Some((1920, 1080)), (1280, 720)), + 1.5 + ); + // A 16:10 desktop in a 16:9 stream is fitted by its height. + let s = desktop_per_stream_pixel(Some((1920, 1200)), (1280, 720)); + assert!((s - 1200.0 / 720.0).abs() < 1e-6, "{s}"); + assert_eq!(desktop_per_stream_pixel(Some((1920, 1080)), (0, 0)), 1.0); } #[test] @@ -1396,6 +1809,92 @@ pub(crate) mod tests { ); } + #[test] + fn clicks_in_letterbox_and_pillarbox_bars_capture_the_mouse() { + for (size, points) in [ + ( + Vec2::new(1920.0, 1440.0), + [Pos2::new(960.0, 90.0), Pos2::new(960.0, 1350.0)], + ), + ( + Vec2::new(2400.0, 1080.0), + [Pos2::new(120.0, 600.0), Pos2::new(2280.0, 600.0)], + ), + ] { + let video = fit(Rect::from_min_size(Pos2::ZERO, size), 16.0 / 9.0); + for p in points { + assert!(!video.contains(p), "the regression must hit a black bar"); + // Connected: a click before the stream connects takes nothing. + let tap = std::rc::Rc::new(crate::input::Recorder::default()); + tap.connected.set(true); + let view = View { + tap: Some(tap.clone()), + ..view_with_bar() + }; + let extra = Extra { + os: "windows".into(), + ..Default::default() + }; + let mut h = harness_with(size, 1.0, false, fixture(view, extra)); + h.input_mut().events.extend([ + Event::PointerMoved(p), + Event::PointerButton { + pos: p, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ]); + h.run_steps(1); + assert!(h.state().view.captured, "black bar at {p} must capture"); + assert!(!h.state().view.bar_shown); + assert!(h.state().view.held.any_down(), "click must reach the PC"); + h.input_mut() + .events + .push(Event::MouseMoved(Vec2::new(3.25, -3.25))); + h.run_steps(1); + assert!(h.state().view.captured); + assert_eq!( + h.state().view.motion, + (0.25, -0.25), + "raw movement must continue in the bars" + ); + } + } + } + + #[test] + fn mouse_coordinates_stop_at_the_last_pixel_on_every_edge() { + for size in [Vec2::new(1512.0, 982.0), Vec2::new(2400.0, 1080.0)] { + let screen = Rect::from_min_size(Pos2::new(17.0, 33.0), size); + let video = fit(screen, 16.0 / 9.0); + for ppp in [1.0, 1.5, 2.0] { + let width = (video.width() * ppp).round() as i16; + let height = (video.height() * ppp).round() as i16; + for (p, expected) in [ + (video.min, (0, 0)), + (video.max, (width - 1, height - 1)), + (Pos2::new(video.right(), video.top()), (width - 1, 0)), + (Pos2::new(video.left(), video.bottom()), (0, height - 1)), + (screen.min - Vec2::splat(100.0), (0, 0)), + (screen.max + Vec2::splat(100.0), (width - 1, height - 1)), + ] { + let (x, y, w, h) = mouse_position_in_video(p, video, ppp); + assert_eq!((x, y), expected, "{size}, scale {ppp}, point {p}"); + assert_eq!((w, h), (width, height)); + } + let (x, y, _, _) = mouse_position_in_video(video.center(), video, ppp); + assert_eq!( + (x, y), + ( + (width as f32 / 2.0).round() as i16, + (height as f32 / 2.0).round() as i16 + ) + ); + } + } + } + #[test] fn the_power_menu_is_named_for_the_machine() { assert_eq!(machine_noun("windows"), "PC"); @@ -1411,6 +1910,8 @@ pub(crate) mod tests { pub old_host: Option<(Version, Option)>, pub handover: Option, pub video_help: Option, + pub power_allowed: bool, + pub can_sleep: bool, } pub(crate) struct Fixture { @@ -1476,9 +1977,11 @@ pub(crate) mod tests { started: Instant::now(), codec: "HEVC", requested: (1920, 1080, 60), + desktop: None, settings, path, clipboard: crate::clipboard::Sync::spawn(ip, ctx.clone()), + shortcuts: Default::default(), } } @@ -1508,6 +2011,8 @@ pub(crate) mod tests { old_host: extra.old_host.clone(), handover: extra.handover.clone(), video_help: extra.video_help.clone(), + power_allowed: extra.power_allowed, + can_sleep: extra.can_sleep, }; view.show(ctx, &env); } @@ -1558,6 +2063,8 @@ pub(crate) mod tests { view_with_bar(), Extra { os: "windows".into(), + power_allowed: true, + can_sleep: true, ..Default::default() }, ), @@ -1648,7 +2155,7 @@ pub(crate) mod tests { h.ctx.memory(|m| m.any_popup_open()), "More must use egui::popup so any_popup_open is true; a Background Area would fail this" ); - for item in ["Ctrl+Alt+Delete", "Stream performance", "Sleep"] { + for item in ["Ctrl+Alt+Delete", "Stream performance", "Sleep…"] { assert!(h.query_by_label(item).is_some(), "More lists {item}"); } let popup_open = h.ctx.memory(|m| m.any_popup_open()); @@ -1661,6 +2168,60 @@ pub(crate) mod tests { ); } + #[test] + fn power_items_follow_the_owner_and_sleep_only_where_it_can_wake() { + let wide = Vec2::new(1400.0, 860.0); + let with = |power_allowed, can_sleep, os: &str| { + harness_with( + wide, + 1.0, + false, + fixture( + view_with_bar(), + Extra { + os: os.into(), + power_allowed, + can_sleep, + ..Default::default() + }, + ), + ) + }; + // The owner turned power actions off: no menu at all. + let h = with(false, false, "windows"); + assert!(h.query_by_label("PC").is_none(), "an empty menu is no menu"); + // A Linux VPS: restart and shut down, never sleep. + let mut h = with(true, false, "linux"); + h.get_by_label("Machine").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Restart…").is_some()); + assert!(h.query_by_label("Sleep…").is_none()); + // A Windows PC: sleep too, and it asks first. + let mut h = with(true, true, "windows"); + h.get_by_label("PC").simulate_click(); + h.run_steps(2); + h.get_by_label("Sleep…").simulate_click(); + h.run_steps(2); + assert_eq!(h.state().view.confirm, Some(PowerAction::Sleep)); + assert!( + h.query_by_label_contains("woken from its own network") + .is_some(), + "the question says what sleep costs" + ); + // The narrow toolbar's More menu follows the same rule. + let mut h = harness_with( + Vec2::new(640.0, 420.0), + 1.0, + false, + fixture(view_with_bar(), Extra::default()), + ); + h.get_by_label("More").simulate_click(); + h.run_steps(2); + for item in ["Sleep…", "Restart…", "Shut down…"] { + assert!(h.query_by_label(item).is_none(), "{item}"); + } + } + #[test] fn toolbar_at_normal_width_keeps_controls_on_the_bar() { let h = harness(Vec2::new(1400.0, 860.0)); @@ -1676,6 +2237,28 @@ pub(crate) mod tests { crate::app::snapshots::assert_fits(&h, 1400.0); } + #[test] + fn stats_toggle_controls_the_bottom_line_with_and_without_letterboxing() { + for size in [Vec2::new(1920.0, 1200.0), Vec2::new(1920.0, 1080.0)] { + let mut h = harness(size); + let line_visible = |h: &egui_kittest::Harness<'_, Fixture>| { + h.output().shapes.iter().any(|s| { + matches!(&s.shape, egui::Shape::Text(t) if t.galley.job.text.contains("/60 fps")) + }) + }; + assert!(!line_visible(&h), "stats start hidden at {size}"); + h.get_by_label("Stats").simulate_click(); + h.run_steps(3); + assert!(line_visible(&h), "Stats shows the bottom line at {size}"); + h.get_by_label("Hide stats").simulate_click(); + h.run_steps(3); + assert!( + !line_visible(&h), + "Hide stats removes the bottom line at {size}" + ); + } + } + #[test] fn the_toolbar_fits_every_width_between_the_minimum_and_full_hd() { for w in [640.0, 800.0, 1039.0, 1040.0, 1100.0, 1299.0, 1300.0, 1920.0] { @@ -1779,6 +2362,8 @@ pub(crate) mod tests { Extra { os: "windows".into(), old_host: Some((Version::new(3, 0, 0), Some(Version::new(4, 0, 2)))), + power_allowed: true, + can_sleep: true, ..Default::default() } } @@ -1999,6 +2584,35 @@ pub(crate) mod tests { h.render().unwrap(), &file_name("stream", "fps-open", min, ppp), ); + // The resolution list, the first select in the panel, reaches 4K. + let view = View { + settings: Some(ClientConfig::default().stream), + ..view_with_bar() + }; + let mut h = harness_with(min, ppp, true, fixture(view, windows_old_host())); + for _ in 0..30 { + if focused(&h, Role::ComboBox) { + break; + } + h.press_key(egui::Key::Tab); + h.run_steps(1); + } + assert!(focused(&h, Role::ComboBox), "Tab reaches the select"); + h.query_all(By::new().predicate(|n| n.is_focused())) + .next() + .unwrap() + .simulate_click(); + h.run_steps(3); + let list = latch_ui::open_menu_rect(&h.ctx).expect("the list is open"); + assert_inside("the resolution list", list, screen); + let last = "4K · 3840 × 2160"; + tab_to(&mut h, last); + let list = latch_ui::open_menu_rect(&h.ctx).expect("Tab keeps the list open"); + assert_inside(last, node_rect(&h.get_by_label(last)), list); + save( + h.render().unwrap(), + &file_name("stream", "resolution-open", min, ppp), + ); } } @@ -2008,6 +2622,8 @@ pub(crate) mod tests { use crate::app::snapshots::{file_name, save, MATRIX, PAIR}; let windows = || Extra { os: "windows".into(), + power_allowed: true, + can_sleep: true, ..Default::default() }; for &(size, ppp) in &MATRIX { @@ -2153,4 +2769,561 @@ pub(crate) mod tests { } } } + + // What the PC is sent: the order, the signs and the masks. + + use crate::input::{ + Recorder, Sent, VK_CAPITAL, VK_CONTROL, VK_LWIN, VK_MENU, VK_RETURN, VK_SHIFT, VK_TAB, VK_V, + }; + use egui::{Key, PointerButton}; + use latch_stream::ffi::{BUTTON_LEFT, MODIFIER_CTRL, MODIFIER_SHIFT}; + use std::rc::Rc; + + const VK_T: i16 = 0x54; + const VK_W: i16 = 0x57; + const VK_Y: i16 = 0x59; + const VK_Z: i16 = 0x5A; + const CMD: Modifiers = Modifiers { + mac_cmd: true, + command: true, + ..Modifiers::NONE + }; + const CTRL_ALT: Modifiers = Modifiers { + ctrl: true, + alt: true, + ..Modifiers::NONE + }; + /// On the picture, clear of the card shown before the first picture. + const ON_PICTURE: Pos2 = Pos2::new(20.0, 200.0); + + /// A stream view at the smallest window whose input goes to a + /// recording instead of the PC, with the other machine running `os`. + fn recording_on( + view: View, + connected: bool, + os: &str, + ) -> (egui_kittest::Harness<'static, Fixture>, Rc) { + let tap = Rc::new(Recorder::default()); + tap.connected.set(connected); + let view = View { + tap: Some(tap.clone()), + ..view + }; + let extra = Extra { + os: os.into(), + ..Default::default() + }; + let h = harness_with(Vec2::new(640.0, 420.0), 1.0, false, fixture(view, extra)); + tap.take(); + (h, tap) + } + + fn recording( + view: View, + connected: bool, + ) -> (egui_kittest::Harness<'static, Fixture>, Rc) { + recording_on(view, connected, "windows") + } + + fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { + key, + physical_key: Some(key), + pressed, + repeat: false, + modifiers, + } + } + + fn click(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + } + } + + /// One frame with these events, ending with `end` held. + fn frame(h: &mut egui_kittest::Harness<'_, Fixture>, events: Vec, end: Modifiers) { + h.input_mut().events.extend(events); + h.input_mut().modifiers = end; + h.run_steps(1); + } + + #[test] + fn caps_lock_follows_the_mac_once_the_stream_has_the_keys() { + // Unknown on the Mac: nothing to do. + assert_eq!(caps_tap(None, None, true), (None, false)); + // Not the stream's keys yet: nothing is taken as matching. + assert_eq!(caps_tap(None, Some(true), false), (None, false)); + // The first state seen with the keys is the baseline. + assert_eq!(caps_tap(None, Some(true), true), (Some(true), false)); + assert_eq!(caps_tap(Some(true), Some(true), true), (Some(true), false)); + // A change is one tap; one made while Latch had the keys waits. + assert_eq!(caps_tap(Some(true), Some(false), true), (Some(false), true)); + assert_eq!( + caps_tap(Some(false), Some(true), false), + (Some(false), false) + ); + assert_eq!(caps_tap(Some(false), Some(true), true), (Some(true), true)); + } + + #[test] + fn caps_lock_on_the_mac_taps_caps_lock_on_the_pc() { + let (mut h, tap) = recording(View::default(), true); + h.state_mut().view.caps_now = Some(false); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!(tap.keys(), vec![], "the first state is the baseline"); + h.state_mut().view.caps_now = Some(true); + frame( + &mut h, + vec![ + key(Key::A, true, Modifiers::NONE), + key(Key::A, false, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.keys(), + vec![ + (VK_CAPITAL, true), + (VK_CAPITAL, false), + (0x41, true), + (0x41, false) + ], + "Caps Lock goes before the frame's keys" + ); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!(tap.keys(), vec![], "once per change"); + } + + #[test] + fn a_shift_let_go_in_the_same_frame_still_capitalises() { + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![ + key(Key::T, true, Modifiers::SHIFT), + key(Key::T, false, Modifiers::SHIFT), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Key(VK_SHIFT, true, MODIFIER_SHIFT), + Sent::Key(VK_T, true, MODIFIER_SHIFT), + Sent::Key(VK_T, false, MODIFIER_SHIFT), + Sent::Key(VK_SHIFT, false, 0), + ] + ); + // A key pressed before Shift in the same frame stays lowercase. + frame( + &mut h, + vec![key(Key::Y, true, Modifiers::NONE)], + Modifiers::SHIFT, + ); + assert_eq!(tap.keys(), [(VK_Y, true), (VK_SHIFT, true)]); + } + + #[test] + fn command_z_with_command_let_go_in_the_same_frame_is_still_undo() { + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![key(Key::Z, true, CMD)], Modifiers::NONE); + assert_eq!( + tap.take(), + [ + Sent::Key(VK_CONTROL, true, MODIFIER_CTRL), + Sent::Key(VK_Z, true, MODIFIER_CTRL), + Sent::Key(VK_CONTROL, false, 0), + ] + ); + } + + #[test] + fn a_mac_there_gets_command_as_command() { + let (mut h, tap) = recording_on(View::default(), true, "macOS"); + assert!(h.state().cfg.cmd_is_ctrl, "the default, for Windows"); + frame(&mut h, vec![key(Key::Z, true, CMD)], CMD); + assert_eq!(tap.keys(), [(VK_LWIN, true), (VK_Z, true)]); + // Copy is the Command chord there too. + frame(&mut h, vec![Event::Copy], CMD); + assert_eq!(tap.keys(), [(input::VK_C, true), (input::VK_C, false)]); + assert!(is_mac_os("macOS") && is_mac_os("darwin") && is_mac_os("MacOS")); + assert!(!is_mac_os("windows") && !is_mac_os("linux") && !is_mac_os("")); + } + + #[test] + fn command_plus_zooms_the_pc_not_latch() { + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![key(Key::Equals, true, CMD)], CMD); + // A new zoom takes effect on the next frame. + h.run_steps(1); + assert_eq!(h.ctx.zoom_factor(), 1.0); + assert!(tap.keys().contains(&(0xBB, true))); + // Without the stream's keys, egui's zoom is back. + h.state_mut().view.settings = Some(ClientConfig::default().stream); + h.run_steps(1); + frame(&mut h, vec![key(Key::Equals, true, CMD)], CMD); + h.run_steps(1); + assert!(h.ctx.zoom_factor() > 1.0); + } + + #[test] + fn the_host_key_never_reaches_the_pc_even_inside_one_frame() { + let (mut h, tap) = recording(View::default(), true); + // Ctrl+Alt+T pressed and Alt let go before the frame was drawn. + frame(&mut h, vec![key(Key::T, true, CTRL_ALT)], Modifiers::CTRL); + let keys = tap.keys(); + assert!(!keys.contains(&(VK_T, true)), "{keys:?}"); + assert!(!keys.iter().any(|&(k, _)| k == VK_MENU), "{keys:?}"); + } + + #[test] + fn scrolling_sideways_scrolls_the_pc_the_same_way() { + use egui::MouseWheelUnit::{Line, Page, Point}; + assert_eq!(wheel_to_host(Line, Vec2::new(1.0, 0.0)), (-120.0, 0.0)); + assert_eq!(wheel_to_host(Line, Vec2::new(0.0, 1.0)), (0.0, 120.0)); + assert_eq!(wheel_to_host(Point, Vec2::new(-2.0, -2.0)), (6.0, -6.0)); + assert_eq!(wheel_to_host(Page, Vec2::new(0.5, 0.0)), (-600.0, 0.0)); + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![ + Event::PointerMoved(ON_PICTURE), + Event::MouseWheel { + unit: Line, + delta: Vec2::new(1.0, 0.0), + modifiers: Modifiers::NONE, + }, + ], + Modifiers::NONE, + ); + let sent = tap.take(); + assert!(sent.contains(&Sent::Scroll(0, -120)), "{sent:?}"); + } + + #[test] + fn a_click_goes_where_the_pointer_was_when_it_was_made() { + let (mut h, tap) = recording(View::default(), true); + h.state_mut().cfg.capture_mouse = false; + // 1920×1080 in 640×420: the picture is 640×360, 30 points down. + let elsewhere = Pos2::new(300.0, 100.0); + frame( + &mut h, + vec![ + Event::PointerMoved(ON_PICTURE), + click(ON_PICTURE, true), + Event::PointerMoved(elsewhere), + click(elsewhere, false), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Position(20, 170, 640, 360), + Sent::Button(BUTTON_LEFT, true), + Sent::Position(300, 70, 640, 360), + Sent::Button(BUTTON_LEFT, false), + ] + ); + assert!(!h.state().view.captured, "free mode"); + } + + #[test] + fn a_captured_click_comes_after_the_movement_before_it() { + let view = View { + captured: true, + grabbed: true, + ..View::default() + }; + let (mut h, tap) = recording(view, true); + // 1920 stream pixels across 640 points: three per point. + frame( + &mut h, + vec![ + Event::MouseMoved(Vec2::new(3.5, 0.0)), + click(ON_PICTURE, true), + Event::MouseMoved(Vec2::new(1.0, 1.0)), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Motion(10, 0), + Sent::Button(BUTTON_LEFT, true), + Sent::Motion(3, 3), + ] + ); + } + + #[test] + fn a_click_that_captures_the_mouse_sends_its_position_first() { + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![Event::PointerMoved(ON_PICTURE), click(ON_PICTURE, true)], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Position(20, 170, 640, 360), + Sent::Button(BUTTON_LEFT, true), + ] + ); + assert!(h.state().view.captured); + } + + #[test] + fn a_click_before_the_stream_connects_does_not_take_the_mouse() { + let (mut h, tap) = recording(View::default(), false); + frame( + &mut h, + vec![ + Event::PointerMoved(ON_PICTURE), + click(ON_PICTURE, true), + click(ON_PICTURE, false), + ], + Modifiers::NONE, + ); + h.run_steps(1); + assert!(!h.state().view.captured && !h.state().view.grabbed); + assert!(h.query_by_label("Cancel").is_some()); + // A capture left over from a dropped connection: the host key + // still lets the mouse go. + h.state_mut().view.captured = true; + frame(&mut h, vec![], CTRL_ALT); + assert!(!h.state().view.captured); + let sent = tap.take(); + assert!( + !sent.iter().any(|s| matches!(s, Sent::Button(..))), + "{sent:?}" + ); + } + + #[test] + fn the_host_key_lets_go_of_what_was_held() { + let (mut h, tap) = recording(view_with_bar(), true); + h.state_mut().cfg.capture_mouse = false; + frame( + &mut h, + vec![key(Key::W, true, Modifiers::NONE)], + Modifiers::NONE, + ); + assert_eq!(tap.keys(), [(VK_W, true)]); + // The bar hides; W is let go while the chord is still down. + frame(&mut h, vec![key(Key::W, false, CTRL_ALT)], CTRL_ALT); + assert_eq!(tap.keys(), [(VK_W, false)]); + assert!(!h.state().view.held.any_down()); + assert!(!h.state().view.bar_shown); + // Control let go before Option: no lone Option reaches the PC. + frame(&mut h, vec![], Modifiers::ALT); + frame(&mut h, vec![], Modifiers::NONE); + assert!(tap.keys().is_empty()); + } + + #[test] + fn the_host_key_never_captures_the_mouse_under_an_open_panel() { + let view = View { + settings: Some(ClientConfig::default().stream), + ..view_with_bar() + }; + let (mut h, _tap) = recording(view, true); + frame(&mut h, vec![], CTRL_ALT); + let v = &h.state().view; + assert!(v.captured && !v.bar_shown); + assert!(v.settings.is_none(), "closed, as Cancel"); + let view = View { + confirm: Some(PowerAction::Restart), + ..view_with_bar() + }; + let (mut h, _tap) = recording(view, true); + frame(&mut h, vec![], CTRL_ALT); + assert!(h.state().view.confirm.is_none() && h.state().view.captured); + } + + #[test] + fn a_paste_goes_before_what_was_typed_after_it() { + // Nothing to send first (an image on the Mac): at once, in order. + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![ + Event::Paste(String::new()), + key(Key::Enter, true, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.keys(), + [ + (VK_CONTROL, true), + (VK_V, true), + (VK_V, false), + (VK_CONTROL, false), + (VK_RETURN, true), + ] + ); + // Text on its way: Return waits for it, then follows the paste. + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![], CMD); + assert_eq!(tap.keys(), [(VK_CONTROL, true)]); + h.state_mut().view.pending = Some(PendingPaste { + id: 1, + until: Instant::now() + Duration::from_secs(60), + queued: Vec::new(), + }); + // ⌘ let go, then Return, while the text travels. + frame(&mut h, vec![], Modifiers::NONE); + frame( + &mut h, + vec![ + key(Key::Enter, true, Modifiers::NONE), + key(Key::Enter, false, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert!(tap.take().is_empty(), "nothing overtakes the paste"); + h.state() + .live + .as_ref() + .unwrap() + .clipboard + .finish_for_test(1); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!( + tap.keys(), + [ + (VK_V, true), + (VK_V, false), + (VK_CONTROL, false), + (VK_RETURN, true), + (VK_RETURN, false), + ] + ); + assert!(h.state().view.pending.is_none()); + } + + #[test] + fn a_paste_that_waits_too_long_goes_once_and_losing_focus_drops_it() { + let (mut h, tap) = recording(View::default(), true); + h.state_mut().view.pending = Some(PendingPaste { + id: 7, + until: Instant::now(), + queued: vec![Queued::Event( + key(Key::Enter, true, Modifiers::NONE), + At { + keys_to_pc: true, + over_stream: false, + }, + )], + }); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!( + tap.keys(), + [ + (VK_CONTROL, true), + (VK_V, true), + (VK_V, false), + (VK_CONTROL, false), + (VK_RETURN, true), + ] + ); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!(tap.keys(), [], "and only once"); + // Focus goes while a paste waits: it goes, unsent, with what was + // held behind it; what the PC holds is let go. + h.state_mut().view.pending = Some(PendingPaste { + id: 8, + until: Instant::now() + Duration::from_secs(60), + queued: Vec::new(), + }); + frame( + &mut h, + vec![key(Key::A, true, Modifiers::NONE)], + Modifiers::NONE, + ); + frame(&mut h, vec![Event::WindowFocused(false)], Modifiers::NONE); + assert!(h.state().view.pending.is_none()); + assert_eq!(tap.keys(), [(VK_RETURN, false)]); + } + + #[test] + fn command_v_pastes_even_with_no_text_on_the_mac() { + // egui swallows ⌘V's press when the Mac's clipboard has no text; + // only the V let go shows. + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![], CMD); + assert_eq!(tap.keys(), [(VK_CONTROL, true)]); + frame(&mut h, vec![key(Key::V, false, CMD)], CMD); + assert_eq!(tap.keys(), [(VK_V, true), (VK_V, false)]); + // With text, the paste event comes and the V let go adds nothing. + frame( + &mut h, + vec![Event::Paste(String::new()), key(Key::V, false, CMD)], + CMD, + ); + assert_eq!(tap.keys(), [(VK_V, true), (VK_V, false)]); + // A plain V is typed as ever. + frame(&mut h, vec![], Modifiers::NONE); + tap.take(); + frame( + &mut h, + vec![ + key(Key::V, true, Modifiers::NONE), + key(Key::V, false, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert_eq!(tap.keys(), [(VK_V, true), (VK_V, false)]); + for (seen, pasted, cmd) in [ + (true, false, true), + (false, true, true), + (false, false, false), + ] { + assert!(!swallowed_paste(seen, pasted, cmd)); + } + assert!(swallowed_paste(false, false, true)); + } + + #[test] + fn tab_in_free_mode_goes_to_the_pc_with_the_stats_panel_open() { + let view = View { + stats: true, + ..View::default() + }; + let (mut h, tap) = recording(view, true); + h.state_mut().cfg.capture_mouse = false; + h.state() + .live + .as_ref() + .unwrap() + .frames + .publish(latch_stream::video::Frame::default()); + h.run_steps(2); + tap.take(); + for _ in 0..3 { + h.press_key(Key::Tab); + h.run_steps(1); + assert!(h.ctx.memory(|m| m.focused()).is_none()); + } + let tabs = tap + .keys() + .into_iter() + .filter(|&k| k == (VK_TAB, true)) + .count(); + assert_eq!(tabs, 3); + // Focus taken in the Stats panel while the toolbar showed goes + // when the toolbar does. + h.state_mut().view.bar_shown = true; + h.run_steps(2); + tab_to(&mut h, "Copy diagnostics"); + h.state_mut().view.bar_shown = false; + h.run_steps(2); + assert!(h.ctx.memory(|m| m.focused()).is_none()); + } } diff --git a/crates/client/src/update.rs b/crates/client/src/update.rs index 7dccc64..32dffcf 100644 --- a/crates/client/src/update.rs +++ b/crates/client/src/update.rs @@ -24,7 +24,7 @@ use parking_lot::Mutex; use semver::Version; use std::collections::{BTreeMap, BTreeSet}; use std::net::{Ipv4Addr, SocketAddr}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -61,6 +61,17 @@ pub struct State { pub rolled_back: BTreeMap, /// Hosts too old to receive `/v1/update`; told the user once. pub told_old: BTreeSet, + /// Pushes in a row a host turned down because a stream ran: each one + /// doubles the wait before the next (see [`push_wait`]). A host from + /// before 4.2 says a stream runs long after the viewer left. + pub deferred: BTreeMap, +} + +/// How long to wait before sending a host the same version again, after +/// `deferred` pushes in a row were turned down for a running stream: +/// [`PUSH_GRACE`], doubling up to an hour. +fn push_wait(deferred: u32) -> Duration { + (PUSH_GRACE * 2u32.pow(deferred.min(5))).min(Duration::from_secs(3600)) } pub fn spawn( @@ -87,6 +98,10 @@ pub fn spawn( let mut next = Instant::now() + FIRST_CHECK; let mut release: Option = None; let mut token = None; + // The last self-update that failed. It is not tried again on every + // tick, and one that can only fail the same way not until the + // release changes; the PCs get theirs meanwhile either way. + let mut self_failed: Option = None; loop { std::thread::sleep(TICK); let cfg = ClientConfig::load(); @@ -130,9 +145,13 @@ pub fn spawn( let rel = &rel; // This app first. - if rel.is_newer_than(&update::current()) && state.lock().ready.is_none() { + if rel.is_newer_than(&update::current()) + && state.lock().ready.is_none() + && self_update_due(self_failed.as_ref(), rel, Instant::now()) + { match prepare_self(rel, token.as_deref()) { Ok(Some(v)) => { + self_failed = None; let mut st = state.lock(); st.ready = Some(v.clone()); st.notice = Some(( @@ -150,13 +169,9 @@ pub fn spawn( } Err(e) => { state.lock().message = - format!("Could not download Latch {}: {e}.", rel.version); + format!("Could not update this Mac to Latch {}: {e:#}.", rel.version); tracing::warn!("self-update: {e:#}"); - // Try again at the next check rather than every tick. - release = None; - next = Instant::now() + RETRY_AFTER; - ctx.request_repaint(); - continue; + self_failed = Some(SelfFailure::new(rel, &e, Instant::now())); } } ctx.request_repaint(); @@ -177,17 +192,16 @@ pub fn spawn( } }; if idle { - match install_self(rel) { + match install_self(rel, token.as_deref()) { Ok(()) => relaunch(), Err(e) => { let mut st = state.lock(); st.ready = None; st.notice = None; - st.message = format!("Could not install Latch {v}: {e}."); + st.message = format!("Could not install Latch {v}: {e:#}."); tracing::error!("self-update: {e:#}"); progress.lock().updating = false; - release = None; - next = Instant::now() + RETRY_AFTER; + self_failed = Some(SelfFailure::new(rel, &e, Instant::now())); } } ctx.request_repaint(); @@ -205,7 +219,10 @@ pub fn spawn( continue; }; if !update::takes_pushed_host(&h.os) { - continue; // a Mac or a container updates itself + // A Mac updates with its own app; a Linux host or a + // container is updated by hand, and the lobby says so + // (`manual_update_message`). + continue; } if !update::host_can_receive_update(&v) { // The lobby shows this PC what to do; here, just note it. @@ -227,15 +244,19 @@ pub fn spawn( ctx.request_repaint(); } } - if !should_push(&v, rel) || streaming_to == Some(ip) { + // Someone is streaming from it (this Mac or another viewer): + // the host would turn the update down until they are done, + // so wait instead of sending the whole executable for that. + if !should_push(&v, rel) || streaming_to == Some(ip) || h.streamer.viewer { continue; } { let mut st = state.lock(); + let wait = push_wait(st.deferred.get(&pc.node_id).copied().unwrap_or(0)); let recently = st .pushed .get(&pc.node_id) - .is_some_and(|(pv, at)| *pv == rel.version && at.elapsed() < PUSH_GRACE); + .is_some_and(|(pv, at)| *pv == rel.version && at.elapsed() < wait); if recently || st.rolled_back.get(&pc.node_id) == Some(&rel.version) { continue; } @@ -258,6 +279,9 @@ pub fn spawn( let mut st = state.lock(); st.pushed .insert(pc.node_id.clone(), (rel.version.clone(), Instant::now())); + if !matches!(&outcome, Err(e) if is_deferred(e)) { + st.deferred.remove(&pc.node_id); + } match outcome { Ok(()) => { st.delivered.insert(pc.node_id.clone(), rel.version.clone()); @@ -268,6 +292,17 @@ pub fn spawn( st.notice = Some((Tone::Info, st.message.clone())); tracing::info!("sent host {} to {} ({ip})", rel.version, pc.name); } + Err(e) if is_deferred(&e) => { + // A host older than the `viewer` status field says so + // only now; it is tried again later, less often each time. + *st.deferred.entry(pc.node_id.clone()).or_default() += 1; + st.message = format!( + "Latch Host {} goes to {} once the stream there ends.", + rel.version, pc.name + ); + st.notice = Some((Tone::Info, st.message.clone())); + tracing::info!("host update for {} deferred: {e:#}", pc.name); + } Err(e) => { st.message = format!("Could not update {}: {e}.", pc.name); st.notice = Some((Tone::Danger, st.message.clone())); @@ -365,86 +400,158 @@ fn run(cmd: &str, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } +/// A download that arrived whole and still cannot update this Mac. The +/// same asset fails the same way every time, so it is not fetched again. +#[derive(Debug)] +struct Unusable; + +impl std::fmt::Display for Unusable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("this release cannot update this Mac") + } +} + +/// A self-update that failed: for which release and asset, and when it may +/// be tried again (`None`: not until the release or its asset changes). +#[derive(Debug, Clone, PartialEq)] +struct SelfFailure { + tag: String, + digest: Option, + retry_at: Option, +} + +impl SelfFailure { + fn new(rel: &Release, e: &anyhow::Error, now: Instant) -> Self { + Self { + tag: rel.tag.clone(), + digest: mac_digest(rel), + retry_at: e + .downcast_ref::() + .is_none() + .then_some(now + RETRY_AFTER), + } + } +} + +fn mac_digest(rel: &Release) -> Option { + rel.asset(MAC_ASSET).and_then(|a| a.sha256.clone()) +} + +/// Whether to try updating this Mac to `rel` now: always after nothing has +/// failed, or for another release or a re-published asset; for the one +/// that failed, once its wait is over, and never if it cannot work. +fn self_update_due(failed: Option<&SelfFailure>, rel: &Release, now: Instant) -> bool { + match failed { + None => true, + Some(f) if f.tag != rel.tag || f.digest != mac_digest(rel) => true, + Some(f) => f.retry_at.is_some_and(|at| now >= at), + } +} + /// Download and verify the new app. `Ok(None)` when this copy is not in a -/// bundle (a development build), which is left alone. +/// bundle (a development build), which is left alone. A download that +/// fails its checks is [`Unusable`]; a failed download is not. fn prepare_self(rel: &Release, token: Option<&str>) -> Result> { if bundle_path().is_none() { return Ok(None); } - let tarball = fetch_asset(rel, MAC_ASSET, token)?; - let unpacked = updates_dir(rel)?.join("unpacked"); - let app = unpacked.join("Latch.app"); - if !app.exists() { - let _ = std::fs::remove_dir_all(&unpacked); - std::fs::create_dir_all(&unpacked)?; - run( - "/usr/bin/tar", - &[ - "xzf", - &tarball.display().to_string(), - "-C", - &unpacked.display().to_string(), - ], - )?; + if rel.asset(MAC_ASSET).is_none() { + return Err(anyhow!("release {} has no {MAC_ASSET}", rel.tag).context(Unusable)); } - let verified = (|| -> Result { - anyhow::ensure!(app.exists(), "the download holds no Latch.app"); - run( - "/usr/bin/codesign", - &["--verify", "--deep", "--strict", &app.display().to_string()], - ) - .context("the new app's signature does not verify")?; - let version = run( - "/usr/bin/plutil", - &[ - "-extract", - "CFBundleShortVersionString", - "raw", - &app.join("Contents/Info.plist").display().to_string(), - ], - )?; - let version = - Version::parse(&version).with_context(|| format!("bundle version {version:?}"))?; - anyhow::ensure!( - version == rel.version, - "the download says {version}, the release {}", - rel.version - ); - Ok(version) - })(); - match verified { + let tarball = fetch_asset(rel, MAC_ASSET, token)?; + match prepare_in(&updates_dir(rel)?.join("unpacked"), &tarball, &rel.version) { Ok(v) => Ok(Some(v)), Err(e) => { - // Nothing from a failed check may be found and trusted at the - // next attempt: fetch and unpack again from scratch. - let _ = std::fs::remove_dir_all(&unpacked); + // It matched GitHub's digest, so fetching it again would bring + // the same bytes. let _ = std::fs::remove_file(&tarball); - Err(e) + Err(e.context(Unusable)) } } } -/// Move the verified app over this one. macOS keeps the running executable -/// alive across the rename, so the swap is safe while we are still up. -fn install_self(rel: &Release) -> Result<()> { +/// Unpack `tarball` into an emptied `unpacked` and check the app in it, +/// then empty it again: an unpacked copy is never kept to be trusted later +/// (the install unpacks the tarball afresh). +fn prepare_in(unpacked: &Path, tarball: &Path, want: &Version) -> Result { + let _ = std::fs::remove_dir_all(unpacked); + std::fs::create_dir_all(unpacked)?; + let checked = unpack_app(tarball, unpacked).and_then(|app| verify_app(&app, want)); + let _ = std::fs::remove_dir_all(unpacked); + checked +} + +/// Extract `Latch.app`, and nothing else in the archive, into `dir`. +fn unpack_app(tarball: &Path, dir: &Path) -> Result { + run( + "/usr/bin/tar", + &[ + "xzf", + &tarball.display().to_string(), + "-C", + &dir.display().to_string(), + "Latch.app", + ], + )?; + Ok(dir.join("Latch.app")) +} + +/// The app is there, its signature verifies, and it is the version wanted. +fn verify_app(app: &Path, want: &Version) -> Result { + anyhow::ensure!(app.exists(), "the download holds no Latch.app"); + run( + "/usr/bin/codesign", + &["--verify", "--deep", "--strict", &app.display().to_string()], + ) + .context("the new app's signature does not verify")?; + let version = run( + "/usr/bin/plutil", + &[ + "-extract", + "CFBundleShortVersionString", + "raw", + &app.join("Contents/Info.plist").display().to_string(), + ], + )?; + let version = + Version::parse(&version).with_context(|| format!("bundle version {version:?}"))?; + anyhow::ensure!( + version == *want, + "the download says {version}, the release {want}" + ); + Ok(version) +} + +/// Move the new app over this one. The release's tarball is checked +/// against GitHub's digest again (and fetched again if the asset changed +/// since it was prepared), unpacked straight into the staging folder beside +/// this app and checked there: what was checked is exactly what goes in. +/// macOS keeps the running executable alive across the rename, so the swap +/// is safe while we are still up. +fn install_self(rel: &Release, token: Option<&str>) -> Result<()> { let bundle = bundle_path().ok_or_else(|| anyhow!("not running from an app bundle"))?; - let new = updates_dir(rel)?.join("unpacked").join("Latch.app"); - anyhow::ensure!(new.exists(), "nothing is downloaded"); - replace_bundle(&bundle, &new, |new, staged| { - run( - "/usr/bin/ditto", - &[&new.display().to_string(), &staged.display().to_string()], - ) - .map(|_| ()) + let tarball = fetch_asset(rel, MAC_ASSET, token)?; + replace_bundle(&bundle, &tarball, |tarball, staged| { + stage_from(tarball, staged, &rel.version) })?; let _ = run( "/usr/bin/xattr", &["-dr", "com.apple.quarantine", &bundle.display().to_string()], ); - let _ = std::fs::remove_dir_all(updates_dir(rel)?.join("unpacked")); Ok(()) } +/// Unpack the app from `tarball` as `staged` (`Latch.app` in a fresh +/// staging folder) and check it where it lies. +fn stage_from(tarball: &Path, staged: &Path, want: &Version) -> Result<()> { + let dir = staged + .parent() + .ok_or_else(|| anyhow!("the staging folder has no parent"))?; + let app = unpack_app(tarball, dir)?; + anyhow::ensure!(app == staged, "unpacked to {}", app.display()); + verify_app(&app, want).map(|_| ()) +} + /// Stage on the destination volume before moving the running app. A failed /// cross-volume copy leaves the working app untouched, and the final swaps /// are both same-volume renames. @@ -529,6 +636,27 @@ pub fn old_host_message(name: &str, version: &Version) -> String { ) } +/// What to say about a host this Mac cannot update when a newer release is +/// out: a Linux machine or a container, whose Latch Host is installed by +/// hand. `None` for a Windows PC (it is sent the update), a Mac (its own +/// app updates it) and a host already on the release. +pub fn manual_update_message( + name: &str, + os: &str, + running: &Version, + rel: &Release, +) -> Option { + if update::takes_pushed_host(os) || os.eq_ignore_ascii_case("macos") { + return None; + } + rel.is_newer_than(running).then(|| { + format!( + "{name} runs Latch Host {running}, and {} is out. This Mac sends updates to Windows PCs only: install the new Latch Host on {name} by hand, the way it was set up (a Linux machine: the release's latch-host-linux tarball).", + rel.version + ) + }) +} + fn is_transient(e: &anyhow::Error) -> bool { let s = format!("{e:#}"); s.contains("timed out") @@ -572,6 +700,12 @@ fn push_host(rel: &Release, token: Option<&str>, ip: Ipv4Addr) -> Result<()> { Err(last.unwrap_or_else(|| anyhow!("update failed"))) } +/// Whether a push was turned down only because someone streams from that +/// machine right now (the host's 409), which is a wait, not a failure. +fn is_deferred(e: &anyhow::Error) -> bool { + format!("{e:#}").contains("a stream is running") +} + /// Whether the host at `addr` reports `want` or newer within `within`: it /// may still be handing over to the new executable when first asked. fn runs_at_least(addr: SocketAddr, want: &Version, within: Duration) -> bool { @@ -644,6 +778,19 @@ pub fn ago(at: Option) -> String { mod tests { use super::*; + #[test] + fn a_host_that_keeps_deferring_is_asked_less_often() { + assert_eq!(push_wait(0), PUSH_GRACE); + assert_eq!(push_wait(1), PUSH_GRACE * 2); + assert_eq!(push_wait(2), PUSH_GRACE * 4); + assert_eq!(push_wait(4), Duration::from_secs(48 * 60)); + assert_eq!(push_wait(5), Duration::from_secs(3600)); + assert_eq!(push_wait(40), Duration::from_secs(3600)); + let e = anyhow!("a stream is running; the update is retried after it ends"); + assert!(is_deferred(&e)); + assert!(!is_deferred(&anyhow!("HTTP 500"))); + } + #[test] fn relaunch_keeps_shell_metacharacters_in_the_path_literal() { let path = std::path::Path::new("/Applications/$(touch nope) \"quoted\" `name`.app"); @@ -695,6 +842,117 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A minimal app bundle at `dir/Latch.app` that says `version` and + /// carries `marker`, ad-hoc signed as a release build is. + #[cfg(target_os = "macos")] + fn fake_app(dir: &Path, version: &str, marker: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let app = dir.join("Latch.app"); + let contents = app.join("Contents"); + std::fs::create_dir_all(contents.join("MacOS")).unwrap(); + std::fs::create_dir_all(contents.join("Resources")).unwrap(); + let exe = contents.join("MacOS/Latch"); + std::fs::write(&exe, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(contents.join("Resources/marker"), marker).unwrap(); + std::fs::write( + contents.join("Info.plist"), + format!( + r#" + + +CFBundleExecutableLatch +CFBundleIdentifiercom.example.latch-update-test +CFBundlePackageTypeAPPL +CFBundleShortVersionString{version} +"# + ), + ) + .unwrap(); + let path = app.display().to_string(); + run( + "/usr/bin/codesign", + &["-s", "-", "--force", "--deep", &path], + ) + .unwrap(); + app + } + + /// A release tarball laid out as release.yml makes it. + #[cfg(target_os = "macos")] + fn tarball_of(dir: &Path, version: &str, marker: &str) -> PathBuf { + let src = dir.join(format!("build-{marker}")); + fake_app(&src, version, marker); + std::fs::write(src.join("README-MACOS.md"), "read me").unwrap(); + let tarball = dir.join(format!("{marker}.tar.gz")); + run( + "/usr/bin/tar", + &[ + "czf", + &tarball.display().to_string(), + "-C", + &src.display().to_string(), + "Latch.app", + "README-MACOS.md", + ], + ) + .unwrap(); + tarball + } + + #[cfg(target_os = "macos")] + fn scratch() -> PathBuf { + let dir = std::env::temp_dir().join(format!("latch-selfup-{:016x}", rand::random::())); + std::fs::create_dir(&dir).unwrap(); + dir + } + + #[cfg(target_os = "macos")] + #[test] + fn an_app_left_unpacked_earlier_is_never_what_gets_checked() { + let dir = scratch(); + let unpacked = dir.join("unpacked"); + // An earlier build of the same tag, valid in itself, left behind. + fake_app(&unpacked, "9.9.9", "stale"); + let tarball = tarball_of(&dir, "9.9.8", "fresh"); + let want = Version::new(9, 9, 9); + let e = prepare_in(&unpacked, &tarball, &want).unwrap_err(); + assert!(e.to_string().contains("says 9.9.8"), "{e:#}"); + assert_eq!( + prepare_in(&unpacked, &tarball, &Version::new(9, 9, 8)).unwrap(), + Version::new(9, 9, 8) + ); + assert!(!unpacked.exists(), "nothing unpacked is kept"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn the_install_checks_the_app_it_unpacks_into_the_stage() { + let dir = scratch(); + let apps = dir.join("Applications"); + let bundle = fake_app(&apps, "9.9.8", "running"); + let tarball = tarball_of(&dir, "9.9.9", "fresh"); + let marker = + |app: &Path| std::fs::read_to_string(app.join("Contents/Resources/marker")).unwrap(); + // The wrong version: refused, and the running app is untouched. + let e = replace_bundle(&bundle, &tarball, |t, staged| { + stage_from(t, staged, &Version::new(9, 9, 7)) + }) + .unwrap_err(); + assert!(format!("{e:#}").contains("says 9.9.9"), "{e:#}"); + assert_eq!(marker(&bundle), "running"); + assert_eq!(std::fs::read_dir(&apps).unwrap().count(), 1); + // The right one goes in, and only the app comes out of the archive. + replace_bundle(&bundle, &tarball, |t, staged| { + stage_from(t, staged, &Version::new(9, 9, 9)) + }) + .unwrap(); + assert_eq!(marker(&bundle), "fresh"); + assert_eq!(std::fs::read_dir(&apps).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn the_bundle_is_found_only_inside_an_app() { // The test binary lives in target/, not in a .app. @@ -791,6 +1049,66 @@ mod tests { assert!(!runs_at_least(closed, &want, Duration::ZERO)); } + #[test] + fn a_linux_host_behind_the_release_is_named_and_others_are_not() { + let r = rel("4.1.2"); + let old = Version::new(4, 1, 1); + let msg = manual_update_message("stuzha", "linux", &old, &r).expect("a line"); + assert!(msg.contains("stuzha") && msg.contains("4.1.1") && msg.contains("4.1.2")); + assert_eq!( + manual_update_message("stuzha", "linux", &Version::new(4, 1, 2), &r), + None + ); + for os in ["windows", "", "macOS"] { + assert_eq!(manual_update_message("pc", os, &old, &r), None, "{os:?}"); + } + } + + fn rel_with_mac_asset(v: &str, digest: &str) -> Release { + Release { + assets: vec![latch_core::update::Asset { + name: MAC_ASSET.into(), + url: String::new(), + size: 1, + sha256: Some(digest.into()), + }], + ..rel(v) + } + } + + #[test] + fn a_failed_self_update_waits_and_one_that_cannot_work_waits_for_a_new_asset() { + let now = Instant::now(); + let r = rel_with_mac_asset("4.1.2", "aa"); + assert!(self_update_due(None, &r, now)); + // A download that failed: again after a while, not every tick. + let failed = SelfFailure::new(&r, &anyhow!("timed out"), now); + assert!(!self_update_due(Some(&failed), &r, now)); + assert!(self_update_due(Some(&failed), &r, now + RETRY_AFTER)); + // A download that can never work (it says 4.1.1 inside): not again + // for these bytes, at once for new ones or a new release. + let unusable = SelfFailure::new( + &r, + &anyhow!("the download says 4.1.1, the release 4.1.2").context(Unusable), + now, + ); + assert!(!self_update_due( + Some(&unusable), + &r, + now + 100 * RETRY_AFTER + )); + assert!(self_update_due( + Some(&unusable), + &rel_with_mac_asset("4.1.2", "bb"), + now + )); + assert!(self_update_due( + Some(&unusable), + &rel_with_mac_asset("4.1.3", "aa"), + now + )); + } + #[test] fn pipe_and_eagain_are_retried() { assert!(is_transient(&anyhow!("connection closed"))); diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index 2bd8420..bb633d2 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -42,9 +42,14 @@ pub const UPDATE_MAX_BYTES: usize = 128 * 1024 * 1024; pub const FIRST_UPDATE_VERSION: &str = "3.1.0"; pub const CLIPBOARD_PATH: &str = "/v1/clipboard"; -/// Clipboard text longer than this is cut. It has to fit a 64 KiB request -/// body once JSON-escaped, and nobody pastes a novel through a stream. +/// Clipboard text longer than this is cut: nobody pastes a novel through a +/// stream. pub const CLIPBOARD_MAX_BYTES: usize = 32 * 1024; +/// ... and so is text that would be longer than this once JSON-escaped, so +/// it fits the 64 KiB body either side accepts with room for the other +/// fields. A quote or a newline takes two bytes there and other control +/// characters six, so 32 KiB of them alone would not fit. +pub const CLIPBOARD_MAX_JSON: usize = 60 * 1024; /// The PC's clipboard, as far as it is text. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -56,23 +61,43 @@ pub struct Clipboard { pub seq: u64, /// The text was cut to [`CLIPBOARD_MAX_BYTES`]. pub truncated: bool, + /// A write also sets the X selection (PRIMARY), so Shift+Insert pastes + /// it in a terminal. Linux hosts newer than 4.1.1; false from any other. + pub selection: bool, } impl Clipboard { - /// `text` cut to the size limit on a character boundary. + /// `text` cut on a character boundary to [`CLIPBOARD_MAX_BYTES`], and + /// to [`CLIPBOARD_MAX_JSON`] once escaped. pub fn fit(text: &str, seq: u64) -> Self { - let mut end = text.len().min(CLIPBOARD_MAX_BYTES); - while end > 0 && !text.is_char_boundary(end) { - end -= 1; + let (mut raw, mut escaped, mut end) = (0, 0, text.len()); + for (i, c) in text.char_indices() { + let (r, e) = (c.len_utf8(), json_len(c)); + if raw + r > CLIPBOARD_MAX_BYTES || escaped + e > CLIPBOARD_MAX_JSON { + end = i; + break; + } + raw += r; + escaped += e; } Self { text: text[..end].to_string(), seq, truncated: end < text.len(), + selection: false, } } } +/// How many bytes `c` takes inside a JSON string as serde_json writes it. +fn json_len(c: char) -> usize { + match c { + '"' | '\\' | '\u{08}' | '\u{0C}' | '\n' | '\r' | '\t' => 2, + c if (c as u32) < 0x20 => 6, + c => c.len_utf8(), + } +} + /// What `tailscale netcheck` says about one machine's network, in the /// terms that decide whether two machines can connect directly. Both apps /// report their own; the Mac combines the two to explain a relayed path. @@ -157,6 +182,23 @@ pub struct Streamer { /// with no monitor or speakers has no audio device, and Sunshine then /// streams silence. pub audio_problem: String, + /// The desktop the engine captures, `[width, height]`, when its size is + /// fixed whatever a viewer asks for: a Linux X screen. Absent when the + /// host matches the viewer's size itself (Windows' virtual display), and + /// from 4.1.1 and older hosts. A viewer asks for no more than this size: + /// a larger one is the same picture scaled up, blurrier and dearer to + /// encode; a smaller one is scaled down and cheaper. + pub capture_size: Option<[u32; 2]>, + /// The highest frame rate the engine keeps up with on this machine at + /// `capture_size`, or at 1920 × 1080 when that is absent; 0 when + /// nothing limits it. Set for software encoding on a small CPU, where + /// asking for more only drops frames. A viewer asking for fewer pixels + /// raises it in proportion. + pub max_fps: u32, + /// A viewer is connected right now, from the engine's own log. A Mac + /// holds a host update back while it is true, instead of sending the + /// whole executable to be turned down. False from older hosts. + pub viewer: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -232,6 +274,21 @@ mod tests { assert_eq!(back, s); } + #[test] + fn an_older_host_reports_no_capture_size_or_frame_rate_limit() { + let s: Status = + serde_json::from_str(r#"{"streamer":{"running":true,"encoder":"software"}}"#).unwrap(); + assert_eq!(s.streamer.capture_size, None); + assert_eq!(s.streamer.max_fps, 0); + let mut s = s; + s.streamer.capture_size = Some([1920, 1080]); + s.streamer.max_fps = 30; + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains(r#""capture_size":[1920,1080]"#), "{json}"); + let back: Status = serde_json::from_str(&json).unwrap(); + assert_eq!(back, s); + } + #[test] fn power_actions_are_lowercase_on_the_wire() { let r = PowerRequest { @@ -246,6 +303,13 @@ mod tests { assert!(serde_json::from_str::(r#"{"action":"Sleep"}"#).is_err()); } + #[test] + fn an_older_hosts_clipboard_does_not_claim_the_selection() { + let c: Clipboard = serde_json::from_str(r#"{"text":"x","seq":1}"#).unwrap(); + assert!(!c.selection); + assert!(!Clipboard::fit("x", 1).selection); + } + #[test] fn clipboard_is_cut_on_a_character_boundary() { let c = Clipboard::fit("héllo", 3); @@ -266,6 +330,43 @@ mod tests { assert_eq!(s.streamer.audio_problem, ""); } + #[test] + fn clipboard_text_fits_a_request_body_however_it_escapes() { + let body = |c: &Clipboard| serde_json::to_string(c).unwrap().len(); + for s in [ + "\u{1b}".repeat(CLIPBOARD_MAX_BYTES), + "\"".repeat(CLIPBOARD_MAX_BYTES), + "\n".repeat(CLIPBOARD_MAX_BYTES), + "a\u{1}\"é\\".repeat(20_000), + ] { + let c = Clipboard::fit(&s, u64::MAX); + assert!(c.truncated); + assert!(s.starts_with(&c.text)); + assert!(body(&c) <= 64 * 1024, "{} bytes", body(&c)); + } + // Plain text still runs to the byte limit. + let plain = "x".repeat(CLIPBOARD_MAX_BYTES + 10); + let c = Clipboard::fit(&plain, 1); + assert_eq!(c.text.len(), CLIPBOARD_MAX_BYTES); + assert!(c.truncated); + let exact = "x".repeat(CLIPBOARD_MAX_BYTES); + assert!(!Clipboard::fit(&exact, 1).truncated); + } + + #[test] + fn the_escaped_length_matches_serde_json() { + let mut chars: Vec = (0u8..128).map(char::from).collect(); + chars.extend(['é', '€', '한', '😀', '\u{7f}', '\u{2028}']); + for c in chars { + let s = c.to_string(); + assert_eq!( + json_len(c), + serde_json::to_string(&s).unwrap().len() - 2, + "{c:?}" + ); + } + } + #[test] fn first_update_version_is_3_1() { assert_eq!(FIRST_UPDATE_VERSION, "3.1.0"); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index d636eba..0b72a55 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -116,44 +116,193 @@ fn copy_tree(from: &Path, to: &Path) -> Result<()> { } /// Read `name` from the data directory; defaults when the file is missing -/// or damaged, because refusing to start over a setting is worse than -/// re-picking it. -pub fn load(name: &str) -> T { +/// or cannot be read, because refusing to start over a setting is worse +/// than re-picking it. One value this build does not understand (a choice +/// a newer build added, a typo) costs only that value: see [`salvage`]. +pub fn load(name: &str) -> T { let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - load_unlocked(name) + read_unlocked(name).unwrap_or_else(|e| { + tracing::warn!("{e:#}; using defaults"); + T::default() + }) } -fn load_unlocked(name: &str) -> T { - let Ok(path) = data_dir().map(|d| d.join(name)) else { - return T::default(); +/// Read `name` exactly as it is: the default only when there is no file, +/// an error for anything else, and the file left as it was. For a reader +/// that already holds good settings and should keep them rather than take +/// defaults over a file someone is still editing. +pub fn try_load(name: &str) -> Result { + let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = data_dir()?.join(name); + let Some(bytes) = read_bytes(&path)? else { + return Ok(T::default()); + }; + let text = + String::from_utf8(bytes).with_context(|| format!("{} is not text", path.display()))?; + toml::from_str(&text).with_context(|| path.display().to_string()) +} + +/// The file's bytes; `None` when there is no file. Any other failure (no +/// permission, a sharing violation, a disk error) is an error: that file +/// may hold every setting, and treating it as missing would overwrite it. +fn read_bytes(path: &Path) -> Result>> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("could not read {}", path.display())), + } +} + +fn read_unlocked(name: &str) -> Result { + let path = data_dir()?.join(name); + let Some(bytes) = read_bytes(&path)? else { + return Ok(T::default()); + }; + let Ok(text) = String::from_utf8(bytes) else { + tracing::warn!("{} is not text; using defaults", path.display()); + set_aside(&path, true); + return Ok(T::default()); + }; + let (value, dropped) = match parse_lenient::(&text) { + Ok((value, dropped)) if dropped.is_empty() => return Ok(value), + Ok(salvaged) => salvaged, + Err(e) => { + tracing::warn!("{} is unreadable ({e:#}); using defaults", path.display()); + set_aside(&path, true); + return Ok(T::default()); + } + }; + // Well-formed, but some value does not fit (one a newer build wrote, or + // a typo). Keep everything else, and the original beside it. + tracing::warn!( + "{}: could not read {}; using the default for {}", + path.display(), + dropped.join(", "), + if dropped.len() == 1 { "it" } else { "them" } + ); + set_aside(&path, false); + if let Err(e) = save_unlocked(name, &value) { + tracing::warn!("could not rewrite {}: {e:#}", path.display()); + } + Ok(value) +} + +/// Read settings text: as a whole when it fits `T`, else what [`salvage`] +/// keeps of it, with the keys it left out (none when it all fit). An error +/// only when the text is not TOML at all. +pub fn parse_lenient( + text: &str, +) -> Result<(T, Vec)> { + match toml::from_str(text) { + Ok(value) => Ok((value, Vec::new())), + Err(e) => { + let disk = text.parse::().map_err(|_| e)?; + Ok(salvage(&disk)) + } + } +} + +/// Keep a damaged settings file as `.bad--`: moved when it is +/// to be replaced by defaults, copied when what could be read of it is +/// written back in its place (so there is never a moment without it). +fn set_aside(path: &Path, moved: bool) { + let backup = path.with_extension(format!( + "bad-{}-{}", + std::process::id(), + TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + let kept = if moved { + std::fs::rename(path, &backup) + } else { + std::fs::copy(path, &backup).map(|_| ()) }; - match std::fs::read_to_string(&path) { - Ok(s) => toml::from_str(&s).unwrap_or_else(|e| { - tracing::warn!("{} is unreadable ({e}); using defaults", path.display()); - let backup = path.with_extension(format!( - "bad-{}-{}", - std::process::id(), - TEMP_ID.fetch_add(1, Ordering::Relaxed) - )); - if let Err(error) = std::fs::rename(&path, &backup) { - tracing::warn!("could not preserve damaged config: {error}"); - } else { - tracing::warn!("damaged config preserved at {}", backup.display()); + match kept { + Ok(()) => tracing::warn!("damaged config preserved at {}", backup.display()), + Err(e) => tracing::warn!("could not preserve damaged config: {e}"), + } +} + +/// What `T` can take from a well-formed file that, as a whole, it cannot: +/// starting from the defaults, each key goes in whole if `T` still reads, +/// else a table key by key, else not at all. Returns the value and the +/// keys left out (`stream.resolution`). Every settings struct here is +/// `#[serde(default)]`, so any part of one is still one. +fn salvage(disk: &toml::Table) -> (T, Vec) { + let reads = |t: &toml::Table| T::deserialize(toml::Value::Table(t.clone())).is_ok(); + let mut good = toml::Table::try_from(T::default()).unwrap_or_default(); + let mut dropped = Vec::new(); + graft(&mut good, &[], disk, &reads, &mut dropped); + let value = T::deserialize(toml::Value::Table(good)).unwrap_or_default(); + (value, dropped) +} + +fn graft( + root: &mut toml::Table, + at: &[String], + disk: &toml::Table, + reads: &dyn Fn(&toml::Table) -> bool, + dropped: &mut Vec, +) { + for (key, value) in disk { + let mut path = at.to_vec(); + path.push(key.clone()); + let mut trial = root.clone(); + put(&mut trial, &path, value.clone()); + if reads(&trial) { + *root = trial; + continue; + } + if let toml::Value::Table(inner) = value { + // Its parts, then, into the table the default has there (or an + // empty one, if that much reads). + let mut trial = root.clone(); + if !matches!(get(&trial, &path), Some(toml::Value::Table(_))) { + put(&mut trial, &path, toml::Value::Table(toml::Table::new())); + } + if reads(&trial) { + *root = trial; + graft(root, &path, inner, reads, dropped); + continue; } - T::default() - }), - Err(_) => T::default(), + } + dropped.push(path.join(".")); + } +} + +fn get<'a>(root: &'a toml::Table, path: &[String]) -> Option<&'a toml::Value> { + let (last, parents) = path.split_last()?; + let mut table = root; + for key in parents { + table = table.get(key)?.as_table()?; } + table.get(last) +} + +/// Set `path` in `root`; its parents are tables already (see `graft`). +fn put(root: &mut toml::Table, path: &[String], value: toml::Value) { + let Some((last, parents)) = path.split_last() else { + return; + }; + let mut table = root; + for key in parents { + match table.get_mut(key).and_then(|v| v.as_table_mut()) { + Some(t) => table = t, + None => return, + } + } + table.insert(last.clone(), value); } /// Apply a read-modify-write transaction without allowing another in-process /// settings, discovery, or pairing writer to replace the snapshot underneath it. +/// A file that exists but cannot be read is left alone: the edit fails +/// rather than replace it with defaults. pub fn update( name: &str, edit: impl FnOnce(&mut T), ) -> Result<()> { let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let mut value: T = load_unlocked(name); + let mut value: T = read_unlocked(name)?; let before = toml::to_string(&value)?; edit(&mut value); if toml::to_string(&value)? != before { @@ -237,6 +386,135 @@ mod tests { n: u32, } + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)] + enum Size { + Small, + #[default] + Native, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + #[serde(default)] + struct Stream { + size: Size, + fps: u32, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + #[serde(default)] + struct Machine { + name: String, + mac: Option, + seen: Option, + } + + /// The shape of client.toml: settings with an enum, and machines. + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + #[serde(default)] + struct Settings { + stream: Stream, + prompt: bool, + pcs: std::collections::BTreeMap, + } + + fn backups(stem: &str) -> Vec { + let prefix = format!("{stem}.bad-"); + std::fs::read_dir(data_dir().unwrap()) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&prefix)) + }) + .collect() + } + + /// A newer build's choice ("P720" read by a build without it) used to + /// cost the whole file: every pairing certificate and wake address. + #[test] + fn one_value_this_build_cannot_read_costs_only_that_value() { + let stem = format!("salvage-{}", std::process::id()); + let name = format!("{stem}.toml"); + let path = data_dir().unwrap().join(&name); + let text = "prompt = true\n[stream]\nsize = \"P9999\"\nfps = 45\n\ + [pcs.n1]\nname = \"PC\"\nmac = \"aa:bb:cc:dd:ee:ff\"\n\ + [pcs.n2]\nname = \"Mac\"\nseen = \"yesterday\"\n"; + std::fs::write(&path, text).unwrap(); + let got: Settings = load(&name); + assert_eq!(got.stream.size, Size::Native); + assert_eq!(got.stream.fps, 45); + assert!(got.prompt); + assert_eq!(got.pcs["n1"].mac.as_deref(), Some("aa:bb:cc:dd:ee:ff")); + assert_eq!(got.pcs["n2"].name, "Mac"); + assert_eq!(got.pcs["n2"].seen, None, "only the bad field goes"); + // The file now reads cleanly, and the original is kept once. + assert_eq!(try_load::(&name).unwrap(), got); + let kept = backups(&stem); + assert_eq!(kept.len(), 1); + assert_eq!(std::fs::read_to_string(&kept[0]).unwrap(), text); + assert_eq!(load::(&name), got); + assert_eq!(backups(&stem).len(), 1, "no new copy on a clean read"); + for p in kept { + std::fs::remove_file(p).unwrap(); + } + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn a_strict_read_never_moves_or_defaults_a_damaged_file() { + let stem = format!("strict-{}", std::process::id()); + let name = format!("{stem}.toml"); + let path = data_dir().unwrap().join(&name); + assert_eq!(try_load::(&name).unwrap(), Cfg::default(), "missing"); + std::fs::write(&path, "n = [oops").unwrap(); + assert!(try_load::(&name).is_err()); + std::fs::write(&path, "n = \"seven\"").unwrap(); + assert!(try_load::(&name).is_err()); + std::fs::write(&path, b"n = 1\n\xff").unwrap(); + assert!(try_load::(&name).is_err()); + assert!(path.exists(), "left where it was"); + assert!(backups(&stem).is_empty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn a_file_that_is_not_text_is_set_aside_like_junk() { + let stem = format!("binary-{}", std::process::id()); + let name = format!("{stem}.toml"); + let path = data_dir().unwrap().join(&name); + std::fs::write(&path, b"n = 1\n\xff").unwrap(); + assert_eq!(load::(&name), Cfg::default()); + let kept = backups(&stem); + assert_eq!(kept.len(), 1); + assert!(!path.exists()); + for p in kept { + std::fs::remove_file(p).unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn an_unreadable_file_is_not_overwritten() { + use std::os::unix::fs::PermissionsExt; + let name = format!("locked-{}.toml", std::process::id()); + save(&name, &Cfg { n: 7 }).unwrap(); + let path = data_dir().unwrap().join(&name); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read(&path).is_ok() { + // Root reads anything; nothing to show here. + std::fs::remove_file(path).unwrap(); + return; + } + assert!(update::(&name, |c| c.n = 9).is_err()); + assert!(try_load::(&name).is_err()); + assert_eq!(load::(&name), Cfg::default()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(load::(&name), Cfg { n: 7 }); + std::fs::remove_file(path).unwrap(); + } + #[test] fn save_then_load_round_trips_and_junk_falls_back() { let stem = format!("test-{}", std::process::id()); diff --git a/crates/core/src/dates.rs b/crates/core/src/dates.rs index 31d83e9..0dffb0c 100644 --- a/crates/core/src/dates.rs +++ b/crates/core/src/dates.rs @@ -42,6 +42,18 @@ pub fn ymd(unix: u64) -> String { format!("{y:04}-{m:02}-{d:02}") } +/// `YYYY-MM-DDTHH:MM:SSZ` (UTC) for a Unix time. +pub fn utc_timestamp(unix: u64) -> String { + let secs = unix % 86_400; + format!( + "{}T{:02}:{:02}:{:02}Z", + ymd(unix), + secs / 3600, + secs / 60 % 60, + secs % 60 + ) +} + /// The date part of an RFC 3339 string such as `2027-03-02T23:03:00Z`. pub fn parse_ymd(s: &str) -> Option<(i64, u32, u32)> { let s = s.get(..10)?; @@ -90,4 +102,10 @@ mod tests { assert!(days_until("2999-01-01T00:00:00Z").unwrap() > 300_000); assert!(days_until("2001-01-01T00:00:00Z").unwrap() < 0); } + + #[test] + fn a_unix_time_reads_as_utc() { + assert_eq!(utc_timestamp(0), "1970-01-01T00:00:00Z"); + assert_eq!(utc_timestamp(1_792_258_000), "2026-10-17T17:26:40Z"); + } } diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index c84fb16..63d88e4 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -685,10 +685,10 @@ mod tests { fn an_address_that_refuses_is_skipped_for_the_next_one() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let open = listener.local_addr().unwrap(); - let closed = TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap(); + // Port 0 can never be connected to. A port freed by dropping a + // listener could be: the tests run in parallel, and another one + // binds the next free port, often that very one. + let closed: SocketAddr = "127.0.0.1:0".parse().unwrap(); std::thread::spawn(move || serve(listener, |_, _| Response::json(200, &Ack::ok()))); let t = Duration::from_secs(2); let r = request(&[closed, open][..], "GET", "/", None, t).unwrap(); diff --git a/crates/core/src/legacy.rs b/crates/core/src/legacy.rs index eb37ebf..0b13ea1 100644 --- a/crates/core/src/legacy.rs +++ b/crates/core/src/legacy.rs @@ -15,6 +15,10 @@ pub const STREAMER_KIND: &str = "BroLink"; /// The update push's headers, which a 4.0 host requires and a 4.0 Mac sends. pub const UPDATE_VERSION_HEADER: &str = "x-brolink-version"; pub const UPDATE_SHA256_HEADER: &str = "x-brolink-sha256"; +/// A Windows host from 4.0 or older: its process name, and where the +/// install script put it under `%LOCALAPPDATA%`. +pub const WINDOWS_HOST_PROCESS: &str = "brolink-host"; +pub const WINDOWS_HOST_EXE: &str = r"BroLink\brolink-host.exe"; /// Whether `app` is what a Latch node (this version or an older one) puts in /// `Status::app`. diff --git a/crates/core/src/screens.rs b/crates/core/src/screens.rs index cb22437..f5fc633 100644 --- a/crates/core/src/screens.rs +++ b/crates/core/src/screens.rs @@ -3,8 +3,18 @@ //! 16:9 sizes, and a PC whose only display is virtual can only switch to a //! size that display lists. Setup on the PC lists these. -/// The standard sizes behind the quality choices: 1080p, 1440p and 4K. -pub const STANDARD: [(u32, u32); 3] = [(1920, 1080), (2560, 1440), (3840, 2160)]; +/// The standard sizes behind the quality choices, smallest first: 540p, +/// 720p and 900p for a PC that cannot encode many pixels quickly (software +/// encoding on a small CPU) or a network that cannot carry them, then +/// 1080p, 1440p and 4K. +pub const STANDARD: [(u32, u32); 6] = [ + (960, 540), + (1280, 720), + (1600, 900), + (1920, 1080), + (2560, 1440), + (3840, 2160), +]; /// Native pixel sizes of Apple displays since 2015, then the common /// external monitors. Points times the native scale factor, whatever diff --git a/crates/core/src/tailscale.rs b/crates/core/src/tailscale.rs index 02350f8..10bede3 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -59,17 +59,27 @@ fn find_cli() -> Option { }) } -/// `tailscale status --json`, the parts Latch reads. +/// `tailscale status --json`, the parts Latch reads. Tailscale writes an +/// empty map or list as `null` (`"Peer": null` on a machine alone in its +/// tailnet; `Peer`, `User` and `Self.TailscaleIPs` signed out), which reads +/// as empty here rather than failing the whole status. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "PascalCase")] pub struct Status { pub backend_state: String, - #[serde(rename = "Self")] + #[serde(rename = "Self", deserialize_with = "null_as_default")] pub self_node: Node, + #[serde(deserialize_with = "null_as_default")] pub peer: BTreeMap, + #[serde(deserialize_with = "null_as_default")] pub user: BTreeMap, /// The tailnet this machine is in. Absent before Tailscale 1.26. pub current_tailnet: Option, + /// Whether tailscaled runs a kernel TUN device. `false` is userspace + /// networking (a container's tailscaled), where every tailnet peer + /// reaches a local service from 127.0.0.1; `None` when it does not say. + #[serde(rename = "TUN")] + pub tun: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -93,14 +103,16 @@ pub struct Node { pub os: String, #[serde(rename = "UserID")] pub user_id: u64, - #[serde(rename = "TailscaleIPs")] + #[serde(rename = "TailscaleIPs", deserialize_with = "null_as_default")] pub tailscale_ips: Vec, pub online: bool, - /// The public `ip:port` of the peer's last direct path, if any. + /// The `ip:port` of the peer's last direct path, if any: its public + /// address, or its LAN address when both are on one network. #[serde(rename = "CurAddr")] pub cur_addr: String, /// The peer's home DERP region code ("tok", "lax"), which is where its - /// packets go when there is no direct path. + /// packets go when there is no direct path. Every peer has one, whether + /// or not its packets go that way. pub relay: String, /// The peer relay carrying this path, as `ip:port:vni:id`, when /// Tailscale found one. Empty for a direct or DERP path, and always @@ -109,14 +121,18 @@ pub struct Node { #[serde(rename = "PeerRelay")] pub peer_relay: String, /// ACL tags ("tag:relay"), present only on tagged nodes. + #[serde(deserialize_with = "null_as_default")] pub tags: Vec, - /// Traffic has flowed recently, so `cur_addr` and `relay` describe the - /// path in use rather than a guess. + /// Traffic has flowed recently, so an empty `cur_addr` means the + /// packets go through a relay rather than that nobody knows yet. pub active: bool, /// When this machine's node key stops working (RFC 3339), unless key /// expiry is disabled for it in the admin console; a zero time then. pub key_expiry: String, pub expired: bool, + /// A node `tailscale status` hides: it is only here because this + /// machine is shared out to its owner. + pub sharee_node: bool, } impl Node { @@ -157,16 +173,29 @@ impl Node { pub fn is_relay(&self) -> bool { self.tags.iter().any(|t| t == RELAY_TAG) } - /// The peer's public IPv4 address, when Tailscale reached it directly. + /// An exit node of the Mullvad add-on (hundreds of them, one per + /// location), which is never a machine of the user's. Told apart by its + /// name, as the CLI does: an ordinary exit node can carry a location too. + pub fn is_mullvad(&self) -> bool { + self.dns_name + .trim_end_matches('.') + .to_ascii_lowercase() + .ends_with(".mullvad.ts.net") + } + /// The peer's public IPv4 address, when Tailscale reached it directly + /// over the internet. A direct path inside one network is a LAN address, + /// which says nothing about the router a wake packet must go to. pub fn public_ipv4(&self) -> Option { - self.cur_addr.rsplit_once(':')?.0.parse().ok() + let ip: Ipv4Addr = self.cur_addr.rsplit_once(':')?.0.parse().ok()?; + is_public_v4(ip).then_some(ip) } /// Packets reach this peer directly rather than through a DERP relay. - /// Unknown until traffic has flowed; `None` then. + /// Unknown until traffic has flowed; `None` then, even though `relay` + /// names a home region for every peer. pub fn direct(&self) -> Option { if !self.cur_addr.is_empty() { Some(true) - } else if self.active || !self.relay.is_empty() { + } else if self.active { Some(false) } else { None @@ -174,6 +203,28 @@ impl Node { } } +/// An IPv4 address on the internet: not private, loopback, link-local, +/// broadcast or multicast, and not in 100.64.0.0/10 (Tailscale's own, and +/// carrier-grade NAT). +pub fn is_public_v4(ip: Ipv4Addr) -> bool { + !(ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_multicast() + || is_tailnet(ip)) +} + +/// A JSON `null` read as the type's empty value. +fn null_as_default<'de, D, T>(d: D) -> std::result::Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::::deserialize(d)?.unwrap_or_default()) +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "PascalCase")] pub struct User { @@ -193,18 +244,37 @@ impl Status { .map(|u| u.login_name.as_str()) } /// Every tailnet peer that can share a desktop: any OS, minus `tag:relay` - /// nodes that exist only to carry packets. Sorted by hostname. + /// nodes that exist only to carry packets, and the nodes `tailscale + /// status` itself hides (machines this one is shared out to, Mullvad's + /// exit nodes). Sorted by hostname. pub fn machine_peers(&self) -> Vec<&Node> { - let mut v: Vec<&Node> = self.peer.values().filter(|n| !n.is_relay()).collect(); + let mut v: Vec<&Node> = self.listed_peers().filter(|n| !n.is_relay()).collect(); v.sort_by(|a, b| a.host_name.cmp(&b.host_name)); v } /// Nodes tagged `tag:relay`, any OS. Sorted by hostname like the PC list. pub fn relay_peers(&self) -> Vec<&Node> { - let mut v: Vec<&Node> = self.peer.values().filter(|n| n.is_relay()).collect(); + let mut v: Vec<&Node> = self.listed_peers().filter(|n| n.is_relay()).collect(); v.sort_by(|a, b| a.host_name.cmp(&b.host_name)); v } + /// The peers `tailscale status` lists. + fn listed_peers(&self) -> impl Iterator { + self.peer + .values() + .filter(|n| !n.sharee_node && !n.is_mullvad()) + } +} + +/// What a backend state other than "Running" means, for people. +pub fn state_words(backend_state: &str) -> String { + match backend_state { + "" | "NoState" => "not running".into(), + "NeedsLogin" => "signed out".into(), + "NeedsMachineAuth" => "waiting for an admin to approve this machine".into(), + "InUseOtherUser" => "in use by another user of this computer".into(), + other => other.to_lowercase(), + } } pub fn parse_status(json: &str) -> Result { @@ -238,14 +308,7 @@ pub fn status() -> Result { let out = run(Command::new(cli).args(["status", "--json"]))?; let st = parse_status(&out)?; if !st.running() { - bail!( - "Tailscale is {}", - if st.backend_state.is_empty() { - "not running".into() - } else { - st.backend_state.to_lowercase() - } - ); + bail!("Tailscale is {}", state_words(&st.backend_state)); } Ok(st) } @@ -275,6 +338,32 @@ pub fn whois(ip: std::net::IpAddr) -> Result { serde_json::from_str(&out).context("parse tailscale whois") } +/// `tailscale whois` for a connection that reached this machine from +/// `addr`, a loopback `ip:port`. Under userspace networking tailscaled +/// relays each tailnet peer's TCP connection from loopback and remembers +/// which peer every local port belongs to. `Ok(None)` when it knows of +/// none: a process on this machine made the connection. +pub fn whois_addr(addr: std::net::SocketAddr) -> Result> { + let cli = cli().ok_or_else(|| anyhow::anyhow!("Tailscale is not installed"))?; + let out = run_limited( + Command::new(cli).args(["whois", "--json", "--proto=tcp", &addr.to_string()]), + Duration::from_secs(2), + ); + match out { + Ok(out) => serde_json::from_str(&out) + .map(Some) + .context("parse tailscale whois"), + Err(e) if no_such_peer(&e.to_string()) => Ok(None), + Err(e) => Err(e), + } +} + +/// The CLI's answer for an address no peer is behind: it logs +/// "2026/10/17 16:46:33 peer not found" and exits 1. +fn no_such_peer(err: &str) -> bool { + err.contains("peer not found") || err.contains("no match for") +} + /// `tailscale netcheck --format=json`, the parts that explain why a peer /// is relayed: whether UDP works at all, what kind of NAT this machine is /// behind, and whether the router will map a port for it. @@ -303,6 +392,17 @@ pub struct NetCheck { } impl NetCheck { + /// [`NetCheck::report`] with the home relay region Tailscale itself + /// names (`Self.Relay` in its status), when there is one: its own name + /// for a region cannot fall out of date like Latch's table of ids. + pub fn report_near(&self, home: &str) -> crate::api::NatReport { + let mut r = self.report(); + if !home.is_empty() { + r.derp = home.to_string(); + } + r + } + /// The summary the host puts in its status and the client shows. pub fn report(&self) -> crate::api::NatReport { crate::api::NatReport { @@ -343,6 +443,8 @@ pub fn is_tailnet_ip(ip: IpAddr) -> bool { } /// The three-letter code of a DERP region, as `tailscale status` prints it. +/// From `tailscale debug derp-map` (Tailscale 1.102, October 2026); a +/// region added later has no code here, and its id says nothing. pub fn derp_code(region: i32) -> &'static str { match region { 1 => "nyc", @@ -358,20 +460,21 @@ pub fn derp_code(region: i32) -> &'static str { 11 => "sao", 12 => "ord", 13 => "den", - 14 => "jnb", - 15 => "mia", - 16 => "lax", - 17 => "par", - 18 => "mad", - 19 => "ams", + 14 => "ams", + 15 => "jnb", + 16 => "mia", + 17 => "lax", + 18 => "par", + 19 => "mad", 20 => "hkg", 21 => "tor", 22 => "waw", - 23 => "nai", + 23 => "dbi", 24 => "hnl", - 25 => "nue", - 26 => "hel", - 27 => "dbi", + 25 => "nai", + 26 => "nue", + 27 => "iad", + 28 => "hel", _ => "", } } @@ -406,6 +509,7 @@ pub fn derp_city(code: &str) -> &str { "nue" => "Nuremberg", "hel" => "Helsinki", "dbi" => "Dubai", + "iad" => "Ashburn", other => other, } } @@ -509,7 +613,7 @@ mod tests { "UserID": 42, "TailscaleIPs": ["fd7a::2", "100.64.0.30"], "Online": true, "CurAddr": "203.0.113.5:41641", "Relay": "lax", "Active": true, "KeyExpiry": "2999-03-02T23:03:00Z"}, "nodekey:c": {"ID": "nPC1", "HostName": "Den", "DNSName": "den.example.ts.net.", "OS": "windows", - "UserID": 43, "TailscaleIPs": ["100.64.0.31"], "Online": false}, + "UserID": 43, "TailscaleIPs": ["100.64.0.31"], "Online": false, "Relay": "sea"}, "nodekey:d": {"ID": "nPC3", "HostName": "Gaming-PC-2", "OS": "windows", "UserID": 42, "TailscaleIPs": ["100.64.0.11"], "Online": true, "CurAddr": "", "Relay": "tok", "Active": true} }, @@ -550,7 +654,7 @@ mod tests { assert!(pcs[2].key_expiry_days().unwrap() > 300_000); assert_eq!(pcs[0].key_expiry_days(), None); // Office is reached directly; Gaming-PC-2 only through the Tokyo relay; - // Den has not been talked to, so nobody knows. + // Den has not been talked to, so nobody knows, home relay or not. assert_eq!(pcs[2].direct(), Some(true)); assert_eq!(pcs[1].direct(), Some(false)); assert_eq!(pcs[1].relay, "tok"); @@ -571,13 +675,16 @@ mod tests { assert!(!r.portmap); assert_eq!(r.derp, "tok"); let hard = parse_netcheck( - r#"{"UDP":true,"MappingVariesByDestIP":true,"UPnP":true,"PreferredDERP":16}"#, + r#"{"UDP":true,"MappingVariesByDestIP":true,"UPnP":true,"PreferredDERP":17}"#, ) .unwrap(); let r = hard.report(); assert_eq!(r.hard, Some(true)); assert!(r.portmap, "UPnP counts as a port mapping"); assert_eq!(r.derp, "lax"); + // Tailscale's own name for the home region wins over the table. + assert_eq!(hard.report_near("sfo").derp, "sfo"); + assert_eq!(hard.report_near("").derp, "lax"); assert_eq!(derp_city("lax"), "Los Angeles"); assert_eq!(derp_city("tok"), "Tokyo"); assert_eq!(derp_city("zzz"), "zzz"); @@ -587,6 +694,140 @@ mod tests { assert_eq!(unknown.report().derp, ""); } + /// `tailscale debug derp-map` on 1.102: the ids from 14 up moved when + /// Amsterdam came in, which put a Mac in Los Angeles near "Paris". + #[test] + fn derp_ids_match_tailscales_map() { + for (id, code) in [ + (13, "den"), + (14, "ams"), + (15, "jnb"), + (16, "mia"), + (17, "lax"), + (18, "par"), + (19, "mad"), + (23, "dbi"), + (25, "nai"), + (26, "nue"), + (27, "iad"), + (28, "hel"), + ] { + assert_eq!(derp_code(id), code, "region {id}"); + assert_ne!(derp_city(code), code, "{code} has a city"); + } + } + + /// Real 1.102 output keeps `null` for empty maps and lists. + #[test] + fn null_maps_and_lists_read_as_empty() { + // Signed in, and alone in the tailnet. + let alone = parse_status( + r#"{"BackendState":"Running","TUN":true, + "Self":{"ID":"n1","HostName":"pc","OS":"windows","UserID":5, + "TailscaleIPs":["100.64.0.5"],"Tags":null}, + "CurrentTailnet":{"Name":"a@b.c"},"CertDomains":null,"Peer":null, + "User":{"5":{"ID":5,"LoginName":"a@b.c"}}}"#, + ) + .unwrap(); + assert!(alone.running()); + assert!(alone.machine_peers().is_empty()); + assert_eq!(alone.self_login(), Some("a@b.c")); + assert_eq!(alone.self_node.ipv4(), Some("100.64.0.5".parse().unwrap())); + // Signed out. + let out = parse_status( + r#"{"BackendState":"NeedsLogin","TailscaleIPs":null, + "Self":{"ID":"","HostName":"pc","OS":"windows","UserID":0, + "TailscaleIPs":null,"Addrs":[]}, + "CurrentTailnet":null,"CertDomains":null,"Peer":null,"User":null}"#, + ) + .unwrap(); + assert!(!out.running()); + assert_eq!(out.self_node.ipv4(), None); + assert_eq!(state_words(&out.backend_state), "signed out"); + assert!(parse_status(r#"{"Self":null}"#).is_ok()); + assert_eq!(state_words(""), "not running"); + assert_eq!(state_words("Stopped"), "stopped"); + assert_eq!(state_words("Starting"), "starting"); + } + + #[test] + fn only_an_internet_address_is_a_public_one() { + let at = |cur: &str| Node { + cur_addr: cur.into(), + ..Default::default() + }; + assert_eq!( + at("203.0.113.5:41641").public_ipv4(), + Some("203.0.113.5".parse().unwrap()) + ); + assert_eq!( + at("116.125.113.36:41641").public_ipv4(), + Some("116.125.113.36".parse().unwrap()) + ); + // Two machines on one network go direct over the LAN. + for lan in [ + "192.168.55.57:41641", + "10.0.0.5:41641", + "172.16.3.4:41641", + "169.254.1.1:41641", + "100.100.1.1:41641", + "127.0.0.1:41641", + "[2001:db8::1]:41641", + "", + ] { + assert_eq!(at(lan).public_ipv4(), None, "{lan}"); + } + } + + #[test] + fn an_idle_peer_has_an_unknown_path_whatever_its_home_relay() { + let st = parse_status( + r#"{"BackendState":"Running","Peer":{ + "a": {"HostName":"stuzha","Online":true,"Active":false,"CurAddr":"","Relay":"sfo"}, + "b": {"HostName":"hermes","Online":true,"Active":true,"CurAddr":"","Relay":"tok"}, + "c": {"HostName":"office","Online":true,"Active":false,"CurAddr":"203.0.113.9:41641","Relay":"lax"}, + "d": {"HostName":"pc","Online":true,"Active":false,"CurAddr":"","Relay":"lax", + "PeerRelay":"100.64.0.40:40000:vni:17"} + }}"#, + ) + .unwrap(); + assert_eq!(st.peer["a"].direct(), None, "nothing sent yet"); + assert_eq!(st.peer["b"].direct(), Some(false), "traffic, but relayed"); + assert_eq!( + st.peer["c"].direct(), + Some(true), + "a direct path still trusted" + ); + assert_eq!(st.peer["d"].direct(), None); + } + + #[test] + fn mullvad_and_sharee_nodes_are_no_machines() { + let st = parse_status( + r#"{"BackendState":"Running","Peer":{ + "a": {"HostName":"us-lax-wg-001","DNSName":"us-lax-wg-001.mullvad.ts.net.","OS":"linux", + "Online":true,"ExitNodeOption":true,"Location":{"Country":"USA","City":"Los Angeles"}}, + "b": {"HostName":"se-sto-wg-002","DNSName":"se-sto-wg-002.mullvad.ts.net.","OS":"linux", + "Online":true,"ExitNodeOption":true,"ExitNode":true}, + "c": {"HostName":"friends-laptop","DNSName":"friends-laptop.other.ts.net.","OS":"macOS", + "Online":true,"ShareeNode":true}, + "d": {"HostName":"vps","DNSName":"vps.example.ts.net.","OS":"linux","Online":true, + "ExitNodeOption":true,"Location":{"Country":"USA","City":"San Jose"}}, + "e": {"HostName":"Gaming-PC","DNSName":"gaming-pc.example.ts.net.","OS":"windows","Online":true} + }}"#, + ) + .unwrap(); + assert_eq!( + st.machine_peers() + .iter() + .map(|n| n.host_name.as_str()) + .collect::>(), + ["Gaming-PC", "vps"], + "an exit node of the user's own stays" + ); + assert!(st.relay_peers().is_empty()); + } + #[test] fn a_stopped_daemon_is_not_running() { let st = parse_status(r#"{"BackendState":"Stopped"}"#).unwrap(); @@ -740,6 +981,19 @@ mod tests { assert!(parse_status(SAMPLE).unwrap().current_tailnet.is_none()); } + #[test] + fn userspace_networking_is_told_from_a_kernel_tun() { + let st = parse_status(r#"{"BackendState":"Running","TUN":false}"#).unwrap(); + assert_eq!(st.tun, Some(false)); + let st = parse_status(r#"{"BackendState":"Running","TUN":true}"#).unwrap(); + assert_eq!(st.tun, Some(true)); + assert_eq!(parse_status(SAMPLE).unwrap().tun, None, "older versions"); + assert!(no_such_peer("2026/10/17 16:46:33 peer not found")); + assert!(no_such_peer("no match for IP:port 127.0.0.1:5")); + assert!(!no_such_peer("tailscale timed out")); + assert!(!no_such_peer("failed to connect to local Tailscale daemon")); + } + #[test] fn whois_is_parsed() { let w: WhoIs = serde_json::from_str( diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index e9174a9..29b4145 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -33,6 +33,11 @@ eframe.workspace = true [target.'cfg(not(windows))'.dependencies] arboard = "3" +# The X screen size the engine captures (see streamer::capture_size). +# arboard already links it on Linux. +[target.'cfg(target_os = "linux")'.dependencies] +x11rb = "0.13" + [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Devices_Display", @@ -40,11 +45,14 @@ windows = { version = "0.58", features = [ "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_System_DataExchange", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Power", "Win32_System_RemoteDesktop", + "Win32_UI_HiDpi", + "Win32_UI_WindowsAndMessaging", ] } [target.'cfg(windows)'.build-dependencies] diff --git a/crates/host/src/app.rs b/crates/host/src/app.rs index fbabb86..e9e0fa8 100644 --- a/crates/host/src/app.rs +++ b/crates/host/src/app.rs @@ -30,6 +30,14 @@ pub struct Shared { pub setup_running: bool, pub setup_result: Option>, pub setup_log: Vec, + /// A Mac whose owner turned sharing off: it only connects to the + /// others, so the window neither starts nor restarts the service. + pub viewer_only: bool, + /// The service runs a newer Latch than this window, and this window's + /// own file has not been replaced: it is an older copy (a download, a + /// disk image) opened by hand. Relaunching it would only start the same + /// old file again, so it says so instead, and leaves the service alone. + pub stale_panel: Option, /// Moves whenever anything above changes, so a reader can skip copying /// what it already has. pub rev: u64, @@ -98,10 +106,17 @@ pub struct HostApp { impl HostApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - let shared = Arc::new(Mutex::new(Shared::default())); + let cfg = HostConfig::load(); + let viewer_only = !crate::service::starts_with_window(&cfg); + let shared = Arc::new(Mutex::new(Shared { + viewer_only, + // Down on purpose, as if stopped here: nothing restarts it. + service_error: viewer_only.then(|| STOPPED.into()), + ..Default::default() + })); spawn_poller(shared.clone(), cc.egui_ctx.clone()); Self { - cfg: HostConfig::load(), + cfg, autostart: setup::starts_with_windows(), bundled_engine: std::env::current_exe().is_ok_and(|e| setup::bundled_engine(&e)), live: true, @@ -255,6 +270,15 @@ impl HostApp { self.autostart = on; return; } + if self.os == Os::Mac && on { + // Loading the login item starts the service at once, and on a + // Mac it exits unless it reads that this Mac is shared. + let mut cfg = self.cfg.clone(); + cfg.start_with_windows = true; + if let Err(e) = cfg.save() { + tracing::warn!("could not save host config: {e:#}"); + } + } let result = std::env::current_exe() .map_err(anyhow::Error::from) .and_then(|exe| setup::set_start_with_windows(on, &exe)); @@ -263,9 +287,26 @@ impl HostApp { self.autostart = on; self.cfg.start_with_windows = on; self.dirty = true; + if self.os == Os::Mac { + // On a Mac this is the switch between sharing and only + // connecting to the others: it takes effect now. + self.shared.lock().viewer_only = !on; + if on { + self.start_service(); + } else { + self.stop_sharing(); + } + } } Err(e) => { tracing::warn!("autostart: {e:#}"); + if self.os == Os::Mac && on { + // Not shared after all: take back what was saved above, + // or the next window to open would start the service. + if let Err(e) = self.cfg.save() { + tracing::warn!("could not save host config: {e:#}"); + } + } self.note = Some(( Tone::Danger, format!("Couldn't change whether Latch starts at login: {e}."), @@ -294,6 +335,20 @@ impl HostApp { s.touch(); } + /// Stop the service and the engine it started: nothing is shared and + /// nothing of Latch keeps running once the window closes. + fn stop_sharing(&mut self) { + self.stop_service(); + #[cfg(not(windows))] + if self.live { + std::thread::spawn(|| { + // After the service has gone, or it starts the engine again. + std::thread::sleep(Duration::from_millis(800)); + crate::unix_setup::stop_engine(); + }); + } + } + fn start_service(&mut self) { { let mut s = self.shared.lock(); @@ -350,7 +405,20 @@ impl HostApp { let setup_running = shared.setup_running; let setup_result = shared.setup_result.clone(); let setup_log = shared.setup_log.clone(); + let viewer_only = shared.viewer_only; + let stale_panel = shared.stale_panel.clone(); drop(shared); + if self.live + && self.os == Os::Mac + && viewer_only + && self.cfg.start_with_windows + && !self.dirty + { + // Another window (or a hand edit) turned sharing off. Saving any + // other setting from here must not turn it back on. + self.cfg.start_with_windows = false; + self.autostart = false; + } if setup_running { self.busy_since.get_or_insert_with(Instant::now); // The seconds counter under the spinner. @@ -370,7 +438,22 @@ impl HostApp { if let Some((tone, text, _)) = self.note.clone() { ui::notice(ui, tone, &text); } - if s.version != env!("CARGO_PKG_VERSION") { + if let Some(path) = &stale_panel { + ui::notice( + ui, + Tone::Warning, + &format!( + "This window is an older copy of Latch ({}, at {path}); the background service runs {}. Close it and open Latch from {}.", + env!("CARGO_PKG_VERSION"), + s.version, + if self.os == Os::Windows { + "the Start menu" + } else { + "Applications" + } + ), + ); + } else if s.version != env!("CARGO_PKG_VERSION") { ui::notice( ui, Tone::Neutral, @@ -407,6 +490,23 @@ impl HostApp { fn no_service(&mut self, ui: &mut egui::Ui, error: Option<&str>) { ui::page_header(ui, "Sharing", None, |_| {}); + if self.shared.lock().viewer_only { + ui::banner( + ui, + Tone::Neutral, + &format!("This {} is not shared", self.os.noun()), + Some(&format!( + "It connects to your other machines, but they can't connect to it, and nothing of Latch runs once this window closes. Share this {} to let them in, also after a restart.", + self.os.noun() + )), + |ui| { + if ui::primary_button(ui, &format!("Share this {}", self.os.noun())).clicked() { + self.set_autostart(true); + } + }, + ); + return; + } if error == Some(STOPPED) { ui::banner( ui, @@ -697,10 +797,14 @@ impl HostApp { } rows.push(Kv::new("Wake-on-LAN", wake).tone(tone)); } - rows.push(Kv::new( - "LAN address", - s.lan_ip.clone().unwrap_or_else(|| "None".into()), - )); + // Only Windows reports one (with its wake adapter), and there "None" + // is worth seeing; elsewhere it only looked like something missing. + if self.os == Os::Windows || s.lan_ip.is_some() { + rows.push(Kv::new( + "LAN address", + s.lan_ip.clone().unwrap_or_else(|| "None".into()), + )); + } if self.os == Os::Windows { let (tone, text) = match gamepad { Some(true) => (Tone::Success, "Virtual controller driver installed"), @@ -819,27 +923,51 @@ impl HostApp { ) { self.dirty = true; } + if self.os != Os::Linux { + // A Linux server does not idle-sleep; there it would do nothing. + let (title, detail) = if self.os == Os::Mac { + ( + "Keep this Mac awake while plugged in", + "Asleep, Tailscale is off too, and no other machine can wake it. Closing the lid or choosing Sleep still sleeps it.", + ) + } else { + ( + "Keep this PC awake while plugged in", + "Asleep, Tailscale is off too, so machines on other networks can't wake it. Sleep from the Start menu or another machine still works.", + ) + }; + ui::row_separator(ui); + if ui::toggle_row(ui, &mut self.cfg.stay_awake, title, Some(detail)) { + self.dirty = true; + } + } if self.os == Os::Windows { ui::row_separator(ui); if ui::toggle_row( ui, - &mut self.cfg.stay_awake, - "Keep this PC awake while plugged in", - Some("Asleep, Tailscale is off too, so machines on other networks can't wake it. Sleep from the Start menu or another machine still works."), + &mut self.cfg.accept_updates, + "Install updates your other machines send", + Some("A Mac on your Tailscale account sends this PC each new Latch and it restarts by itself. Off, update Latch here."), ) { self.dirty = true; } } ui::row_separator(ui); let mut auto = self.autostart; - if ui::toggle_row( - ui, - &mut auto, - "Start Latch when you log in", - Some(&format!( - "So others can connect after this {noun} restarts, with nobody at the keyboard." - )), - ) { + let (title, detail) = if self.os == Os::Mac { + ( + "Share this Mac".to_string(), + "Others can connect whenever it is on, also after a restart. Off, this Mac only connects to your other machines and nothing of Latch runs once its window closes.".to_string(), + ) + } else { + ( + "Start Latch when you log in".to_string(), + format!( + "So others can connect after this {noun} restarts, with nobody at the keyboard." + ), + ) + }; + if ui::toggle_row(ui, &mut auto, &title, Some(&detail)) { self.set_autostart(auto); } }); @@ -1005,11 +1133,15 @@ fn update_action(service: &str, panel: &str) -> UpdateAction { } } -fn relaunch_this_exe() -> bool { - let Ok(exe) = std::env::current_exe() else { +/// Start the window again from the path this one started from, if an +/// update has put a new file there. A window opened from an older copy (a +/// download, a disk image) whose file nobody replaced would only start +/// itself again, and that one again, once a second, forever. +fn relaunch_if_replaced() -> bool { + let Some(started) = crate::service::started_from().filter(|s| s.replaced()) else { return false; }; - std::process::Command::new(exe) + std::process::Command::new(started.path()) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -1017,6 +1149,38 @@ fn relaunch_this_exe() -> bool { .is_ok() } +/// When the window restarts a service that does not answer: after two +/// failed polls (about two seconds), then after waits that double up to a +/// minute. A service that stops as it starts (port 47850 taken by another +/// program, a crash at start) was started again every four seconds for as +/// long as the window stayed open. Starts over once the service answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Respawn { + next_at: u32, + wait: u32, +} + +impl Respawn { + const LONGEST: u32 = 60; + + fn new() -> Self { + Self { + next_at: 2, + wait: 4, + } + } + + /// Whether to start the service at this count of failed polls. + fn due(&mut self, failures: u32) -> bool { + if failures < self.next_at { + return false; + } + self.next_at = failures + self.wait; + self.wait = (self.wait * 2).min(Self::LONGEST); + true + } +} + /// Poll the service every second; refresh Sunshine's client list now and /// then; restart the service after an update. The window repaints only /// when an answer differs from the last. @@ -1035,6 +1199,8 @@ fn only_aged(old: &Status, new: &Status) -> bool { fn spawn_poller(shared: Arc>, ctx: egui::Context) { std::thread::spawn(move || { let mut failures = 0u32; + let mut respawn = Respawn::new(); + let mut quick_exit: Option = None; let mut tick = 0u64; let mut restarted_service = false; loop { @@ -1049,25 +1215,36 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { match r { Ok(st) => { failures = 0; + respawn = Respawn::new(); + quick_exit = None; match update_action(&st.version, env!("CARGO_PKG_VERSION")) { UpdateAction::RestartService if !restarted_service => { // This panel is newer than the service (an // update replaced the exe; current_exe still // names it). Restart once; looping on != used // to kill a *newer* service every second. + // Through launchd or systemd, which would not + // restart a service that quit by itself. restarted_service = true; - let _ = http::request( - ("127.0.0.1", CONTROL_PORT), - "POST", - "/v1/quit", - None, - Duration::from_secs(2), - ); - std::thread::sleep(Duration::from_secs(1)); - crate::service::ensure_service_running(); + crate::service::restart_service(); } - UpdateAction::RelaunchPanel if relaunch_this_exe() => { - std::process::exit(0); + UpdateAction::RelaunchPanel => { + if relaunch_if_replaced() { + std::process::exit(0); + } + let mut s = shared.lock(); + if s.stale_panel.is_none() { + let path = crate::service::started_from() + .map(|e| e.path().display().to_string()) + .unwrap_or_default(); + tracing::warn!( + "this window is Latch {} from {path}, older than the service ({}); not relaunching it", + env!("CARGO_PKG_VERSION"), + st.version + ); + s.stale_panel = Some(path); + changed = true; + } } _ => {} } @@ -1094,18 +1271,35 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { } Err(e) => { failures += 1; + if let Some(why) = crate::service::take_quick_exit() { + quick_exit = Some(why); + } let mut s = shared.lock(); - let stopped_by_user = s.service_error.as_deref() == Some(STOPPED); + // An older copy of the window must not become the + // service: it would point the login item at itself. + let stopped_by_user = s.service_error.as_deref() == Some(STOPPED) + || s.viewer_only + || s.stale_panel.is_some(); changed |= s.status.is_some(); s.status = None; if !stopped_by_user { - let e = format!("{e}"); + let e = quick_exit.clone().unwrap_or_else(|| format!("{e}")); changed |= s.service_error.as_deref() != Some(e.as_str()); s.service_error = Some(e); } drop(s); - if failures % 4 == 2 && !stopped_by_user { - crate::service::ensure_service_running(); + if !stopped_by_user && respawn.due(failures) { + // Another window, or a hand edit, may have turned + // sharing off since this one opened: the service + // would only exit again. + if crate::service::starts_with_window(&HostConfig::load()) { + crate::service::ensure_service_running(); + } else { + let mut s = shared.lock(); + s.viewer_only = true; + s.service_error = Some(STOPPED.into()); + changed = true; + } } } } @@ -1140,6 +1334,22 @@ mod tests { assert_eq!(decode_log(&utf16), "hi\n"); } + #[test] + fn a_service_that_will_not_start_is_retried_less_and_less_often() { + let mut r = Respawn::new(); + let fired: Vec = (1..=400).filter(|&f| r.due(f)).collect(); + assert_eq!(&fired[..6], [2, 6, 14, 30, 62, 122]); + // Then once a minute, never less often: a PC must come back. + assert!( + fired.windows(2).skip(5).all(|w| w[1] - w[0] == 60), + "{fired:?}" + ); + // Once it answers, the next outage starts quickly again. + let mut r = Respawn::new(); + assert!(!r.due(1)); + assert!(r.due(2)); + } + #[test] fn a_newer_service_does_not_get_killed_by_the_old_panel() { assert_eq!(update_action("3.1.0", "3.1.0"), UpdateAction::None); @@ -1211,6 +1421,7 @@ mod snapshots { } fn ready_status(os: &str) -> Status { + let windows = os == "windows"; Status { app: latch_core::APP_ID.into(), version: env!("CARGO_PKG_VERSION").into(), @@ -1222,13 +1433,22 @@ mod snapshots { os: os.into(), tailscale_ip: Some("100.64.0.10".into()), tailscale_login: Some("user@example.com".into()), - lan_ip: Some("192.168.1.10".into()), - mac: Some("02:00:00:00:00:01".into()), - wake_ready: Some(true), - wake_adapter: "Ethernet".into(), - wake_adapter_description: "Example NIC".into(), - wake_packet_age_secs: Some(42), - fast_startup: Some(false), + // Only a Windows PC reports its wake adapter. + lan_ip: windows.then(|| "192.168.1.10".into()), + mac: windows.then(|| "02:00:00:00:00:01".into()), + wake_ready: windows.then_some(true), + wake_adapter: if windows { + "Ethernet".into() + } else { + String::new() + }, + wake_adapter_description: if windows { + "Example NIC".into() + } else { + String::new() + }, + wake_packet_age_secs: windows.then_some(42), + fast_startup: windows.then_some(false), streamer: Streamer { kind: latch_core::STREAMER_KIND.into(), installed: true, @@ -1240,6 +1460,7 @@ mod snapshots { "videotoolbox".into() }, audio_problem: String::new(), + ..Default::default() }, power_allowed: true, nat: Some(latch_core::api::NatReport { @@ -1363,6 +1584,15 @@ mod snapshots { ..Default::default() }, ), + ( + "mac-viewer", + Os::Mac, + Shared { + service_error: Some(STOPPED.into()), + viewer_only: true, + ..Default::default() + }, + ), ] } @@ -1475,9 +1705,30 @@ mod snapshots { ); assert!(h.query_by_label("Wake-on-LAN").is_none()); assert!(h.query_by_label("Game controllers").is_none()); + assert!( + h.query_by_label("LAN address").is_none(), + "a Mac has none to show" + ); + assert!(h.query_by_label_contains("Install updates").is_none()); + assert!( + h.query_all_by_label("Keep this Mac awake while plugged in") + .next() + .is_some(), + "a shared Mac holds a power assertion while plugged in" + ); + let h = build( + Shared { + status: Some(ready_status("linux")), + ..Default::default() + }, + Os::Linux, + tall, + 1.0, + false, + ); assert!( h.query_by_label_contains("Keep this").is_none(), - "keeping awake is Windows-only; the switch would do nothing on a Mac" + "a Linux server does not idle-sleep; the switch would do nothing" ); } diff --git a/crates/host/src/audio.rs b/crates/host/src/audio.rs index e7585f9..79d383f 100644 --- a/crates/host/src/audio.rs +++ b/crates/host/src/audio.rs @@ -3,15 +3,21 @@ //! they are the default, which leaves a PC with no other active device //! with no default endpoint and no sound on the stream. +use std::time::{Duration, Instant}; + +/// The logon task that runs the take-over in the signed-in user's session. +/// Setup run from session 0 (over SSH) starts the engine through it. +pub const ENGINE_TASK: &str = "LatchEngineUser"; + #[cfg(windows)] mod win { + use super::ENGINE_TASK as TASK; use anyhow::{bail, Context, Result}; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; - const TASK: &str = "LatchEngineUser"; const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// The helper scripts, written into the data directory when they are @@ -82,7 +88,9 @@ mod win { .unwrap_or(false) } - fn session_id() -> u32 { + /// The Windows session this process runs in; 0 is the one without a + /// desktop, where services and SSH shells run. + pub fn session_id() -> u32 { let mut id = 0u32; let _ = unsafe { windows::Win32::System::RemoteDesktop::ProcessIdToSessionId(std::process::id(), &mut id) @@ -93,7 +101,7 @@ mod win { /// Start the engine as the logged-on user and restore a default playback /// device. Safe to call often: a running user-session engine is left /// alone aside from putting the default back. - pub fn take_over_engine() -> Result<()> { + pub fn run_take_over() -> Result<()> { let script = install_helpers()?; if session_id() == 0 { let status = Command::new("schtasks") @@ -120,9 +128,120 @@ mod win { } #[cfg(windows)] -pub use win::{install_helpers, take_over_engine}; +pub use win::{install_helpers, session_id}; -#[cfg(not(windows))] +/// Start the engine as the logged-on user (the engine is not running) and +/// put a default playback device back. The engine starts afresh, so any +/// backoff [`retry_take_over`] was keeping starts afresh too. pub fn take_over_engine() -> anyhow::Result<()> { + *AUDIO_RETRY.lock() = None; + #[cfg(windows)] + { + win::run_take_over() + } + #[cfg(not(windows))] Ok(()) } + +/// The take-over again for a running engine whose log says it has no sound, +/// at most as often as [`retry_due`] allows; `problem` is that complaint, +/// and an empty one (the sound works) forgets the backoff. Each run starts +/// PowerShell and can do nothing on a PC with no playback device at all, +/// where the complaint never clears. +pub fn retry_take_over(problem: &str) { + let mut state = AUDIO_RETRY.lock(); + if problem.is_empty() { + *state = None; + return; + } + let now = Instant::now(); + let Some(wait) = retry_due(state.as_ref(), problem, now) else { + return; + }; + *state = Some(AudioRetry { + problem: problem.to_string(), + last: now, + wait, + }); + drop(state); + #[cfg(windows)] + { + if let Err(e) = win::run_take_over() { + tracing::info!("audio take-over: {e:#}"); + } + } +} + +/// The first wait after a take-over that did not bring the sound back, and +/// the longest; each run in between doubles it. +const RETRY_FIRST: Duration = Duration::from_secs(30); +const RETRY_CAP: Duration = Duration::from_secs(10 * 60); + +/// The last take-over run for an audio problem: what it was for, when, and +/// how long to wait before the next. +struct AudioRetry { + problem: String, + last: Instant, + wait: Duration, +} + +static AUDIO_RETRY: parking_lot::Mutex> = parking_lot::Mutex::new(None); + +/// Whether the take-over should run now for `problem`, and if so the wait +/// before the one after: at once for a problem it has not run for, then +/// after 30 s, 1, 2, 4 and 8 minutes, then every 10. +fn retry_due(prev: Option<&AudioRetry>, problem: &str, now: Instant) -> Option { + match prev { + Some(p) if p.problem == problem => { + (now.duration_since(p.last) >= p.wait).then(|| (p.wait * 2).min(RETRY_CAP)) + } + _ => Some(RETRY_FIRST), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn after(prev: &AudioRetry, secs: u64) -> Instant { + prev.last + Duration::from_secs(secs) + } + + #[test] + fn a_new_audio_problem_runs_the_take_over_at_once() { + let now = Instant::now(); + assert_eq!(retry_due(None, "no sink", now), Some(RETRY_FIRST)); + let prev = AudioRetry { + problem: "no sink".into(), + last: now, + wait: Duration::from_secs(480), + }; + assert_eq!( + retry_due(Some(&prev), "no endpoint", after(&prev, 1)), + Some(RETRY_FIRST), + "a different complaint is new" + ); + } + + #[test] + fn the_same_problem_waits_out_a_backoff_that_doubles_up_to_ten_minutes() { + let mut prev = AudioRetry { + problem: "no sink".into(), + last: Instant::now(), + wait: RETRY_FIRST, + }; + assert_eq!(retry_due(Some(&prev), "no sink", after(&prev, 29)), None); + let mut waits = vec![]; + for _ in 0..8 { + let now = after(&prev, prev.wait.as_secs()); + let wait = retry_due(Some(&prev), "no sink", now).expect("due"); + waits.push(wait.as_secs()); + prev = AudioRetry { + problem: "no sink".into(), + last: now, + wait, + }; + } + assert_eq!(waits, [60, 120, 240, 480, 600, 600, 600, 600]); + } +} diff --git a/crates/host/src/clipboard.rs b/crates/host/src/clipboard.rs index 5caeb79..29ba97e 100644 --- a/crates/host/src/clipboard.rs +++ b/crates/host/src/clipboard.rs @@ -115,25 +115,65 @@ unsafe fn open() -> Result<()> { /// belongs to the `arboard::Clipboard` that set it: when the last one is /// dropped the text goes to a clipboard manager if one takes it within /// 100 ms, and is otherwise gone before anything on the desktop can paste -/// it. A handle whose connection failed is replaced on the next call. +/// it. +/// +/// A failed handle is dropped, but on X11 that recovers nothing: arboard +/// keeps one X connection for the whole process, and once it dies (the X +/// server restarted under a host that kept running) every new handle is the +/// same dead one. Only a new process gets a working clipboard, so after +/// [`DEAD_AFTER`] failures in a row while the display itself answers, the +/// service exits for systemd (`Restart=on-failure`) to start it again. #[cfg(not(windows))] fn with_clipboard( f: impl FnOnce(&mut arboard::Clipboard) -> Result, ) -> Result { - static CLIPBOARD: parking_lot::Mutex> = - parking_lot::Mutex::new(None); + struct Slot { + clipboard: Option, + failures: u32, + } + static CLIPBOARD: parking_lot::Mutex = parking_lot::Mutex::new(Slot { + clipboard: None, + failures: 0, + }); let mut slot = CLIPBOARD.lock(); - let clipboard = match slot.as_mut() { + let clipboard = match slot.clipboard.as_mut() { Some(c) => c, - None => slot.insert(arboard::Clipboard::new()?), + None => slot.clipboard.insert(arboard::Clipboard::new()?), }; let result = f(clipboard); - if result.is_err() { - *slot = None; + match &result { + Ok(_) => slot.failures = 0, + Err(e) => { + slot.clipboard = None; + slot.failures = if matches!(e, arboard::Error::Unknown { .. }) { + slot.failures + 1 + } else { + 0 + }; + #[cfg(target_os = "linux")] + if connection_is_dead(slot.failures, || x11rb::connect(None).is_ok()) { + tracing::error!("clipboard: the X connection is gone ({e}); restarting"); + std::process::exit(1); + } + } } Ok(result?) } +/// Unknown errors in a row before the X connection counts as dead: one can +/// be a passing hiccup. +#[cfg(any(target_os = "linux", test))] +const DEAD_AFTER: u32 = 2; + +/// Whether arboard's X connection is dead for good: it failed +/// [`DEAD_AFTER`] times in a row while a fresh connection to the display +/// works. When the display itself does not answer (X is down, or there is +/// no X at all), restarting would not help and is not done. +#[cfg(any(target_os = "linux", test))] +fn connection_is_dead(failures: u32, display_answers: impl FnOnce() -> bool) -> bool { + failures >= DEAD_AFTER && display_answers() +} + #[cfg(not(windows))] pub fn read() -> Result { let text = with_clipboard(|c| match c.get_text() { @@ -141,12 +181,29 @@ pub fn read() -> Result { Err(arboard::Error::ContentNotAvailable) => Ok(String::new()), other => other, })?; - Ok(Clipboard::fit(&text, text_seq(&text))) + Ok(Clipboard { + // write() below puts pasted text in the selection too. + selection: cfg!(target_os = "linux"), + ..Clipboard::fit(&text, text_seq(&text)) + }) } +/// On Linux the text also becomes the X selection (PRIMARY): a Mac sends +/// ⌘V to a Linux PC as Shift+Insert, which pastes the selection rather +/// than the clipboard in xterm and the VTE terminals (GNOME's, XFCE's). #[cfg(not(windows))] pub fn write(text: &str) -> Result<()> { - with_clipboard(|c| c.set_text(text.to_string())) + with_clipboard(|c| { + c.set_text(text.to_string())?; + #[cfg(target_os = "linux")] + { + use arboard::{LinuxClipboardKind, SetExtLinux}; + c.set() + .clipboard(LinuxClipboardKind::Primary) + .text(text.to_string())?; + } + Ok(()) + }) } /// macOS and X11 have no sequence number a program can read, so the text @@ -174,6 +231,26 @@ mod tests { assert_eq!(to_windows(""), ""); } + #[test] + fn only_repeated_failures_with_a_working_display_restart_the_service() { + assert!(!connection_is_dead(0, || true)); + assert!(!connection_is_dead(DEAD_AFTER - 1, || true)); + assert!(connection_is_dead(DEAD_AFTER, || true)); + assert!( + !connection_is_dead(DEAD_AFTER + 5, || false), + "no display: a restart would loop" + ); + let mut asked = false; + connection_is_dead(1, || { + asked = true; + true + }); + assert!( + !asked, + "the display is only probed once the count is reached" + ); + } + #[test] fn a_new_copy_gets_a_new_sequence_number() { assert_eq!(text_seq("hello"), text_seq("hello")); diff --git a/crates/host/src/config.rs b/crates/host/src/config.rs index 897b5c3..f240731 100644 --- a/crates/host/src/config.rs +++ b/crates/host/src/config.rs @@ -17,6 +17,10 @@ pub struct HostConfig { /// on another network cannot wake it. On by default. Explicit Sleep /// from the Mac or the Start menu still works. pub stay_awake: bool, + /// Take a newer `latch-host.exe` that a machine on the owner's account + /// sends (`/v1/update`), which is how a Mac keeps its PCs current. Off, + /// the PC refuses every push and is updated only at the PC itself. + pub accept_updates: bool, /// Sunshine web-UI login the host uses to accept pairing PINs. Written /// by the setup step, which sets the same values on Sunshine. pub sunshine_user: String, @@ -29,6 +33,7 @@ impl Default for HostConfig { power_allowed: true, start_with_windows: true, stay_awake: true, + accept_updates: true, sunshine_user: String::new(), sunshine_pass: String::new(), } @@ -39,6 +44,18 @@ impl HostConfig { pub fn load() -> Self { latch_core::config::load(FILE) } + + /// Read host.toml for a service that already runs on settings: an error + /// rather than defaults for a file that cannot be read, does not parse, + /// or is empty (an editor saving in place truncates it first), and the + /// file left alone. Defaults mean remote power on and no engine login. + pub fn try_load() -> anyhow::Result { + let path = latch_core::config::data_dir()?.join(FILE); + if std::fs::metadata(&path).is_ok_and(|m| m.len() == 0) { + anyhow::bail!("{} is empty", path.display()); + } + latch_core::config::try_load(FILE) + } /// Overlay this copy's settings on the file rather than replacing it: /// `--setup` or a container's bootstrap may have written the engine /// login since this copy was loaded, and an empty login here never @@ -48,6 +65,7 @@ impl HostConfig { disk.power_allowed = self.power_allowed; disk.start_with_windows = self.start_with_windows; disk.stay_awake = self.stay_awake; + disk.accept_updates = self.accept_updates; if self.has_creds() { disk.sunshine_user = self.sunshine_user.clone(); disk.sunshine_pass = self.sunshine_pass.clone(); @@ -89,9 +107,12 @@ mod tests { assert!(c.power_allowed); assert!(c.start_with_windows); assert!(c.stay_awake); + assert!(c.accept_updates, "pushed updates stay on unless turned off"); assert!(!c.has_creds()); // An old host.toml without the field gets the default too. let c: HostConfig = toml::from_str("power_allowed = false").unwrap(); - assert!(!c.power_allowed && c.start_with_windows && c.stay_awake); + assert!(!c.power_allowed && c.start_with_windows && c.stay_awake && c.accept_updates); + let c: HostConfig = toml::from_str("accept_updates = false").unwrap(); + assert!(!c.accept_updates); } } diff --git a/crates/host/src/cursor.rs b/crates/host/src/cursor.rs new file mode 100644 index 0000000..3d3fea2 --- /dev/null +++ b/crates/host/src/cursor.rs @@ -0,0 +1,862 @@ +//! Keep a visible Windows cursor in the streamed picture. A game's cursor +//! can have a transparent hotspot with every visible pixel to its right or +//! below it: at the bottom or right edge the hotspot is still on screen, +//! but the whole visible cursor is off it. The guard stops such a cursor +//! where its nearest visible pixel is still in the picture. A cursor whose +//! hotspot is on its visible part, like every standard pointer and most +//! game cursors, is never held back from an edge, so edge scrolling and +//! controls in the last pixels keep working. + +/// A cursor's hotspot and the box around the pixels it draws (left, top, +/// right, bottom, all inclusive), in the cursor's own pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Shape { + hot: (i32, i32), + visible: [i32; 4], +} + +/// The confinement that keeps `shape`'s nearest visible pixel on `desktop`, +/// within the one already in force (`original`). `None` when the cursor +/// needs none: its hotspot is in line with visible pixels both ways, so +/// some of it shows wherever the hotspot can go. +fn cursor_clip(desktop: [i32; 4], original: [i32; 4], shape: Shape) -> Option<[i32; 4]> { + let (hot_x, hot_y) = shape.hot; + let [x0, y0, x1, y1] = shape.visible; + // How far the hotspot is from the visible pixels nearest to it, per side. + let left = (hot_x - x1).max(0); + let top = (hot_y - y1).max(0); + let right = (x0 - hot_x).max(0); + let bottom = (y0 - hot_y).max(0); + if left == 0 && top == 0 && right == 0 && bottom == 0 { + return None; + } + // ClipCursor's far edges are exclusive. At the last allowed hotspot, + // the nearest visible pixel is on the desktop's last pixel. + let rect = [ + original[0].max(desktop[0] + left), + original[1].max(desktop[1] + top), + original[2].min(desktop[2] - right), + original[3].min(desktop[3] - bottom), + ]; + (rect[0] < rect[2] && rect[1] < rect[3]).then_some(rect) +} + +/// One of a cursor's bitmaps as `GetBitmapBits` copies it: `height` rows +/// of `stride` bytes, top row first. +struct Bitmap<'a> { + width: i32, + height: i32, + stride: usize, + bits_per_pixel: u16, + data: &'a [u8], +} + +impl Bitmap<'_> { + /// Whether the bitmap holds `rows` rows of `width` pixels. + fn holds(&self, width: i32, rows: i32) -> bool { + let (Ok(width), Ok(rows)) = (usize::try_from(width), usize::try_from(rows)) else { + return false; + }; + self.stride * 8 >= width * usize::from(self.bits_per_pixel) + && self.data.len() >= self.stride * rows + } + + /// A monochrome pixel: set or clear. + fn bit(&self, x: i32, y: i32) -> bool { + let at = y as usize * self.stride + x as usize / 8; + self.data + .get(at) + .is_some_and(|b| b & (0x80 >> (x % 8)) != 0) + } + + /// A 32-bit pixel: blue, green, red, alpha. + fn pixel(&self, x: i32, y: i32) -> [u8; 4] { + let at = y as usize * self.stride + x as usize * 4; + self.data + .get(at..at + 4) + .map_or([0; 4], |p| [p[0], p[1], p[2], p[3]]) + } +} + +/// The box around the pixels a cursor draws: left, top, right, bottom, all +/// inclusive. A colour cursor with an alpha channel draws where alpha is +/// not 0; one without draws where its AND mask is clear or its colour +/// inverts the screen. A monochrome cursor's mask is its AND mask above its +/// XOR mask; it draws where AND is clear or XOR is set. `None` when no +/// pixel shows, or for a format this does not read. +fn visible_box(color: Option<&Bitmap>, mask: &Bitmap) -> Option<[i32; 4]> { + let (width, height) = match color { + Some(c) if c.bits_per_pixel == 32 => (c.width, c.height), + Some(_) => return None, + None => (mask.width, mask.height / 2), + }; + let readable = mask.bits_per_pixel == 1 + && mask.holds(width, if color.is_some() { height } else { 2 * height }) + && color.is_none_or(|c| c.holds(width, height)); + if !readable || width <= 0 || height <= 0 { + return None; + } + let alpha = + color.is_some_and(|c| (0..height).any(|y| (0..width).any(|x| c.pixel(x, y)[3] != 0))); + let shows = |x: i32, y: i32| match color { + Some(c) if alpha => c.pixel(x, y)[3] != 0, + Some(c) => !mask.bit(x, y) || c.pixel(x, y)[..3] != [0, 0, 0], + None => !mask.bit(x, y) || mask.bit(x, y + height), + }; + let mut found: Option<[i32; 4]> = None; + for y in 0..height { + for x in (0..width).filter(|&x| shows(x, y)) { + let b = found.get_or_insert([x, y, x, y]); + *b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + } + found +} + +/// Whether the engine's log says a viewer is connected: its last word on +/// one is CLIENT CONNECTED, not CLIENT DISCONNECTED or a ping timeout +/// (which ends a session without the former). The engine keeps the +/// Desktop app running after a viewer leaves, so a running app alone does +/// not say a stream is on. `None` when the log says neither. +fn viewer_connected(log: &str) -> Option { + crate::streamer::viewer_connected_in(log) +} + +/// What the guard reads and sets on Windows; a fake in the tests. +trait Desktop { + /// The virtual screen: left, top, right, bottom, far edges exclusive. + fn bounds(&self) -> [i32; 4]; + /// The confinement in force: the virtual screen when there is none. + fn clip(&self) -> Option<[i32; 4]>; + /// Confine the cursor to a rectangle, or free it with `None`. + fn set_clip(&mut self, rect: Option<[i32; 4]>) -> bool; + /// The custom cursor showing on a single display, if any. + fn custom_cursor(&mut self) -> Option; +} + +/// The guard's record of the cursor's confinement, so it only ever lifts +/// its own. +#[derive(Debug, Default)] +struct Clip { + /// The confinement someone else set (a game, usually); `None` for none. + original: Option<[i32; 4]>, + /// The rectangle the guard set, while it is in force. + applied: Option<[i32; 4]>, +} + +impl Clip { + /// One look at the cursor; `active` while a viewer is connected. + fn step(&mut self, desktop: &mut impl Desktop, active: bool) { + let Some(current) = desktop.clip() else { + return; + }; + let screen = desktop.bounds(); + let shape = if active { + desktop.custom_cursor() + } else { + None + }; + self.observe( + current, + screen, + shape.and_then(|s| cursor_clip(screen, screen, s)), + ); + match shape.and_then(|s| cursor_clip(screen, self.original.unwrap_or(screen), s)) { + Some(bounds) if bounds == current => {} + Some(bounds) => { + if desktop.set_clip(Some(bounds)) { + self.applied = Some(bounds); + } + } + None => self.release(desktop), + } + } + + /// Take note of the confinement in force. `ours` is what the guard + /// would set for this cursor on a free desktop. + fn observe(&mut self, current: [i32; 4], screen: [i32; 4], ours: Option<[i32; 4]>) { + if self.applied == Some(current) { + return; + } + if current != screen && ours == Some(current) { + // A guard that was ended abruptly left its clip behind. It is + // this one's to lift, not a game's to respect. + self.original = None; + self.applied = Some(current); + } else { + // A game changed its own confinement, or Windows reset it after + // a display change. Respect the new bounds. + self.original = (current != screen).then_some(current); + self.applied = None; + } + } + + /// Put back what the guard's clip replaced, unless someone has replaced + /// it since. No confinement before means none now, whatever size the + /// desktop has become. + fn release(&mut self, desktop: &mut impl Desktop) { + if let Some(applied) = self.applied.take() { + if desktop.clip() == Some(applied) { + desktop.set_clip(self.original); + } + } + } +} + +#[cfg(windows)] +mod win { + use super::{visible_box, Bitmap, Clip, Desktop, Shape}; + use anyhow::{bail, Context, Result}; + use std::mem::size_of; + use std::os::windows::process::CommandExt; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + use windows::Win32::Foundation::{CloseHandle, RECT}; + use windows::Win32::Graphics::Gdi::{DeleteObject, GetBitmapBits, GetObjectW, BITMAP, HBITMAP}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + use windows::Win32::UI::HiDpi::{ + SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + ClipCursor, GetClipCursor, GetCursorInfo, GetIconInfo, GetSystemMetrics, LoadCursorW, + CURSORINFO, CURSOR_SHOWING, HCURSOR, HICON, ICONINFO, IDC_APPSTARTING, IDC_ARROW, + IDC_CROSS, IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, + IDC_SIZENWSE, IDC_SIZEWE, IDC_UPARROW, IDC_WAIT, SM_CMONITORS, SM_CXVIRTUALSCREEN, + SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + }; + + const TASK: &str = "LatchCursorUser"; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + fn session_id() -> u32 { + let mut id = 0u32; + let _ = unsafe { + windows::Win32::System::RemoteDesktop::ProcessIdToSessionId(std::process::id(), &mut id) + }; + id + } + + /// Keep the guard running beside this node; the service calls this + /// every 30 seconds. The cursor belongs to the logged-on desktop. A node + /// started there (from the Run key, as usual) runs the guard as its own + /// windowless child. One in session 0 (started over SSH) hands it to a + /// task that runs in the desktop session, without elevation. + pub fn start() -> Result<()> { + static CHILD: Mutex> = Mutex::new(None); + if session_id() == 0 { + return start_task(); + } + let mut child = CHILD.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(running) = child.as_mut() { + if running.try_wait()?.is_none() { + return Ok(()); + } + } + let spawned = Command::new(std::env::current_exe()?) + .arg("--cursor-guard") + .arg(std::process::id().to_string()) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start cursor guard")?; + *child = Some(spawned); + Ok(()) + } + + /// The guard from session 0: a task registered once per node, with + /// this node's process ID. It has no trigger of its own, so it never + /// starts for a node that is gone; the node runs it, and running a task + /// that is already running leaves it alone. + fn start_task() -> Result<()> { + static REGISTERED: AtomicBool = AtomicBool::new(false); + let run = |program: &str, args: &[&str]| { + Command::new(program) + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + }; + // One attempt per node: a failure here would fail the same way + // every 30 seconds. + if !REGISTERED.swap(true, Ordering::Relaxed) { + let exe = std::env::current_exe()?; + let tr = format!( + "\"{}\" --cursor-guard {}", + exe.display(), + std::process::id() + ); + let status = run( + "schtasks", + &[ + "/Create", "/TN", TASK, "/TR", &tr, "/SC", "ONCE", "/ST", "00:00", "/IT", "/F", + "/RL", "LIMITED", + ], + ) + .context("register cursor task")?; + if !status.success() { + bail!("could not register {TASK}"); + } + // Scheduled tasks otherwise stop after 72 hours or on battery. + // This helper must last as long as the node does. + let status = run("powershell.exe", &[ + "-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference='Stop'; $settings=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero); Set-ScheduledTask -TaskName LatchCursorUser -Settings $settings | Out-Null", + ]).context("configure cursor task")?; + if !status.success() { + bail!("could not configure {TASK}"); + } + } + // A previous build's helper exits when its node exits. Retrying also + // starts this build after that handover or after a Windows logon. + if !run("schtasks", &["/Run", "/TN", TASK])?.success() { + bail!("could not run {TASK}"); + } + Ok(()) + } + + fn coordinates(r: RECT) -> [i32; 4] { + [r.left, r.top, r.right, r.bottom] + } + + fn rectangle(r: [i32; 4]) -> RECT { + RECT { + left: r[0], + top: r[1], + right: r[2], + bottom: r[3], + } + } + + /// A bitmap's description and a copy of its pixels. + fn bits(bitmap: HBITMAP) -> Option<(BITMAP, Vec)> { + let mut info = BITMAP::default(); + let read = unsafe { + GetObjectW( + bitmap, + size_of::() as i32, + Some((&mut info as *mut BITMAP).cast()), + ) + }; + if read == 0 { + return None; + } + let size = i32::try_from(i64::from(info.bmWidthBytes) * i64::from(info.bmHeight)).ok()?; + let mut data = vec![0u8; usize::try_from(size).ok()?]; + let copied = unsafe { GetBitmapBits(bitmap, size, data.as_mut_ptr().cast()) }; + (copied > 0).then_some((info, data)) + } + + fn bitmap<'a>(info: &BITMAP, data: &'a [u8]) -> Bitmap<'a> { + Bitmap { + width: info.bmWidth, + height: info.bmHeight, + stride: usize::try_from(info.bmWidthBytes).unwrap_or(0), + bits_per_pixel: info.bmBitsPixel, + data, + } + } + + fn shape(cursor: HICON) -> Option { + let mut icon = ICONINFO::default(); + unsafe { GetIconInfo(cursor, &mut icon).ok()? }; + let has_color = !icon.hbmColor.is_invalid(); + let color = if has_color { bits(icon.hbmColor) } else { None }; + let mask = bits(icon.hbmMask); + unsafe { + if has_color { + let _ = DeleteObject(icon.hbmColor); + } + if !icon.hbmMask.is_invalid() { + let _ = DeleteObject(icon.hbmMask); + } + } + let (mask_info, mask_data) = mask?; + let visible = match (has_color, &color) { + (true, Some((info, data))) => { + visible_box(Some(&bitmap(info, data)), &bitmap(&mask_info, &mask_data))? + } + (true, None) => return None, + (false, _) => visible_box(None, &bitmap(&mask_info, &mask_data))?, + }; + Some(Shape { + hot: (icon.xHotspot as i32, icon.yHotspot as i32), + visible, + }) + } + + /// The desktop as the guard sees it. + #[derive(Default)] + struct Screen { + /// Windows' own pointers, which must still reach scrollbars and + /// controls at the last pixel. + standard: Vec, + /// The last cursor looked at, and its shape. + cached: Option<(HCURSOR, Option)>, + } + + impl Screen { + fn refresh(&mut self) { + // DPI changes can replace a cursor's bitmap without changing + // its handle. Refresh the shape along with the display state. + self.cached = None; + self.standard = [ + IDC_ARROW, + IDC_IBEAM, + IDC_WAIT, + IDC_CROSS, + IDC_UPARROW, + IDC_SIZENWSE, + IDC_SIZENESW, + IDC_SIZEWE, + IDC_SIZENS, + IDC_SIZEALL, + IDC_NO, + IDC_HAND, + IDC_APPSTARTING, + IDC_HELP, + ] + .into_iter() + .filter_map(|id| unsafe { LoadCursorW(None, id).ok() }) + .collect(); + } + } + + impl Desktop for Screen { + fn bounds(&self) -> [i32; 4] { + unsafe { + let (x, y) = ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + ); + [ + x, + y, + x + GetSystemMetrics(SM_CXVIRTUALSCREEN), + y + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ] + } + } + + fn clip(&self) -> Option<[i32; 4]> { + let mut current = RECT::default(); + unsafe { GetClipCursor(&mut current) }.ok()?; + Some(coordinates(current)) + } + + fn set_clip(&mut self, rect: Option<[i32; 4]>) -> bool { + let rect = rect.map(rectangle); + unsafe { ClipCursor(rect.as_ref().map(|r| r as *const RECT)) }.is_ok() + } + + fn custom_cursor(&mut self) -> Option { + let mut cursor = CURSORINFO { + cbSize: size_of::() as u32, + ..Default::default() + }; + unsafe { + if GetSystemMetrics(SM_CMONITORS) != 1 || GetCursorInfo(&mut cursor).is_err() { + return None; + } + } + if cursor.flags != CURSOR_SHOWING || self.standard.contains(&cursor.hCursor) { + return None; + } + if self.cached.as_ref().map(|(handle, _)| *handle) != Some(cursor.hCursor) { + let found = shape(HICON(cursor.hCursor.0)); + self.cached = Some((cursor.hCursor, found)); + } + self.cached.as_ref().and_then(|(_, found)| *found) + } + } + + /// Whether a viewer is watching: the engine runs an app and its log + /// says a viewer is connected. A log that cannot be read leaves the + /// running app to decide. + fn streaming() -> bool { + let running = latch_core::http::request( + ("127.0.0.1", latch_core::SUNSHINE_PORT), + "GET", + "/serverinfo", + None, + Duration::from_millis(200), + ) + .ok() + .filter(|r| r.status == 200) + .and_then(|r| crate::streamer::session_in_serverinfo(&r.body)) + .unwrap_or(false); + running + && crate::streamer::find() + .and_then(|install| crate::streamer::log_text(&install)) + .and_then(|log| super::viewer_connected(&log)) + .unwrap_or(true) + } + + /// This program's file name, as process snapshots list it. + fn own_name() -> Option { + let exe = std::env::current_exe().ok()?; + Some(exe.file_name()?.to_string_lossy().to_lowercase()) + } + + fn parent_running(pid: u32, name: Option<&str>) -> Result { + // A limited desktop task cannot open an elevated service process, + // even just to wait for its exit. A process snapshot needs no such + // access and lets the guard release its clip when that node exits. + // The name check keeps a reused process ID from passing for the node. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?; + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut found = false; + unsafe { + let mut next = Process32FirstW(snapshot, &mut entry); + while next.is_ok() { + if entry.th32ProcessID == pid { + let len = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let exe = String::from_utf16_lossy(&entry.szExeFile[..len]).to_lowercase(); + found = name.is_none_or(|name| exe == name); + break; + } + next = Process32NextW(snapshot, &mut entry); + } + let _ = CloseHandle(snapshot); + } + Ok(found) + } + + pub fn run(parent_pid: u32) -> Result<()> { + let dpi = + unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }; + if dpi.is_invalid() { + bail!("could not use physical cursor coordinates"); + } + tracing::info!("cursor guard for node {parent_pid}"); + let name = own_name(); + let mut screen = Screen::default(); + let mut clip = Clip::default(); + let mut active = false; + let mut checked: Option = None; + loop { + if checked.is_none_or(|at| at.elapsed() >= Duration::from_secs(1)) { + if !parent_running(parent_pid, name.as_deref()).unwrap_or(false) { + break; + } + active = streaming(); + checked = Some(Instant::now()); + screen.refresh(); + } + clip.step(&mut screen, active); + // With no viewer there is nothing to follow but the next check. + std::thread::sleep(Duration::from_millis(if active { 16 } else { 1000 })); + } + // Restore before leaving the desktop, including node updates. + clip.release(&mut screen); + unsafe { + SetThreadDpiAwarenessContext(dpi); + } + tracing::info!("cursor guard released after node {parent_pid} exited"); + Ok(()) + } +} + +#[cfg(windows)] +pub use win::{run, start}; + +#[cfg(test)] +mod tests { + use super::*; + + /// The 1080p desktop most tests use. + const HD: [i32; 4] = [0, 0, 1920, 1080]; + + fn shape(hot: (i32, i32), visible: [i32; 4]) -> Shape { + Shape { hot, visible } + } + + #[test] + fn a_cursor_drawn_at_its_hotspot_reaches_every_edge() { + // A 64x64 game cursor with its tip at the hotspot, and one with a + // centred hotspot (a crosshair): edge scrolling and the last + // pixels' buttons stay reachable. + assert_eq!(cursor_clip(HD, HD, shape((0, 0), [0, 0, 40, 60])), None); + assert_eq!(cursor_clip(HD, HD, shape((31, 31), [0, 0, 63, 63])), None); + // In line with visible pixels both ways, though not on one itself. + assert_eq!(cursor_clip(HD, HD, shape((10, 10), [0, 0, 20, 20])), None); + } + + #[test] + fn the_nearest_visible_pixel_stays_on_screen_at_every_edge() { + for desktop in [HD, [-2560, -1440, 0, 0], [17, 33, 3041, 1997]] { + // Transparent top-left hotspot, visible pixels from (12, 9). + let s = shape((0, 0), [12, 9, 40, 30]); + let r = cursor_clip(desktop, desktop, s).unwrap(); + assert_eq!((r[0], r[1]), (desktop[0], desktop[1])); + // At the last allowed hotspot, the first visible column and row + // are on the desktop's last pixel. + assert_eq!( + (r[2] - 1 + 12, r[3] - 1 + 9), + (desktop[2] - 1, desktop[3] - 1) + ); + // Visible pixels above and left of the hotspot hold it back + // from the top and left edges instead. + let s = shape((50, 40), [0, 0, 20, 30]); + let r = cursor_clip(desktop, desktop, s).unwrap(); + assert_eq!((r[0] - 50 + 20, r[1] - 40 + 30), (desktop[0], desktop[1])); + assert_eq!((r[2], r[3]), (desktop[2], desktop[3])); + } + } + + #[test] + fn game_bounds_are_preserved_and_impossible_bounds_are_dropped() { + let game = [400, 300, 401, 301]; + let offset = shape((0, 0), [8, 8, 31, 31]); + assert_eq!(cursor_clip(HD, game, offset), Some(game)); + assert_eq!( + cursor_clip( + [0, 0, 16, 16], + [0, 0, 16, 16], + shape((0, 0), [20, 0, 31, 31]) + ), + None + ); + } + + #[test] + fn the_visible_box_is_read_from_every_cursor_format() { + // Monochrome, 8x2 (stride 2): AND above XOR. Row 0 shows an inverted + // pixel at x 6 (AND set, XOR set); row 1 a black pixel at x 2. + let data = [0xFF, 0, 0xDF, 0, 0x02, 0, 0, 0]; + let mask = Bitmap { + width: 8, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &data, + }; + assert_eq!(visible_box(None, &mask), Some([2, 0, 6, 1])); + + // 32-bit with alpha: only pixels with alpha count. + let mut pixels = vec![0u8; 4 * 4 * 4]; + pixels[(4 + 1) * 4 + 3] = 255; // (1, 1) + pixels[(3 * 4 + 2) * 4 + 3] = 10; // (2, 3) + let color = Bitmap { + width: 4, + height: 4, + stride: 16, + bits_per_pixel: 32, + data: &pixels, + }; + let and = [0u8, 0, 0, 0, 0, 0, 0, 0]; + let mask = Bitmap { + width: 4, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &and, + }; + assert_eq!(visible_box(Some(&color), &mask), Some([1, 1, 2, 3])); + + // 32-bit without alpha: AND clear draws, AND set with a colour inverts. + let mut pixels = vec![0u8; 4 * 4 * 4]; + pixels[(2 * 4 + 3) * 4] = 0x80; // (3, 2): blue, over a set AND bit + let color = Bitmap { + width: 4, + height: 4, + stride: 16, + bits_per_pixel: 32, + data: &pixels, + }; + // Every AND bit set but (1, 0). + let and = [0xBF, 0, 0xFF, 0, 0xFF, 0, 0xFF, 0]; + let mask = Bitmap { + width: 4, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &and, + }; + assert_eq!(visible_box(Some(&color), &mask), Some([1, 0, 3, 2])); + + // Nothing drawn, a format it does not read, and a short copy. + let clear = [0xFFu8, 0, 0xFF, 0, 0, 0, 0, 0]; + let mask = Bitmap { + width: 8, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &clear, + }; + assert_eq!(visible_box(None, &mask), None); + let color = Bitmap { + width: 4, + height: 4, + stride: 12, + bits_per_pixel: 24, + data: &[0; 48], + }; + assert_eq!(visible_box(Some(&color), &mask), None); + let short = Bitmap { + width: 8, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &[0; 6], + }; + assert_eq!(visible_box(None, &short), None); + } + + #[test] + fn only_a_connected_viewer_counts_as_a_stream() { + let log = "Info: Executing [Desktop]\nInfo: CLIENT CONNECTED\nInfo: Detecting displays"; + assert_eq!(viewer_connected(log), Some(true)); + let left = format!("{log}\nInfo: CLIENT DISCONNECTED\nInfo: Setting default sink"); + assert_eq!(viewer_connected(&left), Some(false)); + // A viewer that vanished: the session ends on a ping timeout. + let lost = format!("{log}\nInfo: 100.111.100.57: Ping Timeout"); + assert_eq!(viewer_connected(&lost), Some(false)); + let back = format!("{left}\nInfo: CLIENT CONNECTED"); + assert_eq!(viewer_connected(&back), Some(true)); + assert_eq!(viewer_connected("Info: Ready"), None); + } + + /// Windows' side of the guard, as far as the tests need it. + struct Fake { + screen: [i32; 4], + clip: Option<[i32; 4]>, + cursor: Option, + } + + impl Fake { + fn new(cursor: Option) -> Fake { + Fake { + screen: HD, + clip: None, + cursor, + } + } + + /// What `GetClipCursor` would answer. + fn current(&self) -> [i32; 4] { + self.clip.unwrap_or(self.screen) + } + } + + impl Desktop for Fake { + fn bounds(&self) -> [i32; 4] { + self.screen + } + + fn clip(&self) -> Option<[i32; 4]> { + Some(self.current()) + } + + fn set_clip(&mut self, rect: Option<[i32; 4]>) -> bool { + self.clip = rect; + true + } + + fn custom_cursor(&mut self) -> Option { + self.cursor + } + } + + const OFFSET: Shape = Shape { + hot: (0, 0), + visible: [16, 16, 47, 47], + }; + + #[test] + fn the_guard_clips_only_while_a_viewer_watches_and_lifts_only_its_own_clip() { + let mut desktop = Fake::new(Some(OFFSET)); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064])); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064]), "steady"); + // The viewer leaves: the cursor is free again. + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, None); + // A standard pointer needs nothing either. + clip.step(&mut desktop, true); + desktop.cursor = None; + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, None); + } + + #[test] + fn a_games_own_confinement_is_kept_and_restored() { + let mut desktop = Fake::new(Some(OFFSET)); + let window = [100, 100, 1920, 1080]; + desktop.clip = Some(window); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([100, 100, 1904, 1064])); + // The game re-clips to a smaller window while the guard's clip is on. + let smaller = [200, 200, 800, 600]; + desktop.clip = Some(smaller); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some(smaller), "inside the safe area already"); + // The guard lets go: the game's bounds stay, and a guard clip ends + // with the game's bounds back, not with none. + desktop.clip = Some(window); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([100, 100, 1904, 1064])); + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, Some(window)); + } + + #[test] + fn a_clip_someone_else_set_is_never_lifted() { + let mut desktop = Fake::new(Some(OFFSET)); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + // A game takes over the confinement; then no viewer is left. + let game = [10, 10, 500, 500]; + desktop.clip = Some(game); + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, Some(game)); + } + + #[test] + fn a_free_cursor_is_freed_again_whatever_the_desktop_became() { + let mut desktop = Fake::new(Some(OFFSET)); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064])); + // The stream switches to 1440p, and Windows keeps the old clip. + desktop.screen = [0, 0, 2560, 1440]; + clip.step(&mut desktop, true); + assert_eq!( + desktop.clip, + Some([0, 0, 2544, 1424]), + "follows the new size" + ); + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, None, "not the 1080p desktop"); + } + + #[test] + fn a_clip_left_by_an_ended_guard_is_lifted() { + // The previous guard was killed with its clip on. + let mut desktop = Fake::new(Some(OFFSET)); + desktop.clip = Some([0, 0, 1904, 1064]); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064])); + desktop.cursor = None; + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, None); + } +} diff --git a/crates/host/src/display.rs b/crates/host/src/display.rs index 7729261..8ba8917 100644 --- a/crates/host/src/display.rs +++ b/crates/host/src/display.rs @@ -431,7 +431,9 @@ mod tests { /// Each section stands on its own: a class that is missing on this Windows /// build leaves one error behind instead of losing the whole report. -#[cfg(windows)] +// Compiled into tests everywhere, so the service names it looks for are +// checked against setup's on a Mac too. +#[cfg(any(windows, test))] const PROBE: &str = r#" $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) @@ -497,10 +499,13 @@ try { Select-Object ProcessId,SessionId,ExecutablePath) } catch { $errors['sunshine'] = $_.Exception.Message } +# The engine's service under every name it has had (Latch's, the one +# before 4.1, upstream's) and any other whose program is the engine's +# service wrapper. PathName shows which engine folder each one runs. $service = @() try { - $service = @(Get-CimInstance Win32_Service -Filter "Name like '%unshine%'" -ErrorAction Stop | - Select-Object Name,State,StartMode,ProcessId,StartName) + $service = @(Get-CimInstance Win32_Service -Filter "Name='LatchStream' OR Name='BroLinkStream' OR Name like '%unshine%' OR PathName like '%sunshinesvc.exe%'" -ErrorAction Stop | + Select-Object Name,State,StartMode,ProcessId,StartName,PathName) } catch { $errors['sunshine_service'] = $_.Exception.Message } # The desktop's own brightness, as sampled numbers rather than a picture: @@ -555,3 +560,18 @@ try { desktop=$desktop; errors=$errors} | ConvertTo-Json -Depth 6 -Compress "#; + +#[cfg(test)] +mod probe_tests { + #[test] + fn the_probe_finds_the_engine_service_under_every_name_latch_gave_it() { + for name in [crate::setup::SERVICE, crate::legacy::WINDOWS_SERVICE] { + assert!( + super::PROBE.contains(&format!("Name='{name}'")), + "{name} is not looked for" + ); + } + assert!(super::PROBE.contains("Name like '%unshine%'")); + assert!(super::PROBE.contains("PathName like '%sunshinesvc.exe%'")); + } +} diff --git a/crates/host/src/legacy.rs b/crates/host/src/legacy.rs index 8623453..39a30bc 100644 --- a/crates/host/src/legacy.rs +++ b/crates/host/src/legacy.rs @@ -41,18 +41,52 @@ pub const MAC_LAUNCH_AGENT: &str = "dev.brolink.node"; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub const LINUX_USER_UNIT: &str = "brolink.service"; +/// What a 4.0 setup left on a Windows PC, as names the retire step looks +/// for. Only [`retire_windows_ps`] uses the real ones; the Windows CI job +/// runs the same step on temporary folders and a dummy service. +pub struct Retiring<'a> { + pub engine_dir: &'a str, + pub service: &'a str, + pub task: &'a str, + pub rules: &'a [&'a str], +} + /// PowerShell for the elevated setup script, run before it looks for an /// engine: removes the old logon task and firewall rules, stops and /// deletes the old service, and moves the engine's folder, with the pairing /// state in its `config`, to `engine_dir`. Safe on a PC with none of it. pub fn retire_windows_ps(engine_dir: &str) -> String { - let rules = WINDOWS_RULES + retire_windows_ps_with( + &Retiring { + engine_dir: WINDOWS_ENGINE_DIR, + service: WINDOWS_SERVICE, + task: WINDOWS_ENGINE_TASK, + rules: &WINDOWS_RULES, + }, + engine_dir, + ) +} + +/// [`retire_windows_ps`] for any old layout. +/// +/// The move is retried: an engine restarted in the gap, an antivirus scan +/// or an Explorer window holds the folder for a moment. If it still fails, +/// the old engine stays where it is and `$retiredState` names its `config`, +/// which [`restore_retired_state_ps`] copies into the fresh engine setup +/// then installs, so paired devices carry over either way. Nothing here +/// throws: the old firewall rules are already gone, and setup has to go on +/// to open the new ones. +pub fn retire_windows_ps_with(old: &Retiring<'_>, engine_dir: &str) -> String { + let q = |s: &str| s.replace('\'', "''"); + let rules = old + .rules .iter() - .map(|r| format!("'{r}'")) + .map(|r| format!("'{}'", q(r))) .collect::>() .join(", "); format!( r#"Step "Retiring what an earlier version registered" +$retiredState = $null foreach ($t in '{task}') {{ if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {{ Stop-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue @@ -63,15 +97,50 @@ foreach ($r in {rules}) {{ netsh advfirewall firewall delete rule name="$r" | Ou $oldEngine = '{old}' if (Test-Path -LiteralPath (Join-Path $oldEngine 'sunshine.exe')) {{ Stop-Service -Name '{service}' -Force -ErrorAction SilentlyContinue - Get-CimInstance Win32_Process -Filter "Name='sunshine.exe'" | Where-Object {{ $_.ExecutablePath -like ($oldEngine + '\*') }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }} + Get-CimInstance Win32_Process -Filter "Name='sunshine.exe'" -ErrorAction SilentlyContinue | Where-Object {{ $_.ExecutablePath -like ($oldEngine + '\*') }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }} if (Get-Service -Name '{service}' -ErrorAction SilentlyContinue) {{ sc.exe delete '{service}' | Out-Null }} if (-not (Test-Path -LiteralPath (Join-Path '{engine}' 'sunshine.exe'))) {{ Step "Moving the streaming engine to {engine}" - try {{ - New-Item -ItemType Directory -Force -Path (Split-Path '{engine}') | Out-Null - Start-Sleep -Seconds 1 - Move-Item -LiteralPath $oldEngine -Destination '{engine}' -ErrorAction Stop - }} catch {{ Write-Output " could not move the engine: $_" }} + New-Item -ItemType Directory -Force -Path (Split-Path '{engine}') | Out-Null + # Moved onto a folder that exists, the old one would land INSIDE it + # (engine\engine). Such a folder holds no engine, only what an + # earlier run left part way: an empty one goes, anything else is + # kept aside. + if (Test-Path -LiteralPath '{engine}') {{ + if (Get-ChildItem -LiteralPath '{engine}' -Force -ErrorAction SilentlyContinue | Select-Object -First 1) {{ + $aside = '{engine}.partial-' + (Get-Date -Format yyyyMMddHHmmss) + Step " keeping what was in the way as $aside" + Move-Item -LiteralPath '{engine}' -Destination $aside -ErrorAction SilentlyContinue + }} else {{ + Remove-Item -LiteralPath '{engine}' -Force -ErrorAction SilentlyContinue + }} + }} + $moved = $false + if (-not (Test-Path -LiteralPath '{engine}')) {{ + foreach ($try in 1..5) {{ + # Again each time: one may have been started from there since. + Get-CimInstance Win32_Process -Filter "Name='sunshine.exe'" -ErrorAction SilentlyContinue | Where-Object {{ $_.ExecutablePath -like ($oldEngine + '\*') }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }} + Start-Sleep -Seconds ([Math]::Min($try, 2)) + # A rename, which either happens whole or not at all. Move-Item + # can leave the destination folder behind when the rename + # fails on a held file, and the next try then moves the old + # engine INTO it (engine\engine). + try {{ + [IO.Directory]::Move($oldEngine, '{engine}') + $moved = $true + break + }} catch {{ Write-Output " could not move the engine (try $try of 5): $($_.Exception.GetBaseException().Message)" }} + }} + }} + if (-not $moved) {{ + # Nothing was there before the tries; whatever one left is not an + # engine, and the fresh one setup installs needs the folder. + if (Test-Path -LiteralPath '{engine}') {{ + Move-Item -LiteralPath '{engine}' -Destination ('{engine}.partial-' + (Get-Date -Format yyyyMMddHHmmss)) -ErrorAction SilentlyContinue + }} + $retiredState = Join-Path $oldEngine 'config' + Step " the earlier engine stays in $oldEngine; setup installs a new one and copies its pairing state across" + }} }} $oldRoot = Split-Path $oldEngine if (-not (Get-ChildItem -LiteralPath $oldRoot -Force -ErrorAction SilentlyContinue)) {{ @@ -79,11 +148,47 @@ if (Test-Path -LiteralPath (Join-Path $oldEngine 'sunshine.exe')) {{ }} }} "#, - task = WINDOWS_ENGINE_TASK, + task = q(old.task), rules = rules, - old = WINDOWS_ENGINE_DIR, - service = WINDOWS_SERVICE, - engine = engine_dir, + old = q(old.engine_dir), + service = q(old.service), + engine = q(engine_dir), + ) +} + +/// PowerShell for setup's install step, once a fresh engine is in +/// `engine_dir`: when the retire step could not move the old engine, copy +/// its pairing state (the server's identity and paired devices, and the +/// apps and settings) into the new one, which has none yet. A failure is +/// logged, never thrown, because the new engine streams either way; its +/// devices then pair again. +pub fn restore_retired_state_ps(engine_dir: &str) -> String { + let list = |files: &[&[&str]]| { + files + .iter() + .map(|f| format!("'{}'", f.join(r"\"))) + .collect::>() + .join(", ") + }; + format!( + r#"if ($retiredState -and -not (Test-Path -LiteralPath (Join-Path '{engine}' 'config\sunshine_state.json'))) {{ + Step "Copying the pairing state from $retiredState" + try {{ + $to = Join-Path '{engine}' 'config' + New-Item -ItemType Directory -Force -Path (Join-Path $to 'credentials') | Out-Null + foreach ($f in @({required}, {optional})) {{ + $from = Join-Path $retiredState $f + if (Test-Path -LiteralPath $from) {{ Copy-Item -LiteralPath $from -Destination (Join-Path $to $f) -Force -ErrorAction Stop }} + }} + foreach ($f in @({required})) {{ + if (-not (Test-Path -LiteralPath (Join-Path $to $f))) {{ Write-Output " $f was not there to copy; paired devices pair again" }} + }} + }} catch {{ Write-Output " could not copy the pairing state ($_); paired devices pair again" }} + }} + "#, + engine = engine_dir.replace('\'', "''"), + required = list(crate::migrate::REQUIRED), + optional = list(crate::migrate::OPTIONAL), ) } @@ -100,9 +205,7 @@ mod tests { assert!(s.contains("Unregister-ScheduledTask"), "{s}"); assert!(s.contains("sc.exe delete 'BroLinkStream'"), "{s}"); assert!( - s.contains( - r"Move-Item -LiteralPath $oldEngine -Destination 'C:\Program Files\Latch\engine'" - ), + s.contains(r"[IO.Directory]::Move($oldEngine, 'C:\Program Files\Latch\engine')"), "{s}" ); // The old folder is only emptied after it has been moved from, and @@ -111,6 +214,127 @@ mod tests { assert!(!s.contains("Remove-Item -Recurse"), "{s}"); } + /// The fragment with its comments stripped, for "never does" checks. + fn code(s: &str) -> String { + s.lines() + .filter(|l| !l.trim_start().starts_with('#')) + .collect::>() + .join("\n") + } + + #[test] + fn a_failed_move_is_retried_then_leaves_the_pairing_state_for_the_new_engine() { + let s = code(&retire_windows_ps(r"C:\Program Files\Latch\engine")); + let reset = s.find("$retiredState = $null").expect("reset first"); + let retry = s.find("foreach ($try in 1..5)").expect("retries"); + let kill = s[retry..].find("Stop-Process").expect("kill in the loop") + retry; + let mov = s.find("[IO.Directory]::Move($oldEngine").expect("move"); + let kept = s + .find("$retiredState = Join-Path $oldEngine 'config'") + .expect("state kept for the fresh engine"); + assert!( + reset < retry && retry < kill && kill < mov && mov < kept, + "{s}" + ); + // A throw would end setup before the new firewall rules, with the + // old ones already deleted: a remote PC would drop off the tailnet. + assert!(!s.contains("throw"), "{s}"); + } + + #[test] + fn a_folder_in_the_way_is_cleared_before_the_move_so_nothing_nests() { + let s = code(&retire_windows_ps(r"C:\Program Files\Latch\engine")); + let aside = s + .find(r"$aside = 'C:\Program Files\Latch\engine.partial-'") + .expect("a partial folder is kept aside"); + let empty = s + .find(r"Remove-Item -LiteralPath 'C:\Program Files\Latch\engine' -Force") + .expect("an empty one is removed"); + let guard = s + .find(r"if (-not (Test-Path -LiteralPath 'C:\Program Files\Latch\engine'))") + .expect("no move onto a folder that is still there"); + let mov = s.find("[IO.Directory]::Move($oldEngine").expect("move"); + assert!(aside < guard && empty < guard && guard < mov, "{s}"); + } + + #[test] + fn the_state_copy_runs_only_after_a_failed_move_and_never_throws() { + let s = restore_retired_state_ps(r"C:\Program Files\Latch\engine"); + assert!( + s.starts_with( + r"if ($retiredState -and -not (Test-Path -LiteralPath (Join-Path 'C:\Program Files\Latch\engine' 'config\sunshine_state.json')))" + ), + "{s}" + ); + for f in crate::migrate::REQUIRED + .iter() + .chain(crate::migrate::OPTIONAL) + { + assert!(s.contains(&format!("'{}'", f.join(r"\"))), "{f:?}: {s}"); + } + assert!(s.contains("Copy-Item -LiteralPath $from"), "{s}"); + assert!(s.contains("} catch {"), "{s}"); + assert!(!code(&s).contains("throw"), "{s}"); + } + + #[test] + fn names_from_elsewhere_are_quoted_as_literals() { + let s = retire_windows_ps_with( + &Retiring { + engine_dir: r"D:\a\_temp\O'Brien\BroLink\engine", + service: "LatchRetireSelftest", + task: "LatchRetireSelftestTask", + rules: &["Latch 'Retire' Selftest"], + }, + r"D:\a\_temp\O'Brien\Latch\engine", + ); + assert!( + s.contains(r"$oldEngine = 'D:\a\_temp\O''Brien\BroLink\engine'"), + "{s}" + ); + assert!(s.contains("'Latch ''Retire'' Selftest'"), "{s}"); + assert!(s.contains("sc.exe delete 'LatchRetireSelftest'"), "{s}"); + assert!(!s.contains(WINDOWS_ENGINE_DIR), "{s}"); + assert!(!s.contains(WINDOWS_SERVICE), "{s}"); + } + + /// The seam the Windows CI job runs the retire step through (see + /// `.github/workflows/ci.yml`): on temporary folders and a dummy service, + /// never the real ones, which a run of this repo's tests on a real PC + /// must not touch. `LATCH_DUMP_RETIRE_SCRIPT` writes the script; the + /// `LATCH_DUMP_RETIRE_OLD`, `_NEW` and `_SERVICE` it uses are required. + #[test] + fn the_retire_step_can_be_dumped_for_a_windows_run() { + let Ok(out) = std::env::var("LATCH_DUMP_RETIRE_SCRIPT") else { + return; + }; + let var = |k: &str| std::env::var(k).unwrap_or_else(|_| panic!("{k} is required")); + let (old, new, service) = ( + var("LATCH_DUMP_RETIRE_OLD"), + var("LATCH_DUMP_RETIRE_NEW"), + var("LATCH_DUMP_RETIRE_SERVICE"), + ); + assert!( + !old.eq_ignore_ascii_case(WINDOWS_ENGINE_DIR) + && !service.eq_ignore_ascii_case(WINDOWS_SERVICE), + "the selftest never runs on the real engine" + ); + let retire = retire_windows_ps_with( + &Retiring { + engine_dir: &old, + service: &service, + task: "LatchRetireSelftestTask", + rules: &["Latch Retire Selftest"], + }, + &new, + ); + let script = format!( + "$ErrorActionPreference = 'Continue'\nfunction Step($m) {{ Write-Output $m }}\n{retire}{}\nexit 0\n", + restore_retired_state_ps(&new) + ); + std::fs::write(out, script).expect("write the script"); + } + #[test] fn an_old_engine_program_is_still_recognised() { assert!(ENGINE_EXE_NAMES.contains(&"BroLinkStreaming")); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 742e6fd..4f1becb 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -6,6 +6,8 @@ pub mod audio; pub mod brand; pub mod clipboard; pub mod config; +#[cfg(any(windows, test))] +pub mod cursor; pub mod display; pub mod legacy; pub mod logfile; diff --git a/crates/host/src/main.rs b/crates/host/src/main.rs index 3b27f1b..b65aa8c 100644 --- a/crates/host/src/main.rs +++ b/crates/host/src/main.rs @@ -26,9 +26,27 @@ struct Args { /// Run the control service with no window. #[arg(long)] background: bool, + /// Keep the streamed Windows cursor visible; runs in the user's desktop. + #[cfg(windows)] + #[arg(long, hide = true, conflicts_with_all = ["background", "setup", "setup_elevated", "brand_engine"])] + cursor_guard: Option, /// Started by an update: wait for this process to give up the port. #[arg(long, requires = "background")] replaces: Option, + /// Started by an update from the executable it retired: watch the new + /// service (this process id) and put the retired one back if it never + /// answers. See `update::probation`. + #[arg( + long, + value_name = "PID", + hide = true, + requires_all = ["background", "probation_version", "probation_exe"] + )] + probation: Option, + #[arg(long, hide = true)] + probation_version: Option, + #[arg(long, hide = true)] + probation_exe: Option, /// Give the streaming engine in DIR Latch's name and icon. Run by /// setup, elevated, with the engine stopped; exits non-zero with the /// reason on stderr. @@ -51,18 +69,30 @@ struct Args { } fn main() -> Result<()> { + // Before anything can replace it: which file this process is. + let _ = service::started_from(); let args = Args::parse(); + #[cfg(windows)] + if let Some(parent) = args.cursor_guard { + init_logging("cursor.log"); + return latch_host::cursor::run(parent).map_err(|e| { + tracing::error!("cursor guard: {e:#}"); + e + }); + } if let Some(dir) = &args.local_app_data { // Before any thread or data_dir() call; edition 2021, so set_var is safe. std::env::set_var("LOCALAPPDATA", dir); } // The elevated setup helper logs with the panel; `setup.log` is the // script's own output, which the setup card shows. - init_logging(if args.background { + let log = if args.background { "service.log" } else { "panel.log" - }); + }; + init_logging(log); + install_panic_hook(data_dir().ok().map(|d| d.join(log))); // init_logging has just called data_dir(), which takes over an older // install's folder; the engine's config inside still names the old one. #[cfg(not(windows))] @@ -73,8 +103,19 @@ fn main() -> Result<()> { // either way this copy runs from where it is. tracing::warn!("could not move into Latch.app: {e:#}"); } + if let (Some(pid), Some(version), Some(exe)) = + (args.probation, &args.probation_version, &args.probation_exe) + { + return latch_host::update::probation(exe, pid, version).inspect_err(|e| { + tracing::error!("the update watcher failed: {e:#}"); + }); + } if args.background { - return Service::new().run_arc(args.replaces.is_some()); + // Started by a window or a supervisor, nobody sees stderr: the + // reason it could not start belongs in its log. + return Service::new() + .run_arc(args.replaces.is_some()) + .inspect_err(|e| tracing::error!("the service could not start: {e:#}")); } if args.setup { return run_setup(); @@ -88,7 +129,9 @@ fn main() -> Result<()> { e }); } - service::ensure_service_running(); + if service::starts_with_window(&latch_host::config::HostConfig::load()) { + service::ensure_service_running(); + } eframe::run_native( "Latch", latch_client::native_options(), @@ -127,6 +170,44 @@ fn run_setup() -> Result<()> { /// and that one target is heard from only when it is an error. const DEFAULT_LOG: &str = "info,wgpu_hal=error"; +/// Release builds abort on a panic, in any thread. Without this the reason +/// went to stderr, which nothing keeps (a launchd agent, a Windows Run-key +/// process), and the log simply stopped. The line is appended to `log` +/// directly rather than through tracing, whose writer may be the very lock +/// the panic happened under; the default hook still runs after it. +fn install_panic_hook(log: Option) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let line = panic_line( + latch_core::dates::now_unix(), + std::thread::current().name(), + &info.to_string(), + ); + if let Some(path) = &log { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = f.write_all(line.as_bytes()); + } + } + previous(info); + })); +} + +/// `2026-10-17T16:46:33Z ERROR panic in thread 'main': `, +/// in the log's own format. +fn panic_line(unix: u64, thread: Option<&str>, info: &str) -> String { + format!( + "{} ERROR panic in thread '{}': {}\n", + latch_core::dates::utc_timestamp(unix), + thread.unwrap_or("unnamed"), + info.replace('\n', " ") + ) +} + /// Logs go to a file in the data directory: neither mode has a console. fn init_logging(file: &str) { use tracing_subscriber::EnvFilter; @@ -146,9 +227,25 @@ fn init_logging(file: &str) { #[cfg(test)] mod tests { - use super::DEFAULT_LOG; + use super::{panic_line, DEFAULT_LOG}; use tracing_subscriber::EnvFilter; + #[test] + fn a_panic_leaves_a_line_in_the_log() { + let line = panic_line( + 1_792_258_000, + Some("refresh"), + "panicked at crates/host/src/service.rs:212:9:\nindex out of bounds", + ); + assert!(line.starts_with("2026-10-17T"), "{line}"); + assert!( + line.contains("panic in thread 'refresh': panicked at crates/host/src/service.rs:212:9: index out of bounds"), + "{line}" + ); + assert!(line.ends_with('\n') && line.matches('\n').count() == 1); + assert!(panic_line(0, None, "x").contains("thread 'unnamed'")); + } + /// A typo here would be silent: `EnvFilter` drops directives it cannot /// parse and carries on, so a broken default would quietly restore the /// per-frame flood rather than fail the build. diff --git a/crates/host/src/power.rs b/crates/host/src/power.rs index f2d830a..aa9b444 100644 --- a/crates/host/src/power.rs +++ b/crates/host/src/power.rs @@ -1,13 +1,20 @@ -//! Remote power actions, and keeping the PC from idle-sleeping. +//! Remote power actions, and keeping the machine from idle-sleeping. use anyhow::Result; use latch_core::api::PowerAction; +#[cfg(target_os = "macos")] +use std::time::{Duration, Instant}; -/// Prevent Windows from idle-sleeping while Latch Host is running, so -/// Tailscale stays up. User-initiated Sleep (Start menu or the Mac) still -/// works. Call this from the background service, not the control panel: -/// the execution state is per-thread, and `refresh_loop` is the one caller. -pub fn keep_awake(on: bool) { +/// Keep the machine from idle-sleeping while the service runs, so Tailscale +/// stays up, but only while it is plugged in. Sleep the owner asks for +/// (the Start menu, the Apple menu, the lid) still works. Returns whether +/// the machine is now kept awake, so the log says so only when it is. +/// +/// Windows: an execution-state request, which belongs to the calling thread; +/// call it from the background service's `refresh_loop`, its one caller. +/// macOS: a PreventUserIdleSystemSleep assertion, which belongs to the +/// process and goes with it. Elsewhere nothing: a server does not idle-sleep. +pub fn keep_awake(on: bool) -> bool { #[cfg(windows)] { use windows::Win32::System::Power::{ @@ -27,17 +34,230 @@ pub fn keep_awake(on: bool) { } else { SetThreadExecutionState(ES_CONTINUOUS); } + on && plugged_in } } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + { + mac::keep_awake(on && mac::on_ac_power()) + } + #[cfg(not(any(windows, target_os = "macos")))] { let _ = on; + false + } +} + +/// IOKit's power assertions and power source, without a crate for them. +#[cfg(target_os = "macos")] +mod mac { + use std::ffi::{c_char, c_void, CStr, CString}; + use std::sync::Mutex; + + type CFTypeRef = *const c_void; + type CFStringRef = *const c_void; + const UTF8: u32 = 0x0800_0100; // kCFStringEncodingUTF8 + const ASSERTION_ON: u32 = 255; // kIOPMAssertionLevelOn + + #[link(name = "IOKit", kind = "framework")] + extern "C" { + fn IOPSCopyPowerSourcesInfo() -> CFTypeRef; + fn IOPSGetProvidingPowerSourceType(snapshot: CFTypeRef) -> CFStringRef; + fn IOPMAssertionCreateWithName( + kind: CFStringRef, + level: u32, + name: CFStringRef, + id: *mut u32, + ) -> i32; + fn IOPMAssertionRelease(id: u32) -> i32; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(cf: CFTypeRef); + fn CFStringCreateWithCString( + alloc: *const c_void, + s: *const c_char, + encoding: u32, + ) -> CFStringRef; + fn CFStringGetCString(s: CFStringRef, buf: *mut c_char, size: isize, encoding: u32) -> u8; + } + + /// The assertion this process holds, if any. + static HELD: Mutex> = Mutex::new(None); + + /// Hold the assertion when `want`, release it otherwise. Returns + /// whether it is held. + pub fn keep_awake(want: bool) -> bool { + let mut held = HELD.lock().unwrap_or_else(|e| e.into_inner()); + match (*held, want) { + (Some(_), true) => true, + (None, false) => false, + (Some(id), false) => { + unsafe { IOPMAssertionRelease(id) }; + *held = None; + false + } + (None, true) => { + // Not PreventSystemSleep (`caffeinate -s`): that one keeps + // the Mac in dark wake when someone asks it to sleep. + let (Some(kind), Some(name)) = ( + cf_string("PreventUserIdleSystemSleep"), + cf_string("Latch: keep this Mac reachable while it is shared"), + ) else { + return false; + }; + let mut id = 0u32; + let ok = unsafe { IOPMAssertionCreateWithName(kind, ASSERTION_ON, name, &mut id) }; + unsafe { + CFRelease(kind); + CFRelease(name); + } + if ok == 0 { + *held = Some(id); + } + held.is_some() + } + } + } + + /// The Mac draws from a power adapter (or a UPS), not its battery. + pub fn on_ac_power() -> bool { + unsafe { + let info = IOPSCopyPowerSourcesInfo(); + if info.is_null() { + // A Mac with no power-source information is a desktop. + return true; + } + let source = IOPSGetProvidingPowerSourceType(info); + let mut buf = [0 as c_char; 64]; + let read = !source.is_null() + && CFStringGetCString(source, buf.as_mut_ptr(), buf.len() as isize, UTF8) != 0; + CFRelease(info); + !read || CStr::from_ptr(buf.as_ptr()).to_bytes() != b"Battery Power" + } } + + fn cf_string(s: &str) -> Option { + let c = CString::new(s).ok()?; + let r = unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) }; + (!r.is_null()).then_some(r) + } +} + +/// What kind of machine this is, as far as remote power goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// A Windows PC: Wake-on-LAN brings it back. + Pc, + /// A Mac: nothing in Latch can wake it once it sleeps. + Mac, + /// A Linux machine on its own hardware: someone at it can wake it. + Linux, + /// A Linux virtual machine (a cloud VPS): asleep, it has no wake + /// source but its provider's console. + LinuxVm, + /// A container (the Docker node): there is no systemd to sleep, + /// restart or power off anything. + Container, +} + +/// This machine's [`Kind`], looked at once. +pub fn kind() -> Kind { + static KIND: std::sync::OnceLock = std::sync::OnceLock::new(); + *KIND.get_or_init(|| { + if cfg!(windows) { + Kind::Pc + } else if cfg!(target_os = "macos") { + Kind::Mac + } else { + let exists = |p: &str| std::path::Path::new(p).exists(); + let vm = std::process::Command::new("systemd-detect-virt") + .args(["--vm", "--quiet"]) + .status() + .is_ok_and(|s| s.success()); + linux_kind( + exists("/run/systemd/system"), + exists("/.dockerenv") || exists("/run/.containerenv"), + vm, + ) + } + }) +} + +fn linux_kind(systemd: bool, container: bool, vm: bool) -> Kind { + if container || !systemd { + Kind::Container + } else if vm { + Kind::LinuxVm + } else { + Kind::Linux + } +} + +/// Why `name`, a machine of `kind`, will not do `action` for another +/// machine; `None` when it will. Sleep is refused where nothing can wake +/// the machine again: it would drop off the tailnet until someone reached +/// it by hand (stuzha, an OCI VM in s2idle, would need the cloud console). +/// Shut down stays: the owner allowed it, and the Mac asks first. +pub fn refusal(kind: Kind, action: PowerAction, name: &str) -> Option { + match (kind, action) { + (Kind::Container, _) => Some(format!( + "{name} runs in a container, which cannot sleep, restart or shut down the machine it runs on" + )), + (Kind::LinuxVm, PowerAction::Sleep) => Some(format!( + "{name} is a virtual machine: asleep, only its provider's console could wake it. Restart it instead" + )), + (Kind::Mac, PowerAction::Sleep) => Some(format!( + "Latch cannot wake {name} once it sleeps, so it does not put it to sleep from here. Sleep it at the Mac" + )), + _ => None, + } +} + +/// On Linux, whether logind lets this service do `action`: `Some(answer)` +/// when it says anything but "yes" ("challenge" needs a password nobody +/// will type, "no" is never). `None` when it agrees or cannot be asked. +pub fn logind_refuses(action: PowerAction) -> Option { + if !cfg!(target_os = "linux") { + return None; + } + let method = match action { + PowerAction::Sleep => "CanSuspend", + PowerAction::Restart => "CanReboot", + PowerAction::Shutdown => "CanPowerOff", + }; + let out = std::process::Command::new("busctl") + .args([ + "--system", + "call", + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + method, + ]) + .output() + .ok() + .filter(|o| o.status.success())?; + let reply = String::from_utf8_lossy(&out.stdout); + let answer = logind_answer(&reply)?; + (answer != "yes").then(|| answer.to_string()) +} + +/// `s "yes"`, busctl's way of printing a string reply, as `yes`. +fn logind_answer(reply: &str) -> Option<&str> { + reply + .trim() + .strip_prefix("s ")? + .trim() + .strip_prefix('"')? + .strip_suffix('"') } /// Carry out `action`. Sleep returns once the request is accepted (the OS /// suspends a moment later); restart and shutdown schedule themselves a few -/// seconds out so the reply has left the machine. +/// seconds out so the reply has left the machine. On a Mac, restart and +/// shutdown go through System Events and say why when macOS will not. pub fn perform(action: PowerAction) -> Result<()> { #[cfg(windows)] { @@ -108,14 +328,7 @@ fn unix_perform(action: PowerAction) -> Result<()> { } else { "shut down" }; - let status = std::process::Command::new("osascript") - .args([ - "-e", - &format!("tell application \"System Events\" to {apple}"), - ]) - .status()?; - anyhow::ensure!(status.success(), "osascript {apple} failed"); - Ok(()) + system_events(apple, Duration::from_secs(3)) } #[cfg(target_os = "linux")] { @@ -133,3 +346,109 @@ fn unix_perform(action: PowerAction) -> Result<()> { } } } + +/// Ask System Events to `verb` the Mac, waiting at most `limit`. Sending it +/// Apple events needs the Automation permission, which macOS asks for on +/// the Mac's own screen, where nobody may be; and an app with unsaved work +/// can cancel the restart. Either way the Mac stays on, so say so. +#[cfg(target_os = "macos")] +fn system_events(verb: &str, limit: Duration) -> Result<()> { + use std::process::{Command, Stdio}; + let mut child = Command::new("osascript") + .args([ + "-e", + &format!("tell application \"System Events\" to {verb}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()?; + let start = Instant::now(); + loop { + if child.try_wait()?.is_some() { + break; + } + if start.elapsed() >= limit { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("{}", osascript_failure("-1712")); + } + std::thread::sleep(Duration::from_millis(100)); + } + let out = child.wait_with_output()?; + anyhow::ensure!( + out.status.success(), + "{}", + osascript_failure(&String::from_utf8_lossy(&out.stderr)) + ); + Ok(()) +} + +/// What an osascript error means for the person who asked. +#[cfg(any(target_os = "macos", test))] +fn osascript_failure(stderr: &str) -> String { + const SETTINGS: &str = "System Settings > Privacy & Security > Automation"; + if stderr.contains("-1743") { + format!("Latch is not allowed to control System Events on this Mac; allow it in {SETTINGS}") + } else if stderr.contains("-1712") { + format!("macOS is asking on the Mac's screen whether Latch may control System Events, and nobody answered; allow it in {SETTINGS}") + } else if stderr.contains("-128") { + "an app on the Mac cancelled it, likely over unsaved work".into() + } else { + format!( + "osascript: {}", + stderr.trim().lines().next().unwrap_or("failed") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sleep_is_refused_where_nothing_could_wake_the_machine() { + use PowerAction::*; + assert!(refusal(Kind::Pc, Sleep, "PC").is_none()); + assert!(refusal(Kind::Linux, Sleep, "box").is_none()); + let vm = refusal(Kind::LinuxVm, Sleep, "stuzha").unwrap(); + assert!(vm.contains("stuzha is a virtual machine"), "{vm}"); + assert!(refusal(Kind::LinuxVm, Restart, "stuzha").is_none()); + assert!(refusal(Kind::LinuxVm, Shutdown, "stuzha").is_none()); + assert!(refusal(Kind::Mac, Sleep, "MacBook").is_some()); + assert!(refusal(Kind::Mac, Restart, "MacBook").is_none()); + for a in [Sleep, Restart, Shutdown] { + assert!(refusal(Kind::Container, a, "node").is_some()); + } + assert_eq!(linux_kind(true, false, true), Kind::LinuxVm); + assert_eq!(linux_kind(true, false, false), Kind::Linux); + assert_eq!(linux_kind(false, false, false), Kind::Container); + assert_eq!(linux_kind(true, true, false), Kind::Container); + } + + /// IOKit is reached without a crate; this is the check that the + /// declarations match it. The assertion is held for a moment only. + #[cfg(target_os = "macos")] + #[test] + #[ignore = "takes a real power assertion; run on demand on a Mac"] + fn a_mac_holds_and_lets_go_of_its_assertion() { + let _ = mac::on_ac_power(); + assert!(mac::keep_awake(true)); + assert!(mac::keep_awake(true), "held once, not again"); + assert!(!mac::keep_awake(false)); + } + + #[test] + fn logind_and_osascript_answers_are_read() { + assert_eq!(logind_answer("s \"yes\"\n"), Some("yes")); + assert_eq!(logind_answer("s \"challenge\""), Some("challenge")); + assert_eq!(logind_answer("garbage"), None); + assert!(osascript_failure( + "0:46: execution error: Not authorized to send Apple events to System Events. (-1743)" + ) + .contains("not allowed")); + assert!(osascript_failure("execution error: User canceled. (-128)").contains("cancelled")); + assert!(osascript_failure("-1712").contains("nobody answered")); + assert_eq!(osascript_failure("boom\nmore"), "osascript: boom"); + } +} diff --git a/crates/host/src/product.rs b/crates/host/src/product.rs index 1308382..8bf20bb 100644 --- a/crates/host/src/product.rs +++ b/crates/host/src/product.rs @@ -86,6 +86,7 @@ impl NodeApp { g.setup_running = shared.setup_running; g.setup_result = shared.setup_result.clone(); g.service_stopped = shared.stopped_by_user(); + g.viewer_only = shared.viewer_only; } } diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 1f719e2..7172cde 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -8,6 +8,10 @@ //! more: a machine shared in from someone else's tailnet can reach the //! port, but may not pair, sleep the PC, read its clipboard or push it an //! executable. +//! +//! Under userspace Tailscale (a container's tailscaled) every tailnet peer +//! arrives from loopback too, so there loopback alone proves nothing: +//! `tailscale whois` of the connection's own port says who is behind it. use crate::clipboard; use crate::config::HostConfig; @@ -16,8 +20,8 @@ use crate::update; use crate::wake::{self, WakeInfo}; use anyhow::{Context, Result}; use latch_core::api::{ - Ack, Clipboard, DisplayRequest, NatReport, PinRequest, PowerRequest, Status, Streamer, - CLIPBOARD_PATH, UPDATE_PATH, + Ack, Clipboard, DisplayRequest, NatReport, PinRequest, PowerAction, PowerRequest, Status, + Streamer, CLIPBOARD_PATH, UPDATE_PATH, }; use latch_core::http::{self, Request, Response}; use latch_core::tailscale::{self, is_tailnet_ip}; @@ -35,9 +39,15 @@ const AUTH_TTL: Duration = Duration::from_secs(60); /// How long an answer is remembered, so the same peer is not logged again. const AUTH_REMEMBER: Duration = Duration::from_secs(3600); const AUTH_CACHE_LIMIT: usize = 256; +/// How long a PIN the engine took counts as entered for the Mac's retries: +/// longer than the engine waits for the handshake. +const PIN_TAKEN_FOR: Duration = Duration::from_secs(30); /// Ticks (of 5 s) between runs of `tailscale netcheck`: it takes seconds /// and the network does not move often. const NETCHECK_TICKS: u64 = 120; +/// The tick (of 5 s) at which a service lets go of what an update retired: +/// ten minutes in, long after the update's watcher has finished. +const TIDY_TICK: u64 = 120; pub struct Service { cfg: Mutex, @@ -58,10 +68,35 @@ pub struct Service { auth: Mutex>, /// The last `whois` failure logged. whois_failed: Mutex>, + /// Whether Tailscale here has a kernel TUN device, as last reported. + /// Kept when a later `status` fails: `Some(false)` (userspace) is what + /// makes loopback a peer's address too, and must not lapse. + tun: Mutex>, + /// The last line logged about a peer forwarded through loopback. + forwarded_logged: Mutex>, + /// Why host.toml could not be re-read, while it cannot. + cfg_problem: Mutex>, + /// A reinstall was noticed and handing over to it failed. + reinstall_tried: AtomicBool, + /// The machine is being kept from idle-sleeping (see `power::keep_awake`). + kept_awake: AtomicBool, + /// The macOS firewall turns away connections to this program. + firewall_blocked: AtomicBool, + /// macOS has the login item turned off (see `unix_setup`). + login_item_off: AtomicBool, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, update_running: Arc, + /// A PIN the engine took and is pairing with, and when: the Mac's + /// retries for it are answered without asking the engine again, which + /// is busy with that very handshake. + pin_taken: Mutex>, + /// The last PIN refusal logged. + pin_refused: Mutex>, + /// Since when a session runs with nobody watching, on a desktop that + /// follows its viewer (see [`streamer::unwatched_close`]). + unwatched_since: Mutex>, } impl Service { @@ -78,9 +113,19 @@ impl Service { log: Arc::new(Mutex::new(VecDeque::new())), auth: Mutex::new(HashMap::new()), whois_failed: Mutex::new(None), + tun: Mutex::new(None), + forwarded_logged: Mutex::new(None), + cfg_problem: Mutex::new(None), + reinstall_tried: AtomicBool::new(false), + kept_awake: AtomicBool::new(false), + firewall_blocked: AtomicBool::new(false), + login_item_off: AtomicBool::new(false), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), + pin_taken: Mutex::new(None), + pin_refused: Mutex::new(None), + unwatched_since: Mutex::new(None), } } @@ -88,14 +133,31 @@ impl Service { push_log(&self.log, msg.into()); } - fn session_active(&self) -> bool { - let cfg = self.cfg.lock().clone(); - cfg.has_creds() - && Api { + /// End a session nobody has watched for [`streamer::UNWATCHED_CLOSE`]: + /// the engine then runs its prep command's undo, which puts a desktop + /// that follows its viewer back at its idle size. + fn end_unwatched_session(&self, cfg: &HostConfig, session: &streamer::Session) { + let mut since = self.unwatched_since.lock(); + let (end, next) = streamer::unwatched_close(session, *since, Instant::now()); + *since = next; + if end { + Api { user: &cfg.sunshine_user, pass: &cfg.sunshine_pass, } - .session_active() + .close_app(); + self.log(format!( + "nobody has watched for {} minutes: ended the session, so the desktop is back at its idle size", + streamer::UNWATCHED_CLOSE.as_secs() / 60 + )); + } + } + + /// A viewer is connected right now. A Desktop left running for the + /// viewer to resume does not count: see [`streamer::Session`]. + fn someone_watching(&self) -> bool { + let install = self.install.lock().clone(); + streamer::session(install.as_ref()).watched() } pub fn run_arc(self, replacing: bool) -> Result<()> { @@ -107,6 +169,13 @@ impl Service { /// update just started: the old one is still answering its last request, /// so wait for the port rather than give up. pub fn run(self: Arc, replacing: bool) -> Result<()> { + if !starts_with_window(&self.cfg.lock()) { + // A Mac that is only a viewer: an older window (or anything else) + // that starts the service must not share it. Exit 0, so launchd + // does not try again. + self.log("this Mac is not shared (Share this Mac is off); the service exits"); + return Ok(()); + } let addr = SocketAddr::from((Ipv4Addr::UNSPECIFIED, CONTROL_PORT)); let listener = if replacing { let deadline = Instant::now() + Duration::from_secs(60); @@ -143,16 +212,26 @@ impl Service { if let Ok(exe) = std::env::current_exe() { if replacing { self.log("updated: the previous version has been replaced"); - update::tidy(&exe); } self.ensure_autostart(&exe); } + // Before the first request: under userspace Tailscale every tailnet + // peer arrives from loopback, so that has to be known first. + if let Ok(st) = tailscale::status() { + self.note_tun(&st); + } let refresher = self.clone(); std::thread::spawn(move || refresher.refresh_loop()); // (refresh_loop takes the Arc so netcheck can run on its own thread.) - let svc = self.clone(); - if let Err(e) = wake::listen(move |mac, from| svc.wake_packet(mac, from)) { - self.log(format!("not listening for wake packets: {e:#}")); + // Only a Windows PC learns its wake MAC (see `wake::probe`), and + // without one every packet is ignored: elsewhere the listener only + // held UDP 9 on every interface (a Mac) or failed to bind it and + // said so at every start (a Linux user service). + if wants_wake_listener() { + let svc = self.clone(); + if let Err(e) = wake::listen(move |mac, from| svc.wake_packet(mac, from)) { + self.log(format!("not listening for wake packets: {e:#}")); + } } let handler = self.clone(); let authorizer = self.clone(); @@ -190,14 +269,35 @@ impl Service { /// Cheap checks every tick; the slower probes on a longer cadence. fn refresh(self: &Arc, tick: u64) { - *self.cfg.lock() = HostConfig::load(); + let prev = self.cfg.lock().clone(); + let (cfg, line) = next_config(prev, HostConfig::try_load(), &mut self.cfg_problem.lock()); + *self.cfg.lock() = cfg; + if let Some(line) = line { + self.log(line); + } let stay_awake = self.cfg.lock().stay_awake; - crate::power::keep_awake(stay_awake); - if tick == 0 && stay_awake { - self.log("keeping this PC awake so Tailscale stays reachable"); + let awake = crate::power::keep_awake(stay_awake); + // Said when it starts and stops being true, and only where it is: + // a Linux server, or a laptop on battery, is not being kept awake. + if awake != self.kept_awake.swap(awake, Ordering::AcqRel) { + self.log(if awake { + format!( + "keeping this {} awake while it is plugged in, so Tailscale stays reachable", + if cfg!(target_os = "macos") { + "Mac" + } else { + "PC" + } + ) + } else { + "no longer keeping this machine awake (on battery, or the setting is off)".into() + }); } let ts = tailscale::status().map_err(|e| e.to_string()); + if let Ok(st) = &ts { + self.note_tun(st); + } let came_up; { let mut cur = self.tailscale.lock(); @@ -236,32 +336,53 @@ impl Service { running && self.streamer.lock().api_ok }; // Apply the streaming profile on any engine Latch can log into, - // after any old app has ended. The marker survives panel/config saves. + // after any old app has ended. The marker survives panel/config saves; + // v4 drops Linux's frame-rate floor (see setup::LINUX_ENGINE_CONF). if tick.is_multiple_of(6) && api_ok && install.is_some() { if let Ok(dir) = latch_core::config::data_dir() { - let marker = dir.join("stream-profile-v3"); + let marker = dir.join("stream-profile-v4"); if !marker.exists() { let api = Api { user: &cfg.sunshine_user, pass: &cfg.sunshine_pass, }; - match api.upgrade_stream_profile() { + match api.upgrade_stream_profile(install.as_ref()) { Ok(true) => { if let Err(e) = std::fs::write(&marker, "1") { self.log(format!("could not save stream profile version: {e}")); } - self.log("stream profile ready: match client display, constant bitrate, 60 fps"); + self.log(if cfg!(target_os = "linux") { + "stream profile ready: constant bitrate, the viewer's frame rate" + } else { + "stream profile ready: match client display, constant bitrate, 60 fps" + }); } - Ok(false) => {} // A running app owns the display until it ends. + Ok(false) => {} // Retried once nobody watches. Err(e) => self.log(format!("stream profile could not be applied: {e:#}")), } + } else if cfg!(windows) { + // Which display the engine captures is kept current all + // along: the virtual display can come or change any time. + let api = Api { + user: &cfg.sunshine_user, + pass: &cfg.sunshine_pass, + }; + if let Some(install) = &install { + match crate::virtual_display::follow(&api, install, &dir) { + Ok(Some(changed)) => self.log(changed), + Ok(None) => {} + Err(e) => self.log(format!("capture display could not be set: {e:#}")), + } + } } } } // The encoder Sunshine picked and whether it could capture audio, // from its log; re-read now and then since Sunshine restarts on // its own after setup and every session tries the audio device. - let (encoder, audio_problem) = + // The X screen it captures is re-read with them: a VPS desktop can + // be restarted at another size. + let (encoder, audio_problem, capture_size) = if tick.is_multiple_of(12) || self.streamer.lock().encoder.is_empty() { let log = install.as_ref().and_then(streamer::log_text); ( @@ -271,11 +392,30 @@ impl Service { log.as_deref() .and_then(streamer::audio_problem_in) .unwrap_or_default(), + streamer::capture_size(), ) } else { let cur = self.streamer.lock(); - (cur.encoder.clone(), cur.audio_problem.clone()) + ( + cur.encoder.clone(), + cur.audio_problem.clone(), + cur.capture_size, + ) }; + let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); + let max_fps = streamer::max_fps(&encoder, cores); + // Whether someone watches, every other tick: a Mac holds a host + // update back while they do, and a desktop that follows its viewer + // goes back to its idle size once nobody has watched for a while. + let viewer = if running && tick.is_multiple_of(2) { + let session = streamer::session(install.as_ref()); + if api_ok && streamer::display_follows_viewer() { + self.end_unwatched_session(&cfg, &session); + } + session.watched() + } else { + running && self.streamer.lock().viewer + }; let st = Streamer { kind: install .as_ref() @@ -286,6 +426,9 @@ impl Service { api_ok, encoder, audio_problem, + capture_size, + max_fps, + viewer, }; { let mut cur = self.streamer.lock(); @@ -322,13 +465,17 @@ impl Service { if tick.is_multiple_of(6) { let install = self.install.lock().clone(); if let Some(install) = install { + #[cfg(windows)] + if let Err(e) = crate::cursor::start() { + tracing::debug!("cursor bounds: {e:#}"); + } let audio_problem = self.streamer.lock().audio_problem.clone(); if !streamer::running() { if let Err(e) = streamer::start(&install) { self.log(format!("streaming engine: {e:#}")); } - } else if cfg!(windows) && !audio_problem.is_empty() { - let _ = crate::audio::take_over_engine(); + } else if cfg!(windows) { + crate::audio::retry_take_over(&audio_problem); } } } @@ -360,6 +507,64 @@ impl Service { *cur = v; } } + + if !cfg!(windows) && tick % 12 == 11 { + self.follow_reinstall(); + } + #[cfg(target_os = "macos")] + if tick.is_multiple_of(12) { + if let Ok(exe) = std::env::current_exe() { + let blocked = crate::unix_setup::firewall_blocks(&exe); + if blocked && !self.firewall_blocked.swap(blocked, Ordering::AcqRel) { + self.log("the macOS firewall blocks incoming connections to Latch, so other machines cannot reach this Mac; run setup here"); + } + self.firewall_blocked.store(blocked, Ordering::Release); + } + let off = crate::unix_setup::login_item_disabled(); + if off && !self.login_item_off.swap(off, Ordering::AcqRel) { + self.log("macOS has Latch's login item turned off, so this Mac is not shared after a restart; run setup here"); + } + self.login_item_off.store(off, Ordering::Release); + } + // What an update retired is kept until the new version has served + // for a while: the update's watcher puts it back if it does not. + if tick == TIDY_TICK { + if let Ok(exe) = std::env::current_exe() { + update::tidy(&exe); + } + } + } + + /// A Mac or Linux install replaced under a running service (a new + /// Latch.app copied over, a package upgrade) keeps serving the old code + /// until something restarts it, and the window cannot tell when the + /// version did not change. Restart into the new program once nobody + /// watches. (Windows swaps its executable through `update`.) + fn follow_reinstall(self: &Arc) { + let Some(stamp) = started_from().filter(|s| s.replaced()) else { + return; + }; + if self.reinstall_tried.load(Ordering::Acquire) || self.someone_watching() { + return; + } + #[cfg(not(windows))] + if crate::unix_setup::supervised() { + // launchd and systemd restart a service that failed, from the + // path the new program is at. + self.log("Latch was reinstalled; the service restarts into the new copy"); + std::process::exit(75); + } + // Once: a new copy that cannot start is not retried every minute. + self.reinstall_tried.store(true, Ordering::Release); + match update::hand_over(stamp.path(), update::STARTUP_GRACE) { + Ok(_) => { + self.log("Latch was reinstalled; the new copy takes over the service"); + std::process::exit(0); + } + Err(e) => self.log(format!( + "Latch was reinstalled, but the new copy did not start: {e:#}" + )), + } } /// `tailscale netcheck` on its own thread, once at a time. @@ -371,7 +576,11 @@ impl Service { std::thread::spawn(move || { match tailscale::netcheck() { Ok(n) => { - let report = n.report(); + let home = match &*svc.tailscale.lock() { + Ok(st) => st.self_node.relay.clone(), + Err(_) => String::new(), + }; + let report = n.report_near(&home); let mut cur = svc.nat.lock(); if cur.as_ref().is_none_or(|c| nat_changed(c, &report)) { svc.log(describe_nat(&report)); @@ -428,6 +637,15 @@ impl Service { if wake.fast_startup == Some(true) { setup.push("Fast Startup is on, so the PC cannot be woken after a shutdown.".into()); } + if self.firewall_blocked.load(Ordering::Acquire) { + setup.push("The macOS firewall blocks other machines from reaching Latch.".into()); + } + if self.login_item_off.load(Ordering::Acquire) { + setup.push( + "macOS has Latch's login item turned off, so sharing stops at the next restart." + .into(), + ); + } if *self.virtual_display.lock() == Some(false) { setup.push( "The virtual display does not list every screen size a viewer can ask for.".into(), @@ -481,9 +699,59 @@ impl Service { } } - /// Loopback is the control panel; a tailnet peer must pass - /// [`Owner::admits`]. A `whois` answer is remembered for [`AUTH_TTL`], - /// a refusal included; a failed `whois` is not remembered. + fn note_tun(&self, st: &tailscale::Status) { + if let Some(tun) = st.tun { + *self.tun.lock() = Some(tun); + } + } + + /// Who sent a request, once it has been read. A loopback connection is + /// a process on this machine, unless Tailscale runs in userspace mode: + /// then it may be a tailnet peer that tailscaled relayed, which must + /// pass [`Owner::admits`] like any other and never counts as local. + /// Asked after the request is read, when tailscaled has certainly + /// recorded which peer the connection is from (it does so only after + /// it has connected to this port). Never cached: every relayed peer + /// shares 127.0.0.1. + fn caller(&self, peer: SocketAddr) -> Caller { + if !peer.ip().is_loopback() { + return Caller::Peer { + allowed: self.authorized(peer.ip()), + }; + } + let tun = *self.tun.lock(); + let owner = self.tailscale.lock().as_ref().ok().map(Owner::of); + loopback_caller(tun, owner.as_ref(), || { + let found = tailscale::whois_addr(peer); + let line = match &found { + Ok(Some(w)) => Some(format!( + "{} ({}) asked through userspace Tailscale{}", + w.node.computed_name, + w.user_profile.login_name, + if owner.as_ref().is_some_and(|o| o.admits(w)) { + "" + } else { + ": not this account, refused" + } + )), + Ok(None) => None, + Err(e) => Some(format!("whois {peer}: {e}; refused")), + }; + if let Some(line) = line { + let mut last = self.forwarded_logged.lock(); + if last.as_deref() != Some(line.as_str()) { + self.log(line.clone()); + *last = Some(line); + } + } + found + }) + } + + /// Loopback may connect (see [`Service::caller`] for what it may do); + /// a tailnet peer must pass [`Owner::admits`]. A `whois` answer is + /// remembered for [`AUTH_TTL`], a refusal included; a failed `whois` is + /// not remembered. fn authorized(&self, ip: IpAddr) -> bool { if ip.is_loopback() { return true; @@ -562,7 +830,8 @@ impl Service { &Ack::err("browser access to the control API is disabled"), ); } - if !self.authorized(peer.ip()) { + let caller = self.caller(peer); + if !caller.allowed() { return Response::json( 403, &Ack::err("not a machine on this PC's Tailscale account"), @@ -581,12 +850,13 @@ impl Service { &Ack::err("a Latch request carries JSON or an executable"), ); } - let local = peer.ip().is_loopback(); + // The service log and /v1/quit are for this machine's own window. + let local = caller == Caller::Local; match (req.method.as_str(), req.path.as_str()) { ("GET", "/v1/status") => Response::json(200, &self.status(local)), ("GET", "/v1/display") => Response::json(200, &self.display()), ("POST", "/v1/display") => self.set_display(peer.ip(), req), - ("POST", "/v1/pin") => self.pin(req), + ("POST", "/v1/pin") => self.pin(peer.ip(), req), ("POST", "/v1/power") => self.power(peer.ip(), req), ("POST", p) if p == UPDATE_PATH => self.update(peer.ip(), req), ("GET", p) if p == CLIPBOARD_PATH => match clipboard::read() { @@ -622,7 +892,18 @@ impl Service { return Response::json( 400, &Ack::err( - "only a Windows PC takes a pushed latch-host.exe; this machine updates itself", + "only a Windows PC takes a pushed latch-host.exe; a Mac updates with its app, and a Linux machine installs the release's latch-host-linux tarball (deploy/native/README.md)", + ), + ); + } + if !self.cfg.lock().accept_updates { + self.log(format!( + "update from {from} refused: this PC takes no pushed updates (accept_updates = false in host.toml)" + )); + return Response::json( + 403, + &Ack::err( + "the owner of this PC has turned pushed updates off; update it at the PC", ), ); } @@ -630,7 +911,7 @@ impl Service { return Response::json(409, &Ack::err("an update is already being installed")); } let engine_running = self.streamer.lock().running; - if update::refuse_self_update(engine_running, self.session_active()) { + if update::refuse_self_update(engine_running, self.someone_watching()) { self.update_running.store(false, Ordering::Release); self.log("update deferred: a stream is running"); return Response::json( @@ -647,15 +928,25 @@ impl Service { }; match update::stage(req, &exe) { Ok(version) => { + let sha = req + .header(latch_core::api::UPDATE_SHA256_HEADER) + .or_else(|| req.header(latch_core::legacy::UPDATE_SHA256_HEADER)) + .unwrap_or("") + .trim() + .to_ascii_lowercase(); self.log(format!( - "updating to {version}: {from} sent the new Latch Host" + "updating to {version}: {from} sent the new Latch Host (SHA-256 {sha})" )); let running = self.update_running.clone(); let log = self.log.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(500)); match update::apply(&exe) { - Ok(()) => { + Ok(h) => { + if let Err(e) = update::start_probation(&exe, &h, &version.to_string()) + { + tracing::warn!("{e:#}"); + } tracing::info!("handing over to {version}"); std::process::exit(0); } @@ -669,13 +960,13 @@ impl Service { } Err(r) => { self.update_running.store(false, Ordering::Release); - self.log(format!("update refused: {}", r.message())); + self.log(format!("update from {from} refused: {}", r.message())); Response::json(r.status(), &Ack::err(r.message())) } } } - fn pin(&self, req: &Request) -> Response { + fn pin(&self, from: IpAddr, req: &Request) -> Response { let Ok(p) = req.json::() else { return Response::json(400, &Ack::err("expected {\"pin\",\"name\"}")); }; @@ -691,17 +982,51 @@ impl Service { ), ); } + if self + .pin_taken + .lock() + .as_ref() + .is_some_and(|(pin, at)| *pin == p.pin && at.elapsed() < PIN_TAKEN_FOR) + { + return Response::json(200, &Ack::ok()); + } let api = Api { user: &cfg.sunshine_user, pass: &cfg.sunshine_pass, }; - match api.submit_pin(&p.pin, &p.name) { - Ok(()) => { + match api.submit_pin(&p.pin, &p.name, from) { + Ok(streamer::Pin::Paired) => { self.log(format!("paired \"{}\"", p.name)); Response::json(200, &Ack::ok()) } + Ok(streamer::Pin::Taken) => { + *self.pin_taken.lock() = Some((p.pin.clone(), Instant::now())); + self.log(format!( + "PIN from \"{}\" entered; it finishes pairing itself", + p.name + )); + Response::json(200, &Ack::ok()) + } + Ok(streamer::Pin::NotYet) => { + // Not an error: the Mac retries within a second. A 200 with + // the reason is what it shows while it waits; any other + // status reads to it as "Latch isn't answering". + Response::json( + 200, + &Ack::err( + "waiting for this machine's pairing request to reach the streaming engine", + ), + ) + } Err(e) => { - self.log(format!("PIN from \"{}\" refused: {e:#}", p.name)); + // The Mac retries every second for over a minute; one line + // per reason, not one per retry. + let line = format!("PIN from \"{}\" refused: {e:#}", p.name); + let mut last = self.pin_refused.lock(); + if last.as_deref() != Some(line.as_str()) { + self.log(line.clone()); + *last = Some(line); + } Response::json(502, &Ack::err(e.to_string())) } } @@ -760,6 +1085,20 @@ impl Service { &Ack::err("the owner of this PC has turned remote power actions off"), ); } + let action = p.action; + let what = action.label().to_lowercase(); + // Refused before the stream is ended for it, and with the reason: + // the Mac shows an answer that is not ok as it is. (Every Mac + // client reads a 200's `ok`; another status only as "HTTP 409".) + let name = self.name.lock().clone(); + let refused = crate::power::refusal(crate::power::kind(), action, &name).or_else(|| { + crate::power::logind_refuses(action) + .map(|answer| format!("this machine's login manager says {answer} to {what}")) + }); + if let Some(why) = refused { + self.log(format!("{what} requested by {from}, refused: {why}")); + return Response::json(200, &Ack::err(why)); + } if cfg.has_creds() { Api { user: &cfg.sunshine_user, @@ -767,16 +1106,24 @@ impl Service { } .close_app(); } - self.log(format!( - "{} requested by {from}", - p.action.label().to_lowercase() - )); + self.log(format!("{what} requested by {from}")); + if cfg!(target_os = "macos") && action != PowerAction::Sleep { + // System Events answers at once, or not without someone at the + // Mac; either way the one who asked hears which. + return match crate::power::perform(action) { + Ok(()) => Response::json(200, &Ack::ok()), + Err(e) => { + self.log(format!("{what} failed: {e:#}")); + Response::json(200, &Ack::err(format!("{e:#}"))) + } + }; + } // Reply first: sleep can suspend the machine before the bytes leave. - let action = p.action; + let log = self.log.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(400)); if let Err(e) = crate::power::perform(action) { - tracing::error!("{}: {e:#}", action.label()); + push_log(&log, format!("{what} failed: {e:#}")); } }); Response::json(200, &Ack::ok()) @@ -808,6 +1155,73 @@ pub fn describe_nat(n: &NatReport) -> String { } } +/// The settings to run on after re-reading host.toml: the file's when it +/// reads, else the last good ones, never defaults. A hand edit with a typo +/// (or a read mid-save) used to move the file aside and run with remote +/// power allowed and no engine login until someone noticed. Returns the +/// line to log: once per problem, and once when it is fixed. +fn next_config( + prev: HostConfig, + loaded: Result, + problem: &mut Option, +) -> (HostConfig, Option) { + match loaded { + Ok(cfg) => { + let fixed = problem + .take() + .map(|_| "host.toml reads again; using its settings".to_string()); + (cfg, fixed) + } + Err(e) => { + let why = format!("{e:#}"); + let line = (problem.as_deref() != Some(why.as_str())).then(|| { + format!("host.toml could not be read ({why}); keeping the settings from before") + }); + *problem = Some(why); + (prev, line) + } + } +} + +/// Who sent a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Caller { + /// A process on this machine: its own window, or a script. + Local, + /// A tailnet machine, and whether this machine's owner admits it. + Peer { allowed: bool }, +} + +impl Caller { + fn allowed(self) -> bool { + match self { + Caller::Local => true, + Caller::Peer { allowed } => allowed, + } + } +} + +/// Who is behind a loopback connection. Only with userspace networking +/// (`TUN` false) can it be a tailnet peer; then `whois` of the connection's +/// address says which, and an address Tailscale knows nothing about is a +/// process on this machine. A lookup that fails admits nobody. +fn loopback_caller( + tun: Option, + owner: Option<&Owner>, + whois: impl FnOnce() -> Result>, +) -> Caller { + if tun != Some(false) { + return Caller::Local; + } + match whois() { + Ok(None) => Caller::Local, + Ok(Some(w)) => Caller::Peer { + allowed: owner.is_some_and(|o| o.admits(&w)), + }, + Err(_) => Caller::Peer { allowed: false }, + } +} + /// Whose machine this is, for deciding who may use the control API. #[derive(Debug, Clone, PartialEq)] struct Owner { @@ -870,6 +1284,12 @@ fn nat_changed(old: &NatReport, new: &NatReport) -> bool { != (new.udp, new.ipv4, new.ipv6, new.hard, new.portmap) } +/// Whether to listen for wake packets: only where `wake::probe` can find +/// this machine's MAC to match them against, which is Windows. +fn wants_wake_listener() -> bool { + cfg!(windows) +} + /// True when a service answers on loopback. pub fn service_alive() -> bool { http::request( @@ -883,13 +1303,85 @@ pub fn service_alive() -> bool { .unwrap_or(false) } -/// Start `--background` if nothing answers on loopback. -pub fn ensure_service_running() { +/// Whether opening the window starts the background service. A PC must +/// stay reachable, so on Windows and Linux it always does. A Mac does only +/// while it is shared ("Share this Mac", stored as `start_with_windows`): +/// off, it is a viewer, and nothing of Latch outlives its window. +pub fn starts_with_window(cfg: &HostConfig) -> bool { + starts_with_window_on(cfg!(target_os = "macos"), cfg) +} + +fn starts_with_window_on(mac: bool, cfg: &HostConfig) -> bool { + !mac || cfg.start_with_windows +} + +/// Start the background service if nothing answers on loopback. Through +/// launchd or systemd when there is a login item, so it is supervised and +/// there is only ever one; a copy of its own only where there is none (a +/// Windows PC, or a Mac whose owner turned the login item off). A Mac that +/// is not shared starts nothing: the service would only exit again, and a +/// window that kept asking spawned one every four seconds for hours. +/// Returns whether it asked for a start. +pub fn ensure_service_running() -> bool { + if !starts_with_window(&HostConfig::load()) { + tracing::info!("not starting the background service: this Mac is not shared"); + return false; + } if service_alive() { - return; + return false; + } + #[cfg(not(windows))] + match crate::unix_setup::start_supervised() { + Some(Ok(by)) => { + tracing::info!("asked {by} to start the background service"); + return true; + } + Some(Err(e)) => { + tracing::warn!("could not start the background service through its login item ({e:#}); starting it directly"); + } + None => {} + } + spawn_service() +} + +/// After an update: stop the service that answers (launchd's, or a copy an +/// older window started) and start it again from the file now on disk. +/// The supervisor would not restart it by itself, since /v1/quit exits 0. +pub fn restart_service() { + let _ = http::request( + ("127.0.0.1", CONTROL_PORT), + "POST", + "/v1/quit", + None, + Duration::from_secs(2), + ); + // Until it has let go of the port: a copy started while it still holds + // it would exit at once, and launchd would leave it at that. + let deadline = Instant::now() + Duration::from_secs(5); + while service_alive() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(200)); } + std::thread::sleep(Duration::from_millis(300)); + ensure_service_running(); +} + +/// How soon after it started a copy that stops counts as one that could not +/// start at all. +const QUICK_EXIT: Duration = Duration::from_secs(5); + +/// Why the last copy this window started stopped at once, for the page. +static QUICK_EXIT_NOTE: Mutex> = Mutex::new(None); + +/// The reason the last copy this window started stopped as soon as it +/// started, once. +pub fn take_quick_exit() -> Option { + QUICK_EXIT_NOTE.lock().take() +} + +/// Start a copy of `--background` as a child of this process. +fn spawn_service() -> bool { let Ok(exe) = std::env::current_exe() else { - return; + return false; }; let mut c = std::process::Command::new(exe); c.arg("--background") @@ -901,17 +1393,74 @@ pub fn ensure_service_running() { use std::os::windows::process::CommandExt; c.creation_flags(0x0000_0008 | 0x0800_0000); // DETACHED_PROCESS | CREATE_NO_WINDOW } + let started = Instant::now(); match c.spawn() { Ok(mut child) => { + let pid = child.id(); std::thread::spawn(move || { - let _ = child.wait(); + // Exit 0 at once is no failure: another copy already + // answered, or a Mac turned out not to be shared. + if let Ok(status) = child.wait() { + if !status.success() && started.elapsed() < QUICK_EXIT { + let why = format!( + "the background service stopped as it started ({status}); its log says why" + ); + tracing::warn!("{why}"); + *QUICK_EXIT_NOTE.lock() = Some(why); + } + } }); - tracing::info!("started the background service"); + tracing::info!("launched the background service (pid {pid})"); + true + } + Err(e) => { + tracing::error!("could not start the background service: {e}"); + false } - Err(e) => tracing::error!("could not start the background service: {e}"), } } +/// The program file this process started from, as it was then: to tell +/// when an update or a reinstall has put another one at that path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExeStamp { + path: std::path::PathBuf, + len: u64, + modified: Option, +} + +impl ExeStamp { + fn of(path: std::path::PathBuf) -> Option { + let m = std::fs::metadata(&path).ok()?; + Some(Self { + len: m.len(), + modified: m.modified().ok(), + path, + }) + } + + pub fn path(&self) -> &std::path::Path { + &self.path + } + + /// Another file sits at the path now: another size or time. `false` + /// while the path cannot be read (gone, or held by a virus scanner). + pub fn replaced(&self) -> bool { + Self::of(self.path.clone()) + .is_some_and(|now| (now.len, now.modified) != (self.len, self.modified)) + } +} + +/// This process's [`ExeStamp`], taken the first time it is asked for; +/// `main` asks first thing. The path is kept from then on: on Linux +/// `current_exe` follows the file an update renames away. +pub fn started_from() -> Option<&'static ExeStamp> { + static STARTED: std::sync::OnceLock> = std::sync::OnceLock::new(); + STARTED + .get_or_init(|| std::env::current_exe().ok().and_then(ExeStamp::of)) + .as_ref() +} + /// The `Content-Type` Latch's own callers send: JSON, or the executable /// on the update route. A browser cannot send either cross-origin without /// a CORS preflight, so this keeps web pages from driving the service. @@ -952,6 +1501,20 @@ fn push_log(log: &Mutex>, msg: String) { mod tests { use super::*; + #[test] + fn only_a_mac_that_is_not_shared_opens_without_the_service() { + let shared = HostConfig::default(); + let viewer = HostConfig { + start_with_windows: false, + ..Default::default() + }; + assert!(starts_with_window_on(true, &shared)); + assert!(!starts_with_window_on(true, &viewer)); + // A PC nobody can reach in person keeps its service either way. + assert!(starts_with_window_on(false, &viewer)); + assert!(starts_with_window_on(false, &shared)); + } + fn whois(id: u64, login: &str) -> tailscale::WhoIs { tailscale::WhoIs { user_profile: tailscale::User { @@ -1019,6 +1582,98 @@ mod tests { assert!(!unnamed.admits(&relay)); } + /// A Docker node's tailscaled in userspace mode relays every tailnet + /// peer from 127.0.0.1: only `whois` of the port tells them apart from + /// the node's own processes. + #[test] + fn a_peer_relayed_through_loopback_is_judged_like_any_other() { + let owner = Owner { + user: 21, + tagged: false, + tailnet: "ada@example.com".into(), + }; + let never = || -> Result> { panic!("no whois needed") }; + // A kernel TUN, or a Tailscale that does not say: loopback is local. + assert_eq!( + loopback_caller(Some(true), Some(&owner), never), + Caller::Local + ); + assert_eq!(loopback_caller(None, Some(&owner), never), Caller::Local); + // Userspace: whoever whois finds behind the port. + let found = |id, login: &str| { + let w = whois(id, login); + move || Ok(Some(w)) + }; + assert_eq!( + loopback_caller(Some(false), Some(&owner), found(21, "ada@example.com")), + Caller::Peer { allowed: true }, + "the owner's Mac may ask, but is not the local window" + ); + assert_eq!( + loopback_caller(Some(false), Some(&owner), found(77, "bob@example.net")), + Caller::Peer { allowed: false } + ); + assert_eq!( + loopback_caller(Some(false), None, found(21, "ada@example.com")), + Caller::Peer { allowed: false }, + "nobody to compare with while Tailscale is down" + ); + assert_eq!( + loopback_caller(Some(false), Some(&owner), || Ok(None)), + Caller::Local, + "no peer behind the port: a process on this machine" + ); + assert_eq!( + loopback_caller(Some(false), Some(&owner), || anyhow::bail!("timed out")), + Caller::Peer { allowed: false }, + "a failed lookup admits nobody" + ); + assert!(!Caller::Peer { allowed: false }.allowed()); + assert!(Caller::Local.allowed()); + + // The window keeps working, and loopback never enters the cache. + let svc = Service::new(); + let req = Request { + method: "GET".into(), + path: "/v1/status".into(), + ..Default::default() + }; + assert_eq!(svc.handle("127.0.0.1:5".parse().unwrap(), &req).status, 200); + assert!(svc.auth.lock().is_empty()); + } + + #[test] + fn a_damaged_host_toml_keeps_the_last_good_settings() { + let good = HostConfig { + power_allowed: false, + sunshine_user: "latch".into(), + sunshine_pass: "secret".into(), + ..Default::default() + }; + let mut problem = None; + let (cfg, line) = next_config( + good.clone(), + Err(anyhow::anyhow!("expected `=`")), + &mut problem, + ); + assert_eq!(cfg, good, "power stays off and the engine login stays"); + assert!(line.unwrap().contains("expected `=`")); + // The same problem every 5 s is said once. + let (cfg, line) = next_config(cfg, Err(anyhow::anyhow!("expected `=`")), &mut problem); + assert_eq!(cfg, good); + assert_eq!(line, None); + // Fixed: the file's settings, and one line to say so. + let edited = HostConfig { + power_allowed: true, + ..good.clone() + }; + let (cfg, line) = next_config(cfg, Ok(edited.clone()), &mut problem); + assert_eq!(cfg, edited); + assert!(line.is_some()); + let (_, line) = next_config(edited.clone(), Ok(edited), &mut problem); + assert_eq!(line, None); + } + #[test] fn browsers_and_rebound_hostnames_cannot_read_or_write_the_api() { let svc = Service::new(); @@ -1074,6 +1729,25 @@ mod tests { assert!(!svc.update_running.load(Ordering::Acquire)); } + #[test] + fn an_owner_can_turn_pushed_updates_off() { + let svc = Service::new(); + svc.cfg.lock().accept_updates = false; + let r = svc.update(LOOPBACK, &Request::default()); + if cfg!(windows) { + assert_eq!(r.status, 403, "{}", r.body); + assert!(r.body.contains("turned pushed updates off"), "{}", r.body); + } else { + assert_eq!(r.status, 400, "only a Windows PC takes pushes at all"); + } + assert!(!svc.update_running.load(Ordering::Acquire)); + } + + #[test] + fn only_a_pc_listens_for_wake_packets() { + assert_eq!(wants_wake_listener(), cfg!(windows)); + } + #[test] fn wake_evidence_requires_a_known_matching_adapter() { let svc = Service::new(); diff --git a/crates/host/src/setup.rs b/crates/host/src/setup.rs index 59863cd..c63c902 100644 --- a/crates/host/src/setup.rs +++ b/crates/host/src/setup.rs @@ -105,6 +105,46 @@ pub const ENGINE_CONF: &[(&str, &str)] = &[ ("vt_realtime", "enabled"), ]; +/// What Linux changes in [`ENGINE_CONF`]. Latch's Linux hosts are VPS +/// desktops that encode in software. +/// +/// Sunshine's X11 capture hands the encoder a picture every frame interval +/// whether or not anything moved, so a frame-rate floor above the stream's +/// rate can only re-encode the last picture: whenever a frame is done in +/// under 16.7 ms (a still desktop on an idle core), a 60 fps floor encodes +/// the same picture again before the next one arrives, up to doubling the +/// work under a 30 fps stream. 0 is the engine's own default, half the +/// stream's rate, which periodic capture never falls below. +/// +/// The viewer asks a small CPU for 30 fps (see `streamer::max_fps`): on a +/// two-core Ampere VPS Sunshine took 136% CPU for a still desktop at 60 fps +/// and 78% for a busier one at 30. That leaves room for superfast: on an Ampere core it took 17.1 ms a +/// 1080p frame of scrolling text against ultrafast's 14.4 ms, and at +/// 20 Mbps its keyframes came out at QP 21 instead of 38 (Y PSNR 35.1 dB +/// against 31.8): text stays legible after every keyframe the viewer asks +/// for, instead of smearing until the next frames repair it. +pub const LINUX_ENGINE_CONF: &[(&str, &str)] = + &[("minimum_fps_target", "0"), ("sw_preset", "superfast")]; + +/// [`ENGINE_CONF`], with [`LINUX_ENGINE_CONF`] in place when `linux`. +pub fn engine_conf_for(linux: bool) -> Vec<(&'static str, &'static str)> { + ENGINE_CONF + .iter() + .map(|&(k, v)| { + let linux_value = LINUX_ENGINE_CONF + .iter() + .find(|(n, _)| linux && *n == k) + .map(|&(_, v)| v); + (k, linux_value.unwrap_or(v)) + }) + .collect() +} + +/// The engine keys for this machine's platform. +pub fn engine_conf() -> Vec<(&'static str, &'static str)> { + engine_conf_for(cfg!(target_os = "linux")) +} + /// The only streamable app Latch launches. Upstream ships extra entries /// (Steam, a low-res desktop) that would show as a second product. pub const DESKTOP_APPS_JSON: &str = r#"{ @@ -127,21 +167,26 @@ fn conf_key(line: &str) -> Option<&str> { (!k.is_empty()).then_some(k) } -/// Upsert `ENGINE_CONF` into an existing sunshine.conf. Known keys are -/// replaced in place (first occurrence wins, later copies of those keys -/// dropped); everything else is kept. Sunshine's parser uses `emplace`, so -/// a naive append would leave an old `system_tray = enabled` in force. +/// Upsert this platform's [`engine_conf`] into an existing sunshine.conf. +/// Known keys are replaced in place (first occurrence wins, later copies of +/// those keys dropped); everything else is kept. Sunshine's parser uses +/// `emplace`, so a naive append would leave an old `system_tray = enabled` +/// in force. pub fn conceal_conf(existing: &str) -> String { + conceal_conf_with(existing, &engine_conf()) +} + +fn conceal_conf_with(existing: &str, conf: &[(&str, &str)]) -> String { let existing = existing.strip_prefix('\u{feff}').unwrap_or(existing); - let mut seen = [false; ENGINE_CONF.len()]; + let mut seen = vec![false; conf.len()]; let mut out = String::new(); if !existing.trim().is_empty() { for line in existing.lines() { - match conf_key(line).and_then(|k| ENGINE_CONF.iter().position(|(n, _)| *n == k)) { + match conf_key(line).and_then(|k| conf.iter().position(|(n, _)| *n == k)) { Some(i) if seen[i] => continue, Some(i) => { seen[i] = true; - let (n, v) = ENGINE_CONF[i]; + let (n, v) = conf[i]; out.push_str(n); out.push_str(" = "); out.push_str(v); @@ -154,7 +199,7 @@ pub fn conceal_conf(existing: &str) -> String { } } } - for (i, (n, v)) in ENGINE_CONF.iter().enumerate() { + for (i, (n, v)) in conf.iter().enumerate() { if !seen[i] { out.push_str(n); out.push_str(" = "); @@ -277,10 +322,23 @@ if ($needFiles -or -not $svcUp) {{ # footgun even when nothing else is running. $staging = Join-Path $env:TEMP ('latch-engine-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Force -Path $staging | Out-Null - $zip = Join-Path '{exe_dir}' '{zip}' - if (Test-Path $zip) {{ - Step "Installing the streaming engine that ships with Latch" - }} else {{ + # The archive beside the exe is used only when it is the pinned release. + # The updater replaces the exe alone, so after an engine bump the one an + # install left there is an older engine, and a copy cut short is no + # engine at all: either way the pinned download still works. + $zip = $null + $bundled = Join-Path '{exe_dir}' '{zip}' + if (Test-Path -LiteralPath $bundled) {{ + $got = $null + try {{ $got = (Get-FileHash -Algorithm SHA256 -LiteralPath $bundled).Hash.ToLower() }} catch {{ $got = "unreadable: $_" }} + if ($got -eq '{sha}') {{ + Step "Installing the streaming engine that ships with Latch" + $zip = $bundled + }} else {{ + Step " the engine archive beside Latch Host is not {tag} (got $got); downloading it instead" + }} + }} + if (-not $zip) {{ Step "Downloading the streaming engine {tag}" $json = curl.exe -sSL -A latch https://api.github.com/repos/{repo}/releases/tags/{tag} if ($LASTEXITCODE -ne 0) {{ throw "could not reach GitHub for engine release {tag} (curl exit $LASTEXITCODE)" }} @@ -312,7 +370,7 @@ if ($needFiles -or -not $svcUp) {{ foreach ($need in {required}) {{ if (-not (Test-Path (Join-Path '{engine}' $need))) {{ throw "the engine did not copy to {engine}: $need is missing" }} }} - $dir = '{engine}' + {restore_state}$dir = '{engine}' }} if (-not $dir) {{ $dir = '{engine}' }} {migrate_copy}Write-EngineConf $dir @@ -360,6 +418,7 @@ if ($needFiles -or -not $svcUp) {{ upstream = UPSTREAM_SERVICE, display = SERVICE_DISPLAY, repo = crate::streamer::REPO, + restore_state = crate::legacy::restore_retired_state_ps(crate::streamer::ENGINE_DIR), migrate_copy = migrate_copy, brand_new = if p.dry_run { "" @@ -428,7 +487,11 @@ try {{ powercfg /deviceenablewake $wakeDevice | Out-Null }} catch {{ Write-Outpu & (Join-Path $dir 'sunshine.exe') --creds '{user}' '{pass}' 2>&1 | Out-Null Pop-Location Step "Restarting the streaming service" - Get-Service | Where-Object {{ $_.Name -match '{service_match}' }} | Restart-Service -ErrorAction SilentlyContinue + # Only one that runs: a restart also starts a stopped service, and + # after a take-over this one is stopped on purpose. Started again, it + # would put a SYSTEM engine beside the user's. The take-over below + # restarts the user's engine instead. + Get-Service | Where-Object {{ $_.Name -match '{service_match}' -and $_.Status -eq 'Running' }} | Restart-Service -ErrorAction SilentlyContinue "#, user = q(p.sunshine_user), pass = q(p.sunshine_pass), @@ -467,6 +530,16 @@ function Brand-Engine($d) {{ foreach ($s in $held) {{ Start-Service -Name $s -ErrorAction SilentlyContinue }} }} }} +function Restore-ServiceEngine($why) {{ + # A take-over stops the service before it starts the user's engine, so + # one that failed part way leaves no engine at all. The service's own + # copy runs as SYSTEM: it streams the console, which beats nothing, and + # Latch Host moves it to the user once setup runs at the PC again. + Step " $why; the {display} service runs the engine" + if (Get-CimInstance Win32_Process -Filter "Name='sunshine.exe'" -ErrorAction SilentlyContinue) {{ return }} + Set-Service -Name '{service}' -StartupType Automatic -ErrorAction SilentlyContinue + Start-Service -Name '{service}' -ErrorAction SilentlyContinue +}} {conceal} Step "Latch setup started" $migrate = {migrate_flag} @@ -537,18 +610,14 @@ exit 0 take_over = if p.dry_run { String::new() } else { - // Base64 UTF-16 is the one form PowerShell takes verbatim from a - // command line: no quoting layer between this script and the - // helper, and nothing on disk a user-writable path could swap. - format!( - " Step \"Running the streaming engine as the signed-in user\"\n & powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand {}\n", - encoded_command(include_str!("../windows/take-over-engine.ps1")) - ) + take_over_ps() }, port = CONTROL_PORT, exe = q(&p.exe.display().to_string()), tcp_rule = TCP_RULE, udp_rule = UDP_RULE, + service = SERVICE, + display = SERVICE_DISPLAY, adapter = adapter, virtual_display = if p.dry_run { String::new() @@ -558,6 +627,64 @@ exit 0 ) } +/// The take-over helper as setup runs it: forced, because setup has just +/// rewritten the engine's login and settings, which an engine of the user's +/// that is already running would not read again. +fn forced_take_over() -> String { + format!( + "$LatchForce = $true\n{}", + include_str!("../windows/take-over-engine.ps1") + ) +} + +/// The last engine step of a real setup run: the engine goes to the +/// signed-in user. Run from that user's desktop session (the panel's UAC +/// prompt) the helper does it directly. Run from session 0 (an SSH or +/// WinRM shell, which has no desktop) an engine started here could neither +/// capture nor take input, so this elevated run stops the service and every +/// engine, and the user's logon task starts one in their session. Whatever +/// fails on the way ends with the service's engine running, never none. +fn take_over_ps() -> String { + format!( + r#" if ([System.Diagnostics.Process]::GetCurrentProcess().SessionId -eq 0) {{ + if (Get-ScheduledTask -TaskName '{task}' -ErrorAction SilentlyContinue) {{ + Step "Handing the streaming engine to the signed-in user's session" + foreach ($s in '{service}', '{old_service}') {{ + Set-Service -Name $s -StartupType Manual -ErrorAction SilentlyContinue + Stop-Service -Name $s -Force -ErrorAction SilentlyContinue + }} + Get-CimInstance Win32_Process -Filter "Name='sunshine.exe'" -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }} + schtasks.exe /Run /TN '{task}' | Out-Null + $userEngine = $null + $deadline = (Get-Date).AddSeconds(30) + while (-not $userEngine -and (Get-Date) -lt $deadline) {{ + Start-Sleep -Seconds 1 + $userEngine = Get-CimInstance Win32_Process -Filter "Name='sunshine.exe'" -ErrorAction SilentlyContinue | Where-Object {{ $_.SessionId -ne 0 }} | Select-Object -First 1 + }} + if ($userEngine) {{ + Step " the engine runs in session $($userEngine.SessionId)" + }} else {{ + Restore-ServiceEngine "the logon task started no engine (is anyone signed in?)" + }} + }} else {{ + Step "Leaving the streaming engine as it is: this session has no desktop to start one in. Run setup from Latch Host at the PC to move a service engine to the signed-in user" + }} + }} else {{ + Step "Running the streaming engine as the signed-in user" + # Base64 UTF-16 is the one form PowerShell takes verbatim from a + # command line: no quoting layer between this script and the + # helper, and nothing on disk a user-writable path could swap. + & powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand {helper} + if ($LASTEXITCODE -ne 0) {{ Restore-ServiceEngine "the take-over failed (exit $LASTEXITCODE)" }} + }} +"#, + task = crate::audio::ENGINE_TASK, + service = SERVICE, + old_service = crate::legacy::WINDOWS_SERVICE, + helper = encoded_command(&forced_take_over()), + ) +} + /// Ask Windows for an administrator token, then run [`run_as_admin`] in a /// new process. The script is generated only after elevation, so a user- /// writable `setup.ps1` cannot be swapped during the UAC prompt. On macOS @@ -570,6 +697,21 @@ pub fn run(p: &Plan<'_>) -> Result<()> { #[cfg(windows)] { use std::os::windows::process::CommandExt; + static WARNED: parking_lot::Mutex> = + parking_lot::Mutex::new(None); + let viewer = crate::streamer::running() + && crate::streamer::find() + .and_then(|i| crate::streamer::log_text(&i)) + .is_some_and(|log| viewer_in_log(&log)); + { + let now = std::time::Instant::now(); + let mut warned = WARNED.lock(); + if refuse_during_stream(viewer, *warned, now) { + *warned = Some(now); + anyhow::bail!(SETUP_DURING_STREAM); + } + *warned = None; + } // The logon helper lives in this user's profile and runs as this // user, so the unelevated panel installs it. let _ = crate::audio::install_helpers(); @@ -601,6 +743,36 @@ pub fn run(p: &Plan<'_>) -> Result<()> { } } +/// What a setup click gets while a viewer is connected. Windows shows the +/// administrator prompt on the secure desktop, which an engine running as +/// the user cannot capture or click, so a viewer would see the stream +/// freeze until someone at the PC answers. The second click within +/// [`SETUP_CONFIRM_WINDOW`] runs setup anyway. +pub const SETUP_DURING_STREAM: &str = "a stream is running, so setup did not start: Windows asks for an administrator on this PC's own screen, which a stream can neither show nor answer, and setup restarts the streaming engine, which ends the stream. Click again within two minutes to run it anyway with someone at the PC, or run it when nobody is streaming"; +#[cfg_attr(not(windows), allow(dead_code))] +const SETUP_CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_secs(120); + +/// Whether the engine's log says a viewer is connected now (the same reading +/// as [`crate::streamer::viewer_connected_in`]; a log that says nothing +/// counts as no viewer). The engine starts a new log each time it starts, +/// and the caller also asks whether it is listening, so a connect left by +/// an engine that died mid-stream does not count. +#[cfg_attr(not(windows), allow(dead_code))] +fn viewer_in_log(log: &str) -> bool { + crate::streamer::viewer_connected_in(log).unwrap_or(false) +} + +/// Whether a setup click is turned down: a viewer is connected, and this is +/// not the second click within [`SETUP_CONFIRM_WINDOW`] of the warning. +#[cfg_attr(not(windows), allow(dead_code))] +fn refuse_during_stream( + viewer: bool, + warned: Option, + now: std::time::Instant, +) -> bool { + viewer && !warned.is_some_and(|at| now.duration_since(at) < SETUP_CONFIRM_WINDOW) +} + /// Run a read-only PowerShell probe and return what it printed. #[cfg(windows)] pub(crate) fn powershell(script: &str) -> Result { @@ -686,6 +858,13 @@ pub fn run_as_admin() -> Result<()> { { use std::os::windows::process::CommandExt; let exe = std::env::current_exe().context("own path")?; + // Over SSH or WinRM this runs in session 0, which has no desktop to + // start the engine in: the script hands it to the logon task, so + // this user needs one. From the panel the unelevated side has + // already installed it, for the right user (see `run`). + if crate::audio::session_id() == 0 { + let _ = crate::audio::install_helpers(); + } let cfg = crate::config::HostConfig::load(); let wake = crate::wake::probe(); let install = crate::streamer::find(); @@ -849,7 +1028,7 @@ mod tests { fn encoded_command_is_base64_of_utf16le() { // "Hi" in UTF-16LE is 48 00 69 00. assert_eq!(encoded_command("Hi"), "SABpAA=="); - let helper = encoded_command(include_str!("../windows/take-over-engine.ps1")); + let helper = encoded_command(&forced_take_over()); assert!(helper.len() < 30_000, "must fit a Windows command line"); assert!(helper .bytes() @@ -1110,6 +1289,7 @@ mod tests { "after the engine, before the power steps" ); assert!(s.contains("@(3024,1964)"), "{s}"); + assert!(s.contains("@(1280,720)"), "{s}"); assert!(s.contains("pnputil /restart-device"), "{s}"); let dry = script(&Plan { dry_run: true, @@ -1283,6 +1463,29 @@ system_tray = enabled assert_eq!(conceal_conf(&got), got); } + #[test] + fn linux_engines_get_no_frame_rate_floor_and_others_keep_theirs() { + fn get(conf: &[(&'static str, &'static str)], key: &str) -> Option<&'static str> { + conf.iter().find(|(k, _)| *k == key).map(|&(_, v)| v) + } + for (k, _) in LINUX_ENGINE_CONF { + assert!(get(ENGINE_CONF, k).is_some(), "{k} overrides nothing"); + } + let linux = engine_conf_for(true); + let other = engine_conf_for(false); + assert_eq!(linux.len(), ENGINE_CONF.len()); + assert_eq!(other, ENGINE_CONF); + assert_eq!(get(&linux, "minimum_fps_target"), Some("0")); + assert_eq!(get(&other, "minimum_fps_target"), Some("60")); + assert_eq!(get(&linux, "sw_preset"), Some("superfast")); + assert_eq!(get(&other, "sw_preset"), Some("ultrafast")); + assert_eq!(get(&linux, "sw_tune"), Some("zerolatency")); + let conf = conceal_conf_with("minimum_fps_target = 60\ncapture = x11\n", &linux); + assert_eq!(key_hits(&conf, "minimum_fps_target"), 1, "{conf}"); + assert!(conf.contains("minimum_fps_target = 0"), "{conf}"); + assert!(conf.contains("capture = x11"), "{conf}"); + } + #[test] fn the_engine_conf_is_written_before_the_service_starts() { let exe = PathBuf::from(r"C:\x\latch-host.exe"); @@ -1378,6 +1581,232 @@ system_tray = enabled !dry.contains("-EncodedCommand "), "dry-run must not take over the live engine:\n{dry}" ); + assert!( + s.contains(&format!( + "-EncodedCommand {}", + encoded_command(&forced_take_over()) + )), + "setup forces the helper, which reloads the new login:\n{s}" + ); + assert!(forced_take_over().starts_with("$LatchForce = $true\n")); + let after = take + "-EncodedCommand ".len() + encoded_command(&forced_take_over()).len(); + let checked = s[after..] + .find("if ($LASTEXITCODE -ne 0) { Restore-ServiceEngine") + .expect("a failed take-over leaves the service's engine running"); + assert!(checked < 40, "checked right after the helper:\n{s}"); + } + + #[test] + fn a_repair_restarts_only_a_running_service() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); + let s = script(&plan(&exe, false)); + let restart = s + .lines() + .find(|l| l.contains("Restart-Service")) + .expect("creds restart"); + assert!( + restart.contains("-and $_.Status -eq 'Running' }"), + "a stopped service started again runs a SYSTEM engine beside the user's:\n{restart}" + ); + } + + #[test] + fn run_from_session_zero_the_engine_goes_to_the_logon_task_not_this_session() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); + let s = code(&script(&plan(&exe, false))); + let guard = find_in_body( + &s, + "if ([System.Diagnostics.Process]::GetCurrentProcess().SessionId -eq 0) {", + ) + .expect("session 0 is told apart"); + let task = find_in_body( + &s, + &format!( + "Get-ScheduledTask -TaskName '{}'", + crate::audio::ENGINE_TASK + ), + ) + .expect("the logon task is looked for"); + let stop = find_in_body(&s, "Stop-Service -Name $s -Force").expect("stop"); + let run = find_in_body( + &s, + &format!("schtasks.exe /Run /TN '{}'", crate::audio::ENGINE_TASK), + ) + .expect("the task starts the engine"); + let wait = find_in_body(&s, "Where-Object { $_.SessionId -ne 0 }").expect("wait"); + let fallback = + find_in_body(&s, "Restore-ServiceEngine \"the logon task").expect("fallback"); + let helper = find_in_body(&s, "-EncodedCommand ").expect("helper"); + assert!( + guard < task && task < stop && stop < run && run < wait && wait < fallback, + "the elevated run stops what the limited task cannot, then waits for its engine:\n{s}" + ); + assert!(fallback < helper, "the helper is the other branch:\n{s}"); + let restore = s + .find("function Restore-ServiceEngine($why) {") + .expect("defined in the preamble"); + assert!(restore < guard); + let body = &s[restore..guard]; + assert!( + body.contains("if (Get-CimInstance Win32_Process -Filter \"Name='sunshine.exe'\"") + && body.contains(&format!("Start-Service -Name '{SERVICE}'")), + "the service comes back only when no engine runs:\n{body}" + ); + } + + #[test] + fn the_helper_never_acts_from_session_zero_and_weighs_every_engine() { + let h = code(include_str!("../windows/take-over-engine.ps1")); + let not_here = h + .find(r#"if ($mySession -eq 0) { Write-Output "NOT_INTERACTIVE"; exit 2 }"#) + .expect("session 0 guard"); + for change in [ + "Set-Service", + "Stop-Service", + "Stop-Process", + "MethodName Create", + ] { + let at = h.find(change).expect(change); + assert!(not_here < at, "{change} before the session-0 guard:\n{h}"); + } + assert!( + h.contains("$_.User -ine $me -or $_.Session -ne $mySession"), + "an engine of this user's in another session is not ours:\n{h}" + ); + assert!( + h.contains("if ($all.Count -eq 0 -or $foreign.Count -gt 0 -or (Test-ServiceUp) -or $LatchForce)"), + "{h}" + ); + assert!( + !h.contains("Select-Object -First 1\n}"), + "no first-listed-only owner check:\n{h}" + ); + // Without rights to stop the SYSTEM engine, the user's own one stays. + let foreign_left = h.find("$left = @(Get-ForeignEngines)").expect("re-check"); + let own_killed = h.find("$own = @(Get-EngineProcesses)").expect("own"); + assert!(foreign_left < own_killed, "{h}"); + // C# is compiled only when there is a device to set. + let none = h.find("NO_ACTIVE_RENDER").expect("no device exit"); + let compile = h.find("Add-Type").expect("compile"); + assert!(none < compile, "{h}"); + } + + #[test] + fn a_stale_bundled_archive_falls_back_to_the_pinned_download() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); + let s = code(&script(&plan(&exe, true))); + let bundled = s + .find("$got = (Get-FileHash -Algorithm SHA256 -LiteralPath $bundled)") + .expect("the bundled archive is hashed first"); + let used = s.find("$zip = $bundled").expect("used only on a match"); + let download = s + .find("if (-not $zip) {") + .expect("download guarded by no archive"); + let url = s.find(&format!("releases/tags/{ENGINE_TAG}")).expect("url"); + let last = s + .rfind("Get-FileHash -Algorithm SHA256 -LiteralPath $zip") + .expect("final check"); + let expand = s.find("Expand-Archive").expect("expand"); + assert!(bundled < used && used < download && download < url, "{s}"); + assert!( + url < last && last < expand, + "every archive is checked before unpacking:\n{s}" + ); + assert!( + !s[bundled..download].contains("throw"), + "a wrong bundled archive is skipped, not fatal:\n{s}" + ); + } + + #[test] + fn a_fresh_engine_gets_the_pairing_state_an_unmoved_old_one_kept() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); + let s = code(&script(&plan(&exe, true))); + let retire = s + .find("$retiredState = $null") + .expect("reset by the retire step"); + let copied = s.find("the engine did not copy to").expect("copy check"); + let restore = s.find("if ($retiredState -and").expect("state copy"); + let conf = s[copied..].find("Write-EngineConf $dir").expect("conf") + copied; + let register = s.find("New-Service").expect("register"); + assert!( + retire < copied && copied < restore && restore < conf && conf < register, + "{s}" + ); + } + + #[test] + fn a_viewer_is_connected_until_its_session_ends_either_way() { + assert!(!viewer_in_log("")); + assert!(!viewer_in_log("[x]: Info: Sunshine version: 2026.1014\n")); + let on = "[a]: Info: New streaming session started [active sessions: 1]\n\ + [b]: Info: CLIENT CONNECTED\n\ + [c]: Info: Opus initialized: 48 kHz, 2 channels\n"; + assert!(viewer_in_log(on)); + assert!(!viewer_in_log(&format!( + "{on}[d]: Info: CLIENT DISCONNECTED\n" + ))); + // stuzha, 2026-10-17: a viewer that went away logs a timeout and no + // disconnect. + assert!(!viewer_in_log(&format!( + "{on}[d]: Info: 100.111.100.57: Ping Timeout\n[e]: Info: Setting default sink to: [auto_null]\n" + ))); + assert!(viewer_in_log(&format!( + "{on}[d]: Info: 100.111.100.57: Ping Timeout\n{on}" + ))); + } + + #[test] + fn setup_during_a_stream_needs_a_second_click() { + use std::time::{Duration, Instant}; + let now = Instant::now(); + assert!(!refuse_during_stream(false, None, now)); + assert!(refuse_during_stream(true, None, now)); + assert!(!refuse_during_stream( + true, + Some(now), + now + Duration::from_secs(30) + )); + assert!(refuse_during_stream( + true, + Some(now), + now + SETUP_CONFIRM_WINDOW + Duration::from_secs(1) + )); + // The panel turns an error that mentions "declined" into the UAC + // decline text, which would hide this one. + assert!(!SETUP_DURING_STREAM.to_lowercase().contains("declined")); + } + + #[test] + fn install_host_brings_the_service_back_in_the_signed_in_session() { + let s = code(include_str!("../../../scripts/install-host.ps1")); + let stop = s + .find("Stop-Process -Force") + .expect("the old host is stopped"); + let branch = s + .find("if ((Get-Process -Id $PID).SessionId -eq 0) {") + .expect("session 0 is told apart"); + let task = &s[branch..]; + assert!(stop < branch); + for need in [ + "-LogonType Interactive", + "-RunLevel Limited", + "-ExecutionTimeLimit ([TimeSpan]::Zero)", + "-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries", + r#"-Execute $DestExe -Argument "--background""#, + "Start-ScheduledTask", + "/v1/status", + ] { + assert!(task.contains(need), "{need}:\n{task}"); + } + assert!(!s.contains("Highest"), "the host never runs elevated:\n{s}"); + assert!( + s.contains( + r#"} elseif ($NoStart) { + Start-Process -FilePath $DestExe -ArgumentList "--background""# + ), + "-NoStart still brings the service back:\n{s}" + ); } #[test] diff --git a/crates/host/src/streamer.rs b/crates/host/src/streamer.rs index d90ef5c..849342c 100644 --- a/crates/host/src/streamer.rs +++ b/crates/host/src/streamer.rs @@ -5,10 +5,10 @@ use anyhow::{bail, Context, Result}; use latch_core::{SUNSHINE_PORT, SUNSHINE_WEB_PORT}; -use std::net::{SocketAddr, TcpStream}; +use std::net::{IpAddr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::Duration; +use std::time::{Duration, Instant}; /// Where Latch unpacks its own copy of the streaming engine. Setup /// installs here; an engine installed by someone else stays where it is. @@ -170,6 +170,77 @@ fn read_tail(path: &std::path::Path, max: u64) -> Option { }) } +/// The size of the X screen the engine captures on Linux. Sunshine records +/// the whole root window of `$DISPLAY` and scales it to whatever size a +/// viewer asked for, so this is the one size that arrives unscaled. `None` +/// elsewhere, when no X server answers, and when the desktop resizes itself +/// to each viewer (`LATCH_DISPLAY_FOLLOWS_VIEWER=1`: a prep command switches +/// the X screen to the size asked for), so viewers ask for their own size. +#[cfg(target_os = "linux")] +pub fn capture_size() -> Option<[u32; 2]> { + use x11rb::connection::Connection; + if display_follows_viewer() { + return None; + } + let (conn, screen) = x11rb::connect(None).ok()?; + let root = conn.setup().roots.get(screen)?; + Some([root.width_in_pixels.into(), root.height_in_pixels.into()]) +} + +#[cfg(not(target_os = "linux"))] +pub fn capture_size() -> Option<[u32; 2]> { + None +} + +/// The desktop resizes itself to each viewer (`LATCH_DISPLAY_FOLLOWS_VIEWER=1`): +/// the engine's prep command sets the size a viewer asks for, and its undo +/// puts the idle size back when the session ends. +pub fn display_follows_viewer() -> bool { + std::env::var_os("LATCH_DISPLAY_FOLLOWS_VIEWER").is_some_and(|v| v == "1") +} + +/// How long a session nobody watches stays open on a desktop that follows +/// its viewer. A viewer back within this resumes where they were; after it +/// the session ends, so the prep command's undo puts the desktop back at its +/// idle size instead of leaving it at the last viewer's (a 4K desktop costs +/// every program on it, and an agent driving it, more for nothing). +pub const UNWATCHED_CLOSE: Duration = Duration::from_secs(120); + +/// Whether to end an unwatched session now, and since when nobody watches. +/// `since` is what the last call returned. Only a running app with a log +/// that says the viewer left counts: unknown never ends anything. +pub(crate) fn unwatched_close( + session: &Session, + since: Option, + now: Instant, +) -> (bool, Option) { + if session.app_running == Some(true) && session.viewer == Some(false) { + let since = since.unwrap_or(now); + if now.duration_since(since) >= UNWATCHED_CLOSE { + (true, None) + } else { + (false, Some(since)) + } + } else { + (false, None) + } +} + +/// The frame rate the engine keeps up with here at 1080p, 0 for no known +/// limit: 30 when it encodes in software on fewer than four cores (a viewer +/// asking for a smaller picture raises it in proportion). On a two-core +/// Ampere VPS, desktop sessions asked for at 60 fps averaged 33 to 47 +/// encoded frames a second by Sunshine's own per-session counts; Sunshine +/// took 136% CPU streaming a still desktop at 60 fps and 78% streaming a +/// busier one at 30, leaving the desktop itself the rest of two cores. +pub fn max_fps(encoder: &str, cores: usize) -> u32 { + if encoder == "software" && cores < 4 { + 30 + } else { + 0 + } +} + pub fn find() -> Option { #[cfg(windows)] { @@ -215,6 +286,17 @@ pub fn start(install: &Install) -> Result<()> { if running() { return Ok(()); } + if let Some(unit) = crate::unix_setup::engine_unit() { + // systemd's engine (it stopped, or someone stopped it): start + // the unit, with its environment, rather than a copy beside it. + // --no-block: the unit may wait for the desktop first. + let started = Command::new("systemctl") + .args(["--user", "start", "--no-block", unit]) + .status() + .context("systemctl --user start")?; + anyhow::ensure!(started.success(), "systemctl --user start {unit} failed"); + return Ok(()); + } let conf = install.conf(); let mut c = Command::new(install.exe()); if conf.exists() { @@ -302,7 +384,9 @@ impl Api<'_> { fn request(&self, method: &str, path: &str, body: Option<&str>) -> Result { let curl = if cfg!(windows) { "curl.exe" } else { "curl" }; let mut c = Command::new(curl); - c.args(["-sk", "--max-time", "8", "--config", "-"]) + // -S keeps curl's own error message under -s: without it every + // failure read "curl: ", whatever went wrong. + c.args(["-sSk", "--max-time", "8", "--config", "-"]) .args(["-X", method, "-H", "Content-Type: application/json"]) .arg(format!("https://localhost:{SUNSHINE_WEB_PORT}{path}")); if let Some(b) = body { @@ -340,7 +424,7 @@ impl Api<'_> { let out = child.wait_with_output().context("wait for curl")?; written?; if !out.status.success() { - bail!("curl: {}", String::from_utf8_lossy(&out.stderr).trim()); + bail!("{}", curl_failure(out.status.code(), &out.stderr)); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } @@ -350,55 +434,129 @@ impl Api<'_> { self.call("GET", "/api/apps", None).is_ok() } - pub fn session_active(&self) -> bool { - self.call("GET", "/api/apps", None) - .ok() - .is_some_and(|v| session_listed(&v)) + /// Migrate managed installations once, without interrupting a viewer. + /// Applying the profile restarts the engine, which ends whatever app is + /// launched; one nobody watches (the Desktop a viewer left to resume) is + /// closed first, so its undo commands run, and the next viewer starts + /// it again. + pub fn upgrade_stream_profile(&self, install: Option<&Install>) -> Result { + let current = self.call("GET", "/api/config", None)?; + let desired = stream_profile(¤t, &crate::setup::engine_conf())?; + if desired == current { + return Ok(true); + } + let s = session(install); + if s.maybe_watched() { + return Ok(false); + } + if s.app_running == Some(true) { + self.close_app(); + } + self.apply_config(&desired)?; + Ok(true) } - /// Migrate managed installations once, without interrupting a running app. - pub fn upgrade_stream_profile(&self) -> Result { - let apps = self.call("GET", "/api/apps", None)?; - if session_listed(&apps) { - return Ok(false); + /// Keep the engine on the display [`crate::virtual_display::capture`] + /// picks, without interrupting a running app. Only the keys it names + /// are written, so engine settings the owner chose stay. `vdd` is the + /// Virtual Display Driver's ID from the engine's log, `picked` the ID + /// Latch set before. `None` while someone may be watching; otherwise what is now in + /// force. + pub fn follow_virtual_display( + &self, + install: Option<&Install>, + vdd: Option<&str>, + picked: Option<&str>, + ) -> Result> { + if session(install).maybe_watched() { + return Ok(None); } let current = self.call("GET", "/api/config", None)?; - let desired = stream_profile(¤t)?; - if desired != current { - self.call("POST", "/api/config", Some(&desired.to_string()))?; - // Sunshine closes this HTTP connection as it restarts. - let _ = self.raw("POST", "/api/restart"); - std::thread::sleep(Duration::from_secs(1)); - let deadline = std::time::Instant::now() + Duration::from_secs(20); - loop { - if self.ok() { - break; - } - if std::time::Instant::now() >= deadline { - bail!("streaming engine did not return after applying the display profile"); - } - std::thread::sleep(Duration::from_millis(500)); + let capture = crate::virtual_display::capture(¤t, vdd, picked); + if !capture.conf.is_empty() { + let desired = stream_profile(¤t, &capture.pairs())?; + // The restart would end a stream that started since the check + // above; a Desktop left for a viewer to resume is closed first, + // as in upgrade_stream_profile, so its undo commands run. + let s = session(install); + if s.maybe_watched() { + return Ok(None); + } + if s.app_running == Some(true) { + self.close_app(); } + self.apply_config(&desired)?; + } + Ok(Some(capture)) + } + + /// Save `config` as the engine's settings and wait for it to come back + /// from the restart that puts them in force. + fn apply_config(&self, config: &serde_json::Value) -> Result<()> { + self.call("POST", "/api/config", Some(&config.to_string()))?; + // Sunshine closes this HTTP connection as it restarts. + let _ = self.raw("POST", "/api/restart"); + std::thread::sleep(Duration::from_secs(1)); + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + if self.ok() { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + bail!("streaming engine did not return after applying the display profile"); + } + std::thread::sleep(Duration::from_millis(500)); } - Ok(true) } /// Accept the PIN the Mac is pairing with. Sunshine says no until the /// Mac has actually started pairing, so callers retry. /// - /// Releases up to 2026.5 take `{pin, name}`; later ones list the waiting - /// requests on `GET /api/pin` and want the request's `pairing_id` too. - pub fn submit_pin(&self, pin: &str, name: &str) -> Result<()> { - let pending = pending_pairings(self.call("GET", "/api/pin", None).ok()); - for id in pending { - let mut body = serde_json::json!({ "pin": pin, "name": name }); - if let Some(id) = id { - body["pairing_id"] = id.into(); - } - let v = self.call("POST", "/api/pin", Some(&body.to_string()))?; - if v["status"].as_bool() == Some(true) || v["status"].as_str() == Some("true") { - return Ok(()); + /// Releases up to 2026.5 take `{pin, name}` and answer at once; later + /// ones list the waiting requests on `GET /api/pin`, want the request's + /// `pairing_id` too, and from October 2026 keep the post open until the + /// Mac has finished the handshake. The Mac only goes on with it once + /// this has answered, so a post still open after [`PIN_TAKEN_AFTER`] + /// is left to finish on its own and reported as [`Pin::Taken`]. + /// + /// The PIN goes to the request that came from `peer` (or, where every + /// address looks local, from a machine called `name`), never simply to + /// the first one listed: that may be another device's, or an abandoned + /// one, and handing it this PIN fails both. + pub fn submit_pin(&self, pin: &str, name: &str, peer: IpAddr) -> Result { + let listed = self.call("GET", "/api/pin", None).ok(); + let id = match choose_pairing(listed.as_ref(), peer, name) { + Pairing::Unlisted => None, + Pairing::Id(id) => Some(id), + Pairing::NotYet => return Ok(Pin::NotYet), + }; + let mut body = serde_json::json!({ "pin": pin, "name": name }); + if let Some(id) = &id { + body["pairing_id"] = id.as_str().into(); + } + let (tx, rx) = std::sync::mpsc::channel(); + let (user, pass) = (self.user.to_string(), self.pass.to_string()); + std::thread::spawn(move || { + let api = Api { + user: &user, + pass: &pass, + }; + let _ = tx.send(api.call("POST", "/api/pin", Some(&body.to_string()))); + }); + // Only an engine with pairing ids holds the post open; one without + // answers, however slowly, and curl's own time limit bounds that. + let reply = if id.is_some() { + match rx.recv_timeout(PIN_TAKEN_AFTER) { + Ok(reply) => reply, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(Pin::Taken), + Err(e) => bail!("PIN post: {e}"), } + } else { + rx.recv().context("PIN post")? + }; + let v = reply?; + if v["status"].as_bool() == Some(true) || v["status"].as_str() == Some("true") { + return Ok(Pin::Paired); } bail!("the streaming engine did not accept the PIN (is the Mac pairing right now?)") } @@ -434,34 +592,107 @@ impl Api<'_> { } } -/// The pairing ids `GET /api/pin` lists on a Sunshine master build. Every -/// other reply (a release Sunshine answers 200 `{"error":"Not Found"}`, an -/// old one 404, curl failing) means "no ids": still send the PIN once -/// without one, which is what those versions want. -fn pending_pairings(reply: Option) -> Vec> { - let ids: Vec> = reply - .as_ref() - .and_then(|v| v["pairings"].as_array()) - .map(|a| { - a.iter() - .filter_map(|p| p["id"].as_str().map(|s| Some(s.to_string()))) - .collect() +/// What became of a PIN handed to the engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pin { + /// The engine says the Mac is paired. + Paired, + /// The engine took the PIN and waits for the Mac to finish the + /// handshake, which it does once it hears the PIN was entered. + Taken, + /// The engine lists the requests waiting for a PIN, and none is this + /// Mac's yet: it asks a moment after it starts pairing, and a slow path + /// can bring the PIN first. Nothing was sent; the Mac tries again. + NotYet, +} + +/// How long a PIN post may stay open before it counts as taken. An engine +/// that turns a PIN down (or one that never holds the post) answers well +/// within this; one that took it waits for the Mac. +pub const PIN_TAKEN_AFTER: Duration = Duration::from_millis(600); + +/// Which of the engine's waiting pairing requests a PIN is for. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Pairing { + /// The engine keeps no list (a release Sunshine answers 200 + /// `{"error":"Not Found"}`, an old one 404, or curl failed): send the + /// PIN once without an id, which is what those versions want. + Unlisted, + /// The request with this id. + Id(String), + /// The engine lists requests, but none is this machine's: an empty + /// list included, since 2026.10 turns down a PIN without an id. + NotYet, +} + +/// Pick the request `GET /api/pin` lists for the machine at `peer` called +/// `name`. Sunshine lists each as `{id, name, address}`, oldest first, and +/// keeps one for five minutes after its device gave up. The address decides +/// when there is one to go by; a local peer (the window, or every peer of a +/// Docker node, which reaches the engine through loopback too) falls back +/// to the name. A request from another address or under another name is +/// never picked: this machine's own may simply not have arrived yet. +fn choose_pairing(reply: Option<&serde_json::Value>, peer: IpAddr, name: &str) -> Pairing { + let Some(list) = reply.and_then(|v| v["pairings"].as_array()) else { + return Pairing::Unlisted; + }; + struct Entry<'a> { + id: &'a str, + name: Option<&'a str>, + address: Option, + } + let entries: Vec = list + .iter() + .filter_map(|p| { + Some(Entry { + id: p["id"].as_str().filter(|id| !id.is_empty())?, + name: p["name"].as_str().filter(|n| !n.is_empty()), + address: p["address"].as_str().and_then(|a| { + a.trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .ok() + .map(|ip| ip.to_canonical()) + }), + }) }) - .unwrap_or_default(); - if ids.is_empty() { - vec![None] - } else { - ids + .collect(); + let peer = peer.to_canonical(); + let one = |found: Vec<&Entry>| match found.as_slice() { + [only] => Some(Pairing::Id(only.id.to_string())), + _ => None, + }; + let named = |e: &&Entry| e.name == Some(name); + if !peer.is_loopback() { + let from_peer: Vec<&Entry> = entries.iter().filter(|e| e.address == Some(peer)).collect(); + if from_peer.len() > 1 { + return one(from_peer.into_iter().filter(named).collect()).unwrap_or(Pairing::NotYet); + } + if let Some(found) = one(from_peer) { + return found; + } + } + // By name, among the requests that do not plainly come from elsewhere. + let local = |e: &&Entry| e.address.is_none_or(|a| a.is_loopback() || a == peer); + if let Some(found) = one(entries.iter().filter(local).filter(named).collect()) { + return found; + } + // A build that lists bare ids: the only one there is. + match entries.as_slice() { + [only] if only.name.is_none() && only.address.is_none() => Pairing::Id(only.id.to_string()), + _ => Pairing::NotYet, } } -/// Preserve unrelated engine settings; metadata from GET is never written. -fn stream_profile(current: &serde_json::Value) -> Result { +/// `current` with the keys of `conf` (this platform's +/// [`crate::setup::engine_conf`]) in force. Unrelated engine settings are +/// kept; metadata from GET is never written. +fn stream_profile(current: &serde_json::Value, conf: &[(&str, &str)]) -> Result { let mut config = current .as_object() .context("engine config is not an object")? .clone(); - let changed = crate::setup::ENGINE_CONF + let changed = conf .iter() .any(|(k, v)| config.get(*k).and_then(|v| v.as_str()) != Some(*v)); if !changed { @@ -470,12 +701,25 @@ fn stream_profile(current: &serde_json::Value) -> Result { for k in ["status", "platform", "version"] { config.remove(k); } - for (k, v) in crate::setup::ENGINE_CONF { + for (k, v) in conf { config.insert((*k).into(), (*v).into()); } Ok(config.into()) } +/// What a failed curl run says: its own message, which names its exit code +/// ("curl: (7) Failed to connect…", "curl: (28) Operation timed out…"), or +/// the code alone when it printed nothing. +fn curl_failure(code: Option, stderr: &[u8]) -> String { + let msg = String::from_utf8_lossy(stderr); + let msg = msg.trim(); + match code { + _ if !msg.is_empty() => msg.to_string(), + Some(code) => format!("curl exited with code {code}"), + None => "curl was stopped".into(), + } +} + /// Sunshine's config carries its own web login and the pairing secrets. /// Display diagnostics travel to the Mac, so those keys never leave the PC. fn is_secret(key: &str) -> bool { @@ -512,20 +756,91 @@ fn parse_reply(text: &str) -> Result { Ok(v) } -pub fn session_listed(v: &serde_json::Value) -> bool { - match v.get("current_app") { - Some(serde_json::Value::String(s)) if !s.is_empty() => return true, - Some(serde_json::Value::Number(n)) if n.as_u64().is_some_and(|n| n > 0) => return true, - _ => {} +/// What the engine says about a stream right now. An app being launched is +/// not someone watching: a viewer that disconnects leaves its Desktop +/// running, to be resumed, until the engine restarts or something closes +/// it (Sunshine treats a Desktop with no command as a placebo that never +/// ends by itself). Only the engine's log says whether a viewer is there. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Session { + /// An app is launched (`currentgame` in `/serverinfo`); `None` when the + /// engine did not answer. + pub app_running: Option, + /// The last line of the engine's log that starts or ends a session is + /// a viewer connecting; `None` when the log has neither. + pub viewer: Option, +} + +impl Session { + /// Someone may be watching, counting what is unknown as yes: what + /// restarting the engine (which ends any stream) has to wait for. + pub fn maybe_watched(&self) -> bool { + self.app_running.unwrap_or(true) && self.viewer.unwrap_or(true) + } + + /// The log says a viewer is connected and the engine does not say + /// nothing runs. Swapping Latch's own executable never touches the + /// engine, so only this defers an update; an unknown never blocks one + /// forever. + pub fn watched(&self) -> bool { + self.viewer == Some(true) && self.app_running != Some(false) } - v.get("apps") - .and_then(|a| a.as_array()) - .is_some_and(|apps| { - apps.iter().any(|a| { - a.get("running").and_then(|x| x.as_bool()) == Some(true) - || a.get("current").and_then(|x| x.as_bool()) == Some(true) - }) - }) +} + +/// Ask the engine whether an app runs, and read its log for a viewer. +pub fn session(install: Option<&Install>) -> Session { + let app_running = latch_core::http::request( + ("127.0.0.1", SUNSHINE_PORT), + "GET", + "/serverinfo", + None, + Duration::from_secs(2), + ) + .ok() + .filter(|r| r.status == 200) + .and_then(|r| session_in_serverinfo(&r.body)); + let viewer = install + .and_then(log_text) + .as_deref() + .and_then(viewer_connected_in); + Session { + app_running, + viewer, + } +} + +/// Whether a viewer is connected, from the engine's log: the last line that +/// starts a session (`CLIENT CONNECTED`) or ends one decides. A session ends +/// with `CLIENT DISCONNECTED` when the viewer quits, but only with `: +/// Ping Timeout` when it vanished (a Mac that slept, Wi-Fi that dropped). +/// `None` when the log holds neither, as after the engine restarted (it +/// rewrites its log at every start). +pub(crate) fn viewer_connected_in(log: &str) -> Option { + // "Initial Ping Timeout" (a viewer that never got going) ends one too. + const ENDED: [&str; 2] = ["CLIENT DISCONNECTED", "Ping Timeout"]; + log.lines().rev().find_map(|line| { + if ENDED.iter().any(|k| line.contains(k)) { + Some(false) + } else if line.contains("CLIENT CONNECTED") { + Some(true) + } else { + None + } + }) +} + +/// Whether the GameStream `/serverinfo` XML says an app is running: its +/// `currentgame` is the running app's id, 0 when nothing streams. `None` +/// when the reply does not say. +pub(crate) fn session_in_serverinfo(xml: &str) -> Option { + xml.split_once("")? + .1 + .split_once("")? + .0 + .trim() + .parse::() + .ok() + .map(|id| id != 0) } fn parse_clients(v: &serde_json::Value) -> Vec<(String, String)> { @@ -548,11 +863,48 @@ fn parse_clients(v: &serde_json::Value) -> Vec<(String, String)> { mod tests { use super::*; + #[test] + fn an_unwatched_session_ends_only_after_the_grace_and_only_when_known() { + let t0 = Instant::now(); + let left = Session { + app_running: Some(true), + viewer: Some(false), + }; + // The clock starts when the viewer is first seen gone. + assert_eq!(unwatched_close(&left, None, t0), (false, Some(t0))); + let soon = t0 + Duration::from_secs(60); + assert_eq!(unwatched_close(&left, Some(t0), soon), (false, Some(t0))); + let late = t0 + UNWATCHED_CLOSE; + assert_eq!(unwatched_close(&left, Some(t0), late), (true, None)); + // A viewer back, no app, or a log that says nothing: nothing ends + // and the clock starts again. + for s in [ + Session { + app_running: Some(true), + viewer: Some(true), + }, + Session { + app_running: Some(false), + viewer: Some(false), + }, + Session { + app_running: None, + viewer: Some(false), + }, + Session { + app_running: Some(true), + viewer: None, + }, + ] { + assert_eq!(unwatched_close(&s, Some(t0), late), (false, None), "{s:?}"); + } + } + #[test] fn streaming_profile_replaces_caps_and_fixed_modes_without_losing_other_settings() { let original = serde_json::json!({"status":true,"version":"x", "platform":"windows", "encoder":"amdvce", "max_bitrate":"1000", "dd_resolution_option":"manual", "output_name":"virtual"}); - let profile = stream_profile(&original).unwrap(); + let profile = stream_profile(&original, crate::setup::ENGINE_CONF).unwrap(); assert_eq!(profile["max_bitrate"], "0"); assert_eq!(profile["amd_rc"], "cbr"); assert_eq!(profile["amd_quality"], "speed"); @@ -565,8 +917,45 @@ mod tests { assert_eq!(profile["encoder"], "amdvce"); assert_eq!(profile["output_name"], "virtual"); assert!(profile.get("platform").is_none()); - assert_eq!(stream_profile(&profile).unwrap(), profile); - assert!(stream_profile(&serde_json::Value::Null).is_err()); + assert_eq!( + stream_profile(&profile, crate::setup::ENGINE_CONF).unwrap(), + profile + ); + assert!(stream_profile(&serde_json::Value::Null, crate::setup::ENGINE_CONF).is_err()); + } + + #[test] + fn the_capture_display_alone_leaves_every_other_engine_setting() { + // Engine settings the owner chose in its own web UI. + let owners = serde_json::json!({"status":true, "platform":"windows", "output_name":"", + "nvenc_twopass":"disabled", "minimum_fps_target":"30"}); + let capture = crate::virtual_display::capture(&owners, Some("vdd"), None); + let profile = stream_profile(&owners, &capture.pairs()).unwrap(); + assert_eq!(profile["output_name"], "vdd"); + assert_eq!(profile["dd_configuration_option"], "ensure_only_display"); + assert_eq!(profile["nvenc_twopass"], "disabled"); + assert_eq!(profile["minimum_fps_target"], "30"); + assert!(profile.get("platform").is_none()); + let again = crate::virtual_display::capture(&profile, Some("vdd"), Some("vdd")); + assert_eq!(stream_profile(&profile, &again.pairs()).unwrap(), profile); + } + + #[test] + fn a_linux_engine_drops_the_frame_rate_floor_and_reapplies_it_elsewhere() { + // The node kit's VPS profile: a 60 fps floor under every stream. + let vps = serde_json::json!({"status":true, "capture":"x11", "encoder":"software", + "minimum_fps_target":"60", "sw_preset":"ultrafast", "min_threads":"2"}); + let linux = crate::setup::engine_conf_for(true); + let profile = stream_profile(&vps, &linux).unwrap(); + assert_eq!(profile["minimum_fps_target"], "0"); + assert_eq!(profile["sw_preset"], "superfast"); + assert_eq!(profile["capture"], "x11", "the deployment's own keys stay"); + assert_eq!(profile["encoder"], "software"); + assert_eq!(stream_profile(&profile, &linux).unwrap(), profile); + let other = crate::setup::engine_conf_for(false); + let elsewhere = stream_profile(&profile, &other).unwrap(); + assert_eq!(elsewhere["minimum_fps_target"], "60"); + assert_eq!(elsewhere["sw_preset"], "ultrafast"); } #[test] @@ -602,19 +991,99 @@ mod tests { } #[test] - fn session_listed_reads_current_app_and_running_flags() { - assert!(!session_listed(&serde_json::json!({"apps": []}))); - assert!(session_listed( - &serde_json::json!({"current_app": "Desktop"}) - )); - assert!(!session_listed(&serde_json::json!({"current_app": ""}))); - assert!(session_listed(&serde_json::json!({"current_app": 1}))); - assert!(session_listed( - &serde_json::json!({"apps": [{"name": "Desktop", "running": true}]}) - )); - assert!(!session_listed( - &serde_json::json!({"apps": [{"name": "Desktop", "running": false}]}) + fn only_software_encoding_on_a_small_cpu_caps_the_frame_rate() { + assert_eq!(max_fps("software", 2), 30); + assert_eq!(max_fps("software", 1), 30); + assert_eq!(max_fps("software", 4), 0); + assert_eq!(max_fps("nvenc", 2), 0); + assert_eq!(max_fps("", 2), 0, "unknown encoder: no limit yet"); + } + + #[test] + fn serverinfo_reports_a_running_session_and_unknown_state_stays_unknown() { + assert_eq!( + session_in_serverinfo("0"), + Some(false) + ); + assert_eq!( + session_in_serverinfo("881448767"), + Some(true) + ); + assert_eq!( + session_in_serverinfo("bad"), + None + ); + assert_eq!(session_in_serverinfo(""), None); + } + + /// stuzha's own log, 2026-10-17: two sessions that ended with a ping + /// timeout (the Mac slept) and none with a disconnect line, while + /// `/serverinfo` still said Desktop was running. + const STUZHA_LOG: &str = "\ +[2026-10-17 16:32:42.610]: Info: Executing Undo Cmd: [/usr/local/bin/stuzha-display 1920 1080] +[2026-10-17 16:32:44.410]: Info: Found H.264 encoder: libx264 [software] +[2026-10-17 16:32:44.916]: Info: Executing [Desktop] +[2026-10-17 16:32:45.085]: Info: CLIENT CONNECTED +[2026-10-17 16:33:08.397]: Info: 100.111.100.57: Ping Timeout +[2026-10-17 16:36:36.882]: Info: Found H.264 encoder: libx264 [software] +[2026-10-17 16:36:37.224]: Info: CLIENT CONNECTED +[2026-10-17 16:37:28.162]: Info: 100.111.100.57: Ping Timeout +"; + + #[test] + fn a_viewer_is_connected_only_until_its_session_ends() { + let connected = "[2026-10-14 19:00:00.000]: Info: CLIENT CONNECTED\n"; + let quit = format!("{connected}[2026-10-14 19:21:54.306]: Info: CLIENT DISCONNECTED\n"); + assert_eq!(viewer_connected_in(connected), Some(true)); + assert_eq!(viewer_connected_in(&quit), Some(false)); + assert_eq!( + viewer_connected_in(&format!("{quit}{connected}")), + Some(true), + "a later session counts" + ); + assert_eq!(viewer_connected_in(STUZHA_LOG), Some(false)); + assert_eq!( + viewer_connected_in("[x]: Info: 100.64.0.5:51234: Initial Ping Timeout\n"), + Some(false) + ); + // A fresh log (the engine restarted) says nothing either way. + assert_eq!( + viewer_connected_in("[x]: Info: Executing [Desktop]\n"), + None + ); + assert_eq!(viewer_connected_in(""), None); + } + + #[test] + fn a_desktop_left_to_resume_is_nobody_watching() { + let s = |app_running, viewer| Session { + app_running, + viewer, + }; + // stuzha after the Mac slept: Desktop still launched, nobody there. + let resumable = s(Some(true), viewer_connected_in(STUZHA_LOG)); + assert!(!resumable.watched()); + assert!(!resumable.maybe_watched()); + assert!(!crate::update::refuse_self_update( + true, + resumable.watched() )); + + let live = s(Some(true), Some(true)); + assert!(live.watched() && live.maybe_watched()); + assert!(crate::update::refuse_self_update(true, live.watched())); + // The engine did not answer, but its log has a viewer: watched. + assert!(s(None, Some(true)).watched()); + // Nothing launched: whatever the log says, nobody is watching. + assert!(!s(Some(false), Some(true)).watched()); + assert!(!s(Some(false), None).maybe_watched()); + // Unknowns never block an update, which leaves the engine alone... + assert!(!s(None, None).watched()); + assert!(!s(Some(true), None).watched()); + // ...but do hold back an engine restart, which would end a stream. + assert!(s(None, None).maybe_watched()); + assert!(s(Some(true), None).maybe_watched()); + assert!(!s(None, Some(false)).maybe_watched()); } #[test] @@ -679,6 +1148,19 @@ mod tests { ); } + #[test] + fn a_failed_engine_call_says_why() { + assert_eq!( + curl_failure( + Some(7), + b"curl: (7) Failed to connect to localhost port 47990: Connection refused\n" + ), + "curl: (7) Failed to connect to localhost port 47990: Connection refused" + ); + assert_eq!(curl_failure(Some(28), b""), "curl exited with code 28"); + assert_eq!(curl_failure(None, b" "), "curl was stopped"); + } + #[test] fn replies_are_parsed_and_errors_surfaced() { assert!(parse_reply(r#"{"status":true}"#).is_ok()); @@ -690,19 +1172,87 @@ mod tests { } #[test] - fn pin_is_sent_once_without_an_id_unless_sunshine_lists_some() { + fn the_pin_goes_to_the_requesters_own_pairing() { + let mac: IpAddr = "100.100.1.2".parse().unwrap(); + let name = "Daniel\u{2019}s MacBook Pro"; + let pick = + |reply: serde_json::Value, peer: IpAddr| choose_pairing(Some(&reply), peer, name); // Release Sunshine: 200 with a "Not Found" body, no pairings key. let not_found = serde_json::json!({"error": "Not Found", "status_code": 404}); - assert_eq!(pending_pairings(Some(not_found)), vec![None]); - assert_eq!(pending_pairings(None), vec![None]); + assert_eq!(pick(not_found, mac), Pairing::Unlisted); + assert_eq!(choose_pairing(None, mac, name), Pairing::Unlisted); + // 2026.10 lists none yet: posting without an id would be refused. + assert_eq!( + pick(serde_json::json!({"pairings": []}), mac), + Pairing::NotYet + ); + + let ipad = serde_json::json!({"id": "a", "name": "iPad", "address": "100.64.0.5"}); + let ours = serde_json::json!({"id": "b", "name": name, "address": "100.100.1.2"}); + // Another device's request is listed first (oldest first). + assert_eq!( + pick(serde_json::json!({"pairings": [ipad, ours]}), mac), + Pairing::Id("b".into()) + ); + // Only someone else's is there: ours has not arrived yet. + assert_eq!( + pick(serde_json::json!({"pairings": [ipad]}), mac), + Pairing::NotYet + ); + // An IPv4-mapped address is the same machine. + let mapped = serde_json::json!({"id": "c", "name": "x", "address": "::ffff:100.100.1.2"}); + assert_eq!( + pick(serde_json::json!({"pairings": [ipad, mapped]}), mac), + Pairing::Id("c".into()) + ); + // Two from one address: the name tells them apart, or nothing does. + let moonlight = + serde_json::json!({"id": "d", "name": "Moonlight", "address": "100.100.1.2"}); + assert_eq!( + pick(serde_json::json!({"pairings": [moonlight, ours]}), mac), + Pairing::Id("b".into()) + ); + assert_eq!( + pick(serde_json::json!({"pairings": [moonlight, moonlight]}), mac), + Pairing::NotYet + ); + + // A Docker node: every peer, and every request, is loopback. + let local: IpAddr = "127.0.0.1".parse().unwrap(); + let node = + |id: &str, n: &str| serde_json::json!({"id": id, "name": n, "address": "127.0.0.1"}); + assert_eq!( + pick( + serde_json::json!({"pairings": [node("a", "iPad"), node("b", name)]}), + local + ), + Pairing::Id("b".into()) + ); + assert_eq!( + pick( + serde_json::json!({"pairings": [node("a", name), node("b", name)]}), + local + ), + Pairing::NotYet + ); + // The same name from another address is someone else. + let twin = serde_json::json!({"id": "e", "name": name, "address": "100.64.0.9"}); + assert_eq!( + pick(serde_json::json!({"pairings": [twin]}), mac), + Pairing::NotYet + ); + + // Builds that list bare ids: the only one, never a guess among two. assert_eq!( - pending_pairings(Some(serde_json::json!({"pairings": []}))), - vec![None] + pick(serde_json::json!({"pairings": [{"id": "f"}]}), mac), + Pairing::Id("f".into()) ); - let master = serde_json::json!({"pairings": [{"id": "a", "name": "mac"}, {"id": "b"}]}); assert_eq!( - pending_pairings(Some(master)), - vec![Some("a".to_string()), Some("b".to_string())] + pick( + serde_json::json!({"pairings": [{"id": "f"}, {"id": "g"}]}), + mac + ), + Pairing::NotYet ); } diff --git a/crates/host/src/unix_setup.rs b/crates/host/src/unix_setup.rs index 2d6b09d..d1a88a5 100644 --- a/crates/host/src/unix_setup.rs +++ b/crates/host/src/unix_setup.rs @@ -191,7 +191,16 @@ fn run_inner(p: &Plan<'_>, log: &mut Vec) -> Result<()> { install_engine(&dir, log)?; } - stop_engine(); + // A native Linux node (deploy/native, stuzha) runs the engine as a + // systemd user unit, with the desktop's environment and its priority. + // Killing it there stopped the unit for good (a clean exit is no + // failure to restart from), and the copy started here instead lacked + // DISPLAY when setup ran over SSH: every stream failed while the port + // still answered. + let unit = engine_unit(); + if unit.is_none() { + stop_engine(); + } let conf = conf_path()?; let existing = fs::read_to_string(&conf).unwrap_or_default(); let credentials = conf.with_file_name(LOGIN_FILE); @@ -226,18 +235,102 @@ fn run_inner(p: &Plan<'_>, log: &mut Vec) -> Result<()> { step(log, "engine login set"); } - stop_engine(); - std::thread::sleep(Duration::from_millis(400)); - streamer::start(&install).context("start streaming engine")?; - step(log, "streaming engine started"); + if let Some(unit) = unit { + // Through `Requires=`, Latch's own unit restarts with it. + anyhow::ensure!( + systemctl_user(&["restart", unit])?, + "systemctl --user restart {unit} failed" + ); + // Its start may wait for the X screen first. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !streamer::running() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(250)); + } + anyhow::ensure!(streamer::running(), "{unit} did not start listening"); + step( + log, + format!("restarted the streaming engine's unit, {unit}"), + ); + } else { + stop_engine(); + std::thread::sleep(Duration::from_millis(400)); + streamer::start(&install).context("start streaming engine")?; + step(log, "streaming engine started"); + } if let Ok(exe) = std::env::current_exe() { set_autostart(true, &exe)?; step(log, "Latch starts at login so others can connect"); + #[cfg(target_os = "macos")] + if firewall_blocks(&exe) { + allow_through_firewall(&exe)?; + step(log, "the macOS firewall lets other machines reach Latch"); + } } Ok(()) } +#[cfg(any(target_os = "macos", test))] +const FIREWALL: &str = "/usr/libexec/ApplicationFirewall/socketfilterfw"; + +/// Whether the macOS application firewall turns away connections to +/// `exe`: the control service then looks absent to every other machine +/// (no PIN entry, no status), while the Mac's own window says "Shared". A +/// rule for an ad-hoc signed app can lapse with each new build. +#[cfg(target_os = "macos")] +pub fn firewall_blocks(exe: &Path) -> bool { + let run = |args: &[&str]| { + Command::new(FIREWALL) + .args(args) + .stderr(Stdio::null()) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() + }; + firewall_on(&run(&["--getglobalstate"])) + && app_blocked(&run(&["--getappblocked", &exe.display().to_string()])) +} + +/// "Firewall is enabled. (State = 1)"; 2 is "block all but essentials". +#[cfg(any(target_os = "macos", test))] +fn firewall_on(global: &str) -> bool { + global.contains("State = 1") || global.contains("State = 2") +} + +/// "Incoming connection to is blocked." (or "is permitted."). +#[cfg(any(target_os = "macos", test))] +fn app_blocked(answer: &str) -> bool { + answer.contains("is blocked") +} + +/// Add `exe` to the firewall's list as allowed. That takes an +/// administrator, so macOS asks for the password; setup is something the +/// owner started at the Mac. +#[cfg(target_os = "macos")] +fn allow_through_firewall(exe: &Path) -> Result<()> { + let out = Command::new("osascript") + .args(["-e", &firewall_allow_script(&exe.display().to_string())]) + .stdin(Stdio::null()) + .output() + .context("run osascript")?; + anyhow::ensure!( + out.status.success() && !firewall_blocks(exe), + "the macOS firewall blocks Latch, so other machines cannot reach it ({}). Allow Latch in System Settings > Network > Firewall > Options", + String::from_utf8_lossy(&out.stderr).trim() + ); + Ok(()) +} + +/// The AppleScript that runs `socketfilterfw --add` and `--unblockapp` for +/// `exe` as an administrator. +#[cfg(any(target_os = "macos", test))] +fn firewall_allow_script(exe: &str) -> String { + let exe = exe.replace('\\', "\\\\").replace('"', "\\\""); + format!( + "set exe to \"{exe}\"\ndo shell script \"{FIREWALL} --add \" & quoted form of exe & \" && {FIREWALL} --unblockapp \" & quoted form of exe with administrator privileges" + ) +} + fn install_engine(dir: &Path, log: &mut Vec) -> Result<()> { if exe_in(dir).exists() { log.push("engine already unpacked".into()); @@ -387,6 +480,41 @@ fn which(name: &str) -> Option { .map(PathBuf::from) } +/// The systemd user unit that runs the engine on this machine, when there is +/// one: then the engine is systemd's to stop and start, never Latch's. +pub fn engine_unit() -> Option<&'static str> { + #[cfg(target_os = "linux")] + { + const UNIT: &str = "latch-engine.service"; + let out = Command::new("systemctl") + .args([ + "--user", + "show", + "-p", + "LoadState", + "-p", + "UnitFileState", + UNIT, + ]) + .stderr(Stdio::null()) + .output() + .ok() + .filter(|o| o.status.success())?; + unit_installed(&String::from_utf8_lossy(&out.stdout)).then_some(UNIT) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +/// `systemctl show -p LoadState -p UnitFileState` of a unit that exists +/// and is not masked. ("not-found" when nobody installed one.) +#[cfg(any(target_os = "linux", test))] +fn unit_installed(show: &str) -> bool { + show.lines().any(|l| l.trim() == "LoadState=loaded") +} + pub fn stop_engine() { for name in engine_exe_names() { let _ = Command::new("pkill") @@ -424,6 +552,16 @@ pub fn set_autostart(enable: bool, exe: &Path) -> Result<()> { pub fn register_autostart(exe: &Path) -> Result<()> { #[cfg(target_os = "macos")] { + if !installed_app(exe) { + // A build run from a scratch folder, a disk image or App + // Translocation must not take the login item over: at the next + // login it would start that copy, or nothing once it is gone. + tracing::info!( + "not pointing the login item at {}: not an installed Latch.app", + exe.display() + ); + return Ok(()); + } write_launch_agent(exe)?; retire_legacy_launch_agent(); Ok(()) @@ -463,9 +601,221 @@ pub fn autostart_enabled() -> bool { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] const LAUNCH_AGENT_LABEL: &str = "com.bardbro.latch.node"; +/// How to start the background service when nothing answers. A copy the +/// window starts itself has no supervisor: launchd and systemd restart +/// only what they started, and a second copy started beside theirs loses +/// the port race and leaves their job stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartPlan { + /// macOS: load the login item; its RunAtLoad starts the service. + Bootstrap, + /// macOS: start the loaded login item (nothing if it already runs). + Kickstart, + /// Linux: start the user unit, with its drop-ins' environment. + SystemdStart, + /// No login item to go through: start a copy directly. + Spawn, +} + +/// What login item this machine has for the service. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginItem { + None, + /// The launch agent's file exists; whether launchd has it loaded, and + /// whether it is marked disabled (`launchctl print-disabled`), which + /// System Settings' Login Items does and which loads nothing. + MacAgent { + loaded: bool, + disabled: bool, + }, + /// The systemd user unit's file exists. + LinuxUnit, +} + +pub fn start_plan(item: LoginItem) -> StartPlan { + match item { + LoginItem::None | LoginItem::MacAgent { disabled: true, .. } => StartPlan::Spawn, + LoginItem::MacAgent { loaded: true, .. } => StartPlan::Kickstart, + LoginItem::MacAgent { loaded: false, .. } => StartPlan::Bootstrap, + LoginItem::LinuxUnit => StartPlan::SystemdStart, + } +} + +fn login_item() -> LoginItem { + #[cfg(target_os = "macos")] + { + if launch_agent_path().is_ok_and(|p| p.exists()) { + return LoginItem::MacAgent { + loaded: launch_agent_loaded(), + disabled: launch_agent_disabled(), + }; + } + } + #[cfg(target_os = "linux")] + { + if user_unit_path().is_ok_and(|p| p.exists()) { + return LoginItem::LinuxUnit; + } + } + LoginItem::None +} + +/// Start the service through its login item. `None` when there is none to +/// go through (or macOS has it disabled), so the caller starts a copy +/// itself; otherwise how it went, naming what was asked. +pub fn start_supervised() -> Option> { + let item = login_item(); + let plan = start_plan(item); + if let LoginItem::MacAgent { disabled: true, .. } = item { + tracing::warn!( + "macOS has Latch's login item turned off (System Settings > General > Login Items), so the service starts without it and does not come back after a restart" + ); + } + match plan { + StartPlan::Spawn => None, + #[cfg(target_os = "macos")] + StartPlan::Bootstrap | StartPlan::Kickstart => Some((|| -> Result<&'static str> { + let uid = uid()?; + let args = if plan == StartPlan::Bootstrap { + let plist = launch_agent_path()?; + bootstrap_args(&uid, &plist.display().to_string()) + } else { + kickstart_args(&uid) + }; + anyhow::ensure!(launchctl(&args)?, "launchctl {} failed", args[0]); + Ok("launchd") + })()), + #[cfg(target_os = "linux")] + StartPlan::SystemdStart => Some((|| -> Result<&'static str> { + // --no-block: the window calls this before it opens, and a unit + // may wait for the engine before it starts. + anyhow::ensure!( + systemctl_user(&["start", "--no-block", "latch.service"])?, + "systemctl --user start latch.service failed" + ); + Ok("systemd") + })()), + _ => None, + } +} + +/// Whether launchd or systemd started this process, and so restarts it +/// after a failure. +pub fn supervised() -> bool { + #[cfg(target_os = "macos")] + { + std::env::var("XPC_SERVICE_NAME").is_ok_and(|v| v == LAUNCH_AGENT_LABEL) + } + #[cfg(target_os = "linux")] + { + fs::read_to_string("/proc/self/cgroup").is_ok_and(|c| in_latch_unit(&c)) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + false + } +} + +/// `/proc/self/cgroup` of a process systemd runs as `latch.service`. +#[cfg(any(target_os = "linux", test))] +fn in_latch_unit(cgroup: &str) -> bool { + cgroup + .lines() + .any(|l| l.trim_end().ends_with("/latch.service")) +} + +/// `launchctl kickstart` without `-k`: it starts a job that is not +/// running and leaves one that is alone. +#[cfg(any(target_os = "macos", test))] +fn kickstart_args(uid: &str) -> Vec { + vec![ + "kickstart".into(), + format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"), + ] +} + +#[cfg(any(target_os = "macos", test))] +fn bootstrap_args(uid: &str, plist: &str) -> Vec { + vec!["bootstrap".into(), format!("gui/{uid}"), plist.into()] +} + +#[cfg(any(target_os = "macos", test))] +fn enable_args(uid: &str) -> Vec { + vec!["enable".into(), format!("gui/{uid}/{LAUNCH_AGENT_LABEL}")] +} + +/// Whether `exe` is the program of an app installed in /Applications or +/// ~/Applications, the only copies the service may point the login item at +/// by itself. +#[cfg(any(target_os = "macos", test))] +fn installed_app(exe: &Path) -> bool { + let p = exe.to_string_lossy(); + p.contains("/Applications/") + && p.contains(".app/Contents/MacOS/") + && !p.contains("/AppTranslocation/") +} + +/// The login item exists but macOS has it turned off (Login Items in +/// System Settings, or `launchctl disable`): the Mac looks shared, and is +/// not after the next restart. Setup turns it back on. +pub fn login_item_disabled() -> bool { + #[cfg(target_os = "macos")] + { + launch_agent_path().is_ok_and(|p| p.exists()) && launch_agent_disabled() + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +/// Whether `launchctl print-disabled gui/` lists the launch agent as +/// disabled ("=> disabled", or "=> true" before macOS 13). +#[cfg(any(target_os = "macos", test))] +fn listed_disabled(print_disabled: &str) -> bool { + let label = format!("\"{LAUNCH_AGENT_LABEL}\""); + print_disabled.lines().any(|l| { + l.trim().strip_prefix(&label).is_some_and(|rest| { + let v = rest.trim().trim_start_matches("=>").trim(); + v == "disabled" || v == "true" + }) + }) +} + +#[cfg(target_os = "macos")] +fn launch_agent_disabled() -> bool { + let Ok(uid) = uid() else { return false }; + Command::new("launchctl") + .args(["print-disabled", &format!("gui/{uid}")]) + .output() + .is_ok_and(|o| listed_disabled(&String::from_utf8_lossy(&o.stdout))) +} + +#[cfg(target_os = "macos")] +fn uid() -> Result { + let out = Command::new("id").arg("-u").output().context("id -u")?; + let uid = String::from_utf8_lossy(&out.stdout).trim().to_string(); + anyhow::ensure!( + out.status.success() && !uid.is_empty() && uid.bytes().all(|b| b.is_ascii_digit()), + "id -u said {uid:?}" + ); + Ok(uid) +} + +#[cfg(target_os = "macos")] +fn launchctl(args: &[String]) -> Result { + Ok(Command::new("launchctl") + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("run launchctl")? + .success()) +} + #[cfg(target_os = "macos")] fn launch_agent_path() -> Result { let home = dirs_home()?; @@ -536,12 +886,7 @@ pub fn adopt_legacy_bundle(background: bool) -> Result<()> { } retire_legacy_launch_agent(); if !background { - let uid = Command::new("id") - .arg("-u") - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); + let uid = uid().unwrap_or_default(); // The old job, which launchd runs from the path that no longer exists. let _ = Command::new("launchctl") .args([ @@ -654,19 +999,22 @@ fn macos_launch_agent(enable: bool, exe: &Path) -> Result<()> { } write_launch_agent(exe)?; retire_legacy_launch_agent(); + // Turning sharing on is the owner asking for the login item, so a label + // macOS has marked disabled is enabled: the legacy `launchctl load` + // skipped such a job without a word, and the service then ran with + // nothing to restart it, and not at all after the next login. + let uid = uid()?; + let _ = launchctl(&enable_args(&uid)); if launch_agent_loaded() { return Ok(()); } - // RunAtLoad starts the service now. If one already answers on the port, - // the new copy exits 0 and launchd does not try again. - let loaded = Command::new("launchctl") - .args(["load"]) - .arg(&path) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("load Latch launch agent")?; - anyhow::ensure!(loaded.success(), "could not load Latch launch agent"); + // RunAtLoad starts the service now; if one already answers on the + // port, the new copy exits 0 and launchd leaves it. + let _ = launchctl(&bootstrap_args(&uid, &path.display().to_string())); + anyhow::ensure!( + launch_agent_loaded(), + "macOS did not load Latch's login item; allow Latch in System Settings > General > Login Items" + ); Ok(()) } @@ -700,7 +1048,6 @@ fn retire_legacy_user_unit() { } } -#[cfg(target_os = "linux")] fn systemctl_user(args: &[&str]) -> Result { Ok(Command::new("systemctl") .arg("--user") @@ -782,6 +1129,121 @@ mod tests { assert!(plist.contains("RunAtLoad")); } + #[test] + fn the_service_starts_through_its_login_item_when_there_is_one() { + use LoginItem::*; + let mac = |loaded, disabled| MacAgent { loaded, disabled }; + assert_eq!(start_plan(mac(false, false)), StartPlan::Bootstrap); + // Stopped by the window (Share off then on, or Stop then Start): + // the job is still loaded but nothing runs. + assert_eq!(start_plan(mac(true, false)), StartPlan::Kickstart); + // Turned off in Login Items: nothing launchd would start. + assert_eq!(start_plan(mac(false, true)), StartPlan::Spawn); + assert_eq!(start_plan(mac(true, true)), StartPlan::Spawn); + assert_eq!(start_plan(LinuxUnit), StartPlan::SystemdStart); + assert_eq!(start_plan(None), StartPlan::Spawn); + + let kick = kickstart_args("501"); + assert_eq!(kick, ["kickstart", "gui/501/com.bardbro.latch.node"]); + assert!( + !kick.iter().any(|a| a == "-k"), + "-k would kill a service that runs" + ); + assert_eq!( + bootstrap_args("501", "/Users/a/Library/LaunchAgents/x.plist"), + [ + "bootstrap", + "gui/501", + "/Users/a/Library/LaunchAgents/x.plist" + ] + ); + assert_eq!( + enable_args("501"), + ["enable", "gui/501/com.bardbro.latch.node"] + ); + } + + #[test] + fn only_an_installed_app_takes_the_login_item_by_itself() { + for ok in [ + "/Applications/Latch.app/Contents/MacOS/Latch", + "/Users/ada/Applications/Latch.app/Contents/MacOS/Latch", + ] { + assert!(installed_app(Path::new(ok)), "{ok}"); + } + for scratch in [ + "/Users/ada/dev/latch/dist.noindex/Latch-prev-2026-10-12.app/Contents/MacOS/Latch", + "/Volumes/Latch/Latch.app/Contents/MacOS/Latch", + "/private/var/folders/x/T/AppTranslocation/1234/d/Latch.app/Contents/MacOS/Latch", + "/Users/ada/dev/latch/target/release/latch-host", + ] { + assert!(!installed_app(Path::new(scratch)), "{scratch}"); + } + } + + #[test] + fn a_disabled_login_item_and_a_systemd_service_are_recognised() { + // `launchctl print-disabled gui/501` on the Mac, 2026-10-17. + let listing = "disabled services = {\n\t\t\"com.apple.x\" => enabled\n\t\t\"com.bardbro.latch.node\" => disabled\n\t}\n"; + assert!(listed_disabled(listing)); + assert!(listed_disabled("\t\"com.bardbro.latch.node\" => true\n")); + assert!(!listed_disabled( + "\t\"com.bardbro.latch.node\" => enabled\n" + )); + assert!(!listed_disabled( + "\t\"com.bardbro.latch.node.extra\" => disabled\n" + )); + assert!(in_latch_unit( + "0::/user.slice/user-1004.slice/user@1004.service/app.slice/latch.service\n" + )); + assert!(in_latch_unit( + "1:name=systemd:/user.slice/user@1000.service/latch.service\n" + )); + assert!(!in_latch_unit( + "0::/user.slice/user-1004.slice/user@1004.service/app.slice/latch-engine.service\n" + )); + assert!(!in_latch_unit( + "0::/user.slice/user-1004.slice/session-3.scope\n" + )); + } + + #[test] + fn a_firewall_that_blocks_latch_is_recognised_and_opened_by_path() { + assert!(firewall_on("Firewall is enabled. (State = 1)\n")); + assert!(!firewall_on("Firewall is disabled. (State = 0)\n")); + assert!(app_blocked( + "Incoming connection to /Applications/Latch.app/Contents/MacOS/Latch is blocked.\n" + )); + assert!(!app_blocked( + "Incoming connection to /Applications/Latch.app/Contents/MacOS/Latch is permitted.\n" + )); + let script = firewall_allow_script("/Applications/My \"Latch\".app/Contents/MacOS/Latch"); + assert!( + script.starts_with( + "set exe to \"/Applications/My \\\"Latch\\\".app/Contents/MacOS/Latch\"\n" + ), + "{script}" + ); + assert!(script.contains("--add \" & quoted form of exe"), "{script}"); + assert!( + script.contains("--unblockapp \" & quoted form of exe"), + "{script}" + ); + assert!( + script.ends_with("with administrator privileges"), + "{script}" + ); + } + + #[test] + fn an_engine_systemd_runs_is_left_to_systemd() { + assert!(unit_installed("LoadState=loaded\nUnitFileState=static\n")); + assert!(unit_installed("LoadState=loaded\nUnitFileState=enabled\n")); + assert!(!unit_installed("LoadState=not-found\nUnitFileState=\n")); + assert!(!unit_installed("LoadState=masked\nUnitFileState=masked\n")); + assert!(!unit_installed("")); + } + #[test] fn an_older_installs_engine_login_is_found_under_its_new_name() { let dir = std::env::temp_dir().join(format!("latch-repair-{}", std::process::id())); diff --git a/crates/host/src/update.rs b/crates/host/src/update.rs index 3de9057..34db660 100644 --- a/crates/host/src/update.rs +++ b/crates/host/src/update.rs @@ -10,6 +10,13 @@ //! service to let go of the port, and the old one exits after answering. //! A new exe that cannot start, or exits at once, is moved aside and the //! old one put back, still serving. +//! +//! One that starts but then never serves (it panics on its first tick, or +//! hangs) is caught by the old executable itself: the old service starts it +//! from its retired file with `--probation` before it exits, and that +//! watcher puts the old version back unless the new one answers with its +//! version within [`PROBATION`]. The retired file stays until the new +//! service has run for a while (see [`tidy`]). use anyhow::{Context, Result}; use latch_core::api::{UPDATE_MAX_BYTES, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; @@ -19,14 +26,20 @@ use latch_core::update; use semver::Version; use std::io::ErrorKind; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::process::Child; +use std::time::{Duration, Instant}; /// Anything smaller is not the host. const MIN_SIZE: usize = 1024 * 1024; /// How long a new copy must stay up before the old one lets go. It waits /// for the old one's port, so it is still running after this unless it /// could not start at all. -const STARTUP_GRACE: Duration = Duration::from_secs(2); +pub const STARTUP_GRACE: Duration = Duration::from_secs(2); +/// How long a new service has, once the old one has let go, to answer on +/// the control port with its version and keep answering for +/// [`HEALTHY_FOR`]. Otherwise the previous executable is put back. +pub const PROBATION: Duration = Duration::from_secs(60); +const HEALTHY_FOR: Duration = Duration::from_secs(15); /// Why an upload was not staged, with the HTTP status it earns. #[derive(Debug, PartialEq, Eq)] @@ -67,8 +80,11 @@ fn with_suffix(exe: &Path, suffix: &str) -> PathBuf { exe.with_file_name(name) } -pub fn refuse_self_update(engine_running: bool, session_active: bool) -> bool { - engine_running && session_active +/// Whether to put an update off: only while a viewer is actually connected. +/// A Desktop left launched for a viewer to resume is no reason; refusing +/// then kept a PC from updating until it next restarted. +pub fn refuse_self_update(engine_running: bool, someone_watching: bool) -> bool { + engine_running && someone_watching } /// Check the upload and stage it beside `exe`. Returns the version it @@ -140,13 +156,52 @@ fn is_host_executable(bytes: &[u8]) -> bool { && bytes.get(offset..).is_some_and(|b| b.len() >= 24 + optional_size + sections * 40) } +/// A new service started, and where the one it replaces now is. +#[derive(Debug)] +pub struct Handover { + /// The new service's process. + pub pid: u32, + /// The executable that was running, renamed out of the way. + pub retired: PathBuf, +} + /// Swap the staged executable in and start it. The caller exits afterwards; /// the new process waits for that before it takes the port. -pub fn apply(exe: &Path) -> Result<()> { +pub fn apply(exe: &Path) -> Result { apply_within(exe, STARTUP_GRACE) } -fn apply_within(exe: &Path, grace: Duration) -> Result<()> { +/// Start `exe` as the service that takes over from this one: `--replaces` +/// makes it wait for this process to let go of the port. Fails if it has +/// already exited after `grace` (a crash, a missing library). Returns its +/// process id. +pub fn hand_over(exe: &Path, grace: Duration) -> Result { + let mut child = start_service(exe, &["--replaces", &std::process::id().to_string()]) + .context("start the new service")?; + std::thread::sleep(grace); + if let Ok(Some(status)) = child.try_wait() { + anyhow::bail!("the new service exited at once ({status})"); + } + Ok(child.id()) +} + +/// `exe --background `, detached from this process. +fn start_service(exe: &Path, args: &[&str]) -> std::io::Result { + let mut c = std::process::Command::new(exe); + c.arg("--background") + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0000_0008 | 0x0800_0000); // DETACHED_PROCESS | CREATE_NO_WINDOW + } + c.spawn() +} + +fn apply_within(exe: &Path, grace: Duration) -> Result { let new = staged(exe); anyhow::ensure!(new.exists(), "nothing is staged at {}", new.display()); let old = { @@ -168,37 +223,151 @@ fn apply_within(exe: &Path, grace: Duration) -> Result<()> { // Keep the old service installed if the replacement cannot run: this // process is still serving requests, and a PC nobody can reach in // person must not be left with a service that never starts. - let restore = |why: anyhow::Error| -> Result<()> { - rename_retry(exe, &new).context("move the failed replacement aside")?; - rename_retry(&old, exe).context("restore the previous executable")?; - Err(why.context("the previous executable is back in place")) - }; - let mut c = std::process::Command::new(exe); - c.arg("--background") - .arg("--replaces") - .arg(std::process::id().to_string()) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - c.creation_flags(0x0000_0008 | 0x0800_0000); // DETACHED_PROCESS | CREATE_NO_WINDOW - } - let mut child = match c.spawn() { - Ok(child) => child, - Err(e) => return restore(anyhow::Error::new(e).context("start the new service")), - }; // One that has already exited could not start (a crash, a missing // library): it would leave the PC with no service after this one quits. - std::thread::sleep(grace); - if let Ok(Some(status)) = child.try_wait() { - return restore(anyhow::anyhow!("the new service exited at once ({status})")); + match hand_over(exe, grace) { + Ok(pid) => Ok(Handover { pid, retired: old }), + Err(why) => { + rename_retry(exe, &new).context("move the failed replacement aside")?; + rename_retry(&old, exe).context("restore the previous executable")?; + Err(why.context("the previous executable is back in place")) + } } +} + +/// Start the watcher for the new service from the executable it retired, +/// which is what it puts back if it has to. Call before exiting. +pub fn start_probation(exe: &Path, h: &Handover, version: &str) -> Result<()> { + let exe = exe.display().to_string(); + start_service( + &h.retired, + &[ + "--probation", + &h.pid.to_string(), + "--probation-version", + version, + "--probation-exe", + &exe, + ], + ) + .map(drop) + .context("start the update watcher") +} + +/// The watcher (`--background --probation `), run from the retired +/// executable: wait for the new service at `exe` to answer with `version` +/// and keep answering; if it does not, stop it, put this executable back at +/// `exe` and start it again. A PC nobody can reach in person must not be +/// left with a service that started and then died. +pub fn probation(exe: &Path, new_pid: u32, version: &str) -> Result<()> { + let retired = std::env::current_exe().context("own path")?; + let verdict = watch( + answering_version, + version, + PROBATION, + HEALTHY_FOR, + Duration::from_millis(500), + ); + let Err(why) = verdict else { + tracing::info!("Latch Host {version} is up and answering; the update is done"); + return Ok(()); + }; + tracing::error!("Latch Host {version} {why}; putting the previous version back"); + // A hung one still holds the port; a dead one's id may already belong + // to something else, so only one that still listens is stopped. + if port_held() { + kill(new_pid); + } + restore_previous(exe, &retired)?; + start_service(exe, &["--replaces", &new_pid.to_string()]) + .context("start the previous version again")?; + tracing::info!("the previous version is back in place and starting"); Ok(()) } -/// Remove what the last update retired, once its process has gone. +/// Wait until `probe` has said `want` for `healthy_for` without a gap, or +/// give up after `limit`, saying what went wrong. +fn watch( + probe: impl Fn() -> Option, + want: &str, + limit: Duration, + healthy_for: Duration, + every: Duration, +) -> std::result::Result<(), String> { + let start = Instant::now(); + let mut up_since: Option = None; + let mut ever_up = false; + loop { + match probe() { + Some(v) if v == want => { + ever_up = true; + if up_since.get_or_insert_with(Instant::now).elapsed() >= healthy_for { + return Ok(()); + } + } + _ => up_since = None, + } + if start.elapsed() >= limit { + return Err(if ever_up { + "started, then stopped answering".into() + } else { + format!("did not answer within {} s", limit.as_secs()) + }); + } + std::thread::sleep(every); + } +} + +/// The version the service on this machine reports, if one answers. +fn answering_version() -> Option { + let st: latch_core::api::Status = latch_core::http::get_json( + ("127.0.0.1", latch_core::CONTROL_PORT), + "/v1/status", + Duration::from_secs(2), + ) + .ok()?; + Some(st.version) +} + +fn port_held() -> bool { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], latch_core::CONTROL_PORT)); + std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() +} + +fn kill(pid: u32) { + #[cfg(windows)] + let mut c = { + use std::os::windows::process::CommandExt; + let mut c = std::process::Command::new("taskkill"); + c.args(["/F", "/PID", &pid.to_string()]) + .creation_flags(0x0800_0000); + c + }; + #[cfg(not(windows))] + let mut c = { + let mut c = std::process::Command::new("kill"); + c.args(["-9", &pid.to_string()]); + c + }; + let _ = c + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + std::thread::sleep(Duration::from_millis(500)); +} + +/// Put the retired executable back at `exe`, and the one that failed where +/// an upload waits (`.new`), so the logs can still be matched to it. +fn restore_previous(exe: &Path, retired: &Path) -> Result<()> { + let failed = staged(exe); + let _ = std::fs::remove_file(&failed); + rename_retry(exe, &failed).context("move the failed version aside")?; + rename_retry(retired, exe).context("put the previous version back") +} + +/// Remove what the last update retired. The service calls this once it +/// has run for a while, not when it starts: until the new version has +/// proven itself, the retired one is what [`probation`] puts back. pub fn tidy(exe: &Path) { let _ = std::fs::remove_file(retired(exe)); let Some(dir) = exe.parent() else { return }; @@ -295,6 +464,31 @@ mod tests { assert!(!is_host_executable(b"MZ")); } + #[test] + fn a_new_service_must_answer_with_its_version_and_keep_answering() { + use std::cell::Cell; + let ms = Duration::from_millis; + let want = "4.2.0"; + // Up at once and staying up. + assert_eq!( + watch(|| Some(want.into()), want, ms(400), ms(60), ms(10)), + Ok(()) + ); + // Never answers: the panic on the first tick, or a hang. + let e = watch(|| None, want, ms(100), ms(30), ms(10)).unwrap_err(); + assert!(e.contains("did not answer"), "{e}"); + // The old version still answers: the new one never took over. + assert!(watch(|| Some("4.1.1".into()), want, ms(100), ms(30), ms(10)).is_err()); + // Answers, then dies before it has proven itself. + let calls = Cell::new(0); + let flaky = || { + calls.set(calls.get() + 1); + (calls.get() < 3).then(|| want.to_string()) + }; + let e = watch(flaky, want, ms(150), ms(100), ms(10)).unwrap_err(); + assert!(e.contains("stopped answering"), "{e}"); + } + #[test] fn an_active_session_blocks_self_update() { assert!(refuse_self_update(true, true)); @@ -427,9 +621,18 @@ mod tests { assert!(staged(&exe).exists()); // One that keeps running is the new service. script("#!/bin/sh\nexec sleep 2\n"); - apply_within(&exe, Duration::from_millis(300)).unwrap(); + let h = apply_within(&exe, Duration::from_millis(300)).unwrap(); assert!(std::fs::read(&exe).unwrap().starts_with(b"#!/bin/sh")); assert_eq!(std::fs::read(retired(&exe)).unwrap(), b"old"); + assert_eq!(h.retired, retired(&exe)); + assert!(h.pid > 0); + // It then never served: the watcher puts the old one back. + restore_previous(&exe, &h.retired).unwrap(); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert!(std::fs::read(staged(&exe)) + .unwrap() + .starts_with(b"#!/bin/sh")); + assert!(!retired(&exe).exists()); } std::fs::write(retired(&exe), b"retired").unwrap(); std::fs::write(dir.join("latch-host.exe.old-notes"), b"keep").unwrap(); diff --git a/crates/host/src/virtual_display.rs b/crates/host/src/virtual_display.rs index 6a10ea6..b31e886 100644 --- a/crates/host/src/virtual_display.rs +++ b/crates/host/src/virtual_display.rs @@ -20,6 +20,151 @@ const REG_KEY: &str = r"HKLM:\SOFTWARE\MikeTheTech\VirtualDisplayDriver"; /// Listed with every size: the default frame rate and ProMotion's. pub const REFRESH_RATES: [u32; 2] = [60, 120]; +/// The name the Virtual Display Driver's monitor reports. +const VDD_NAME: &str = "VDD by MTT"; + +/// Sunshine's stable ID for the Virtual Display Driver, including an +/// inactive display. Use the latest device list, so a removed display +/// from an older list is never selected. Sunshine lists the devices as +/// objects when it starts, and as `" - "` strings when it has +/// recovered the display layout after a stream. +// ponytail: Sunshine logs its display IDs; use an enumeration API if it gains one. +pub fn output_in(log: &str) -> Option { + let json = log + .rmatch_indices("Currently available ") + .find_map(|(at, key)| { + let list = &log[at + key.len()..]; + list.strip_prefix("display devices:") + .or_else(|| list.strip_prefix("devices:")) + })?; + let devices = serde_json::Deserializer::from_str(json.trim_start()) + .into_iter::>() + .next()? + .ok()?; + devices.iter().find_map(|d| { + if let Some(entry) = d.as_str() { + let (id, name) = entry.split_once(" - ")?; + return (name == VDD_NAME && !id.is_empty()).then(|| id.to_string()); + } + let vdd = d["friendly_name"].as_str() == Some(VDD_NAME) + || (d["edid"]["manufacturer_id"].as_str() == Some("MTT") + && d["edid"]["product_code"].as_str() == Some("1337")); + d["device_id"] + .as_str() + .filter(|id| vdd && !id.is_empty()) + .map(String::from) + }) +} + +/// The engine key that picks the display a stream captures. +const OUTPUT: &str = "output_name"; +/// The engine key for what happens to the other displays during a stream. +const DD_OPTION: &str = "dd_configuration_option"; +/// The streamed display is the only one on while a stream runs. +const ONLY_DISPLAY: &str = "ensure_only_display"; +/// Where the service keeps the display ID it put in [`OUTPUT`], so that ID +/// still counts as automatic when the driver comes back with another one. +const PICKED: &str = "capture-display"; + +/// Which display the engine captures, as far as Latch manages it on Windows. +#[derive(Debug, PartialEq)] +pub struct Capture { + /// Engine settings to change, with their new values; empty when none need to. + pub conf: Vec<(&'static str, String)>, + /// The display ID Latch has set once `conf` is in force. + pub picked: Option, +} + +impl Capture { + /// `conf` as the engine-key pairs [`crate::streamer`] writes. + pub fn pairs(&self) -> Vec<(&str, &str)> { + self.conf.iter().map(|(k, v)| (*k, v.as_str())).collect() + } +} + +/// With capture on automatic, Latch prefers the Virtual Display Driver even +/// while a physical monitor is connected. Automatic is no display chosen +/// in the engine (`output_name` empty), the driver's own ID `vdd`, or the ID +/// Latch set before (`picked`): a reinstalled driver can come back with a +/// new ID, and capture follows it. Any other display is the owner's choice +/// and is kept; the engine settings Latch changed for its own pick go back. +pub fn capture(current: &serde_json::Value, vdd: Option<&str>, picked: Option<&str>) -> Capture { + let selected = current[OUTPUT].as_str().unwrap_or_default(); + let automatic = selected.is_empty() || Some(selected) == vdd || Some(selected) == picked; + let mut conf = Vec::new(); + let picked = match (automatic, vdd) { + (true, Some(id)) => { + conf.push((OUTPUT, id.to_string())); + // Games already on the physical monitor must move onto the + // streamed display too. The engine restores the physical + // displays when the stream disconnects. + conf.push((DD_OPTION, ONLY_DISPLAY.to_string())); + Some(id.to_string()) + } + // No device list in the log tail (a long stream pushes it out) or + // no driver right now: what is set stays. + (true, None) => picked.map(String::from), + (false, _) => { + if picked.is_some() && current[DD_OPTION] == ONLY_DISPLAY { + let default = crate::setup::ENGINE_CONF + .iter() + .find(|(k, _)| *k == DD_OPTION) + .map_or("ensure_active", |(_, v)| *v); + conf.push((DD_OPTION, default.to_string())); + } + None + } + }; + conf.retain(|(k, v)| current[*k].as_str() != Some(v.as_str())); + Capture { conf, picked } +} + +/// Keep the engine capturing the virtual display while capture is on +/// automatic (see [`capture`]); the service calls this every 30 seconds on +/// Windows, since the driver can be installed, or get a new ID, at any +/// time. Nothing is asked of the engine on a PC that has never had the +/// driver. Returns a line for the service log when settings changed. +pub fn follow( + api: &crate::streamer::Api, + install: &crate::streamer::Install, + data_dir: &std::path::Path, +) -> anyhow::Result> { + let vdd = crate::streamer::log_text(install).and_then(|log| output_in(&log)); + let file = data_dir.join(PICKED); + let picked = std::fs::read_to_string(&file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if vdd.is_none() && picked.is_none() { + return Ok(None); + } + let Some(capture) = + api.follow_virtual_display(Some(install), vdd.as_deref(), picked.as_deref())? + else { + return Ok(None); // Someone may be watching: the display stays until they are done. + }; + if capture.picked != picked { + let saved = match &capture.picked { + Some(id) => std::fs::write(&file, id), + None => std::fs::remove_file(&file), + }; + if let Err(e) = saved { + tracing::warn!("could not save the capture display: {e}"); + } + } + Ok((!capture.conf.is_empty()).then(|| { + let keys: Vec = capture + .conf + .iter() + .map(|(k, v)| format!("{k} = {v}")) + .collect(); + format!( + "capture display: {}; the streaming engine restarted", + keys.join(", ") + ) + })) +} + /// Every size as a PowerShell array of `@(width,height)` pairs. pub fn modes_ps() -> String { stream_modes() @@ -144,6 +289,96 @@ fn parse_probe(out: &str) -> Option { mod tests { use super::*; + #[test] + fn capture_uses_the_virtual_display_from_the_latest_device_list() { + let list = r#"[ + {"device_id":"physical", "friendly_name":"27E1QA", "info":{"primary":true}}, + {"device_id":"virtual", "friendly_name":"VDD by MTT", "info":null} + ]"#; + let log = format!("Info: Currently available display devices:\n{list}\nInfo: Ready"); + assert_eq!(output_in(&log).as_deref(), Some("virtual")); + let log = format!("{log}\nInfo: Currently available devices:\n[]\nInfo: Ready"); + assert_eq!(output_in(&log), None, "the old virtual display is gone"); + assert_eq!(output_in("Currently available devices:\n["), None); + assert_eq!(output_in("no devices reported"), None); + // After a stream, the layout recovery lists " - " strings. + let recovered = format!( + "{log}\nInfo: Recovered the available display layout, but settings for an \ + unavailable display are still pending. Will retry once devices are added or \ + removed. Currently available devices:\n[\"{{a1}} - 27E1QA\", \"{{b2}} - VDD by MTT\"]\n" + ); + assert_eq!(output_in(&recovered).as_deref(), Some("{b2}")); + let gone = format!("{recovered}Info: Currently available devices:\n[\"{{a1}} - 27E1QA\"]"); + assert_eq!(output_in(&gone), None, "the latest list decides"); + // Other text that starts the same way is not a device list. + let other = format!("{recovered}Info: Currently available encoders: none"); + assert_eq!(output_in(&other).as_deref(), Some("{b2}")); + } + + #[test] + fn automatic_capture_selects_the_virtual_display_and_keeps_the_owners_choice() { + let fresh = + serde_json::json!({"encoder":"amdvce", "dd_configuration_option":"ensure_active"}); + let first = capture(&fresh, Some("vdd-a"), None); + assert_eq!( + first.conf, + [ + ("output_name", "vdd-a".to_string()), + ("dd_configuration_option", "ensure_only_display".to_string()) + ] + ); + assert_eq!(first.picked.as_deref(), Some("vdd-a")); + + // In force: nothing to change, with or without the device list. + let set = serde_json::json!({"output_name":"vdd-a", "dd_configuration_option":"ensure_only_display"}); + let same = capture(&set, Some("vdd-a"), Some("vdd-a")); + assert!(same.conf.is_empty(), "{same:?}"); + assert_eq!(same.picked.as_deref(), Some("vdd-a")); + let unlisted = capture(&set, None, Some("vdd-a")); + assert!(unlisted.conf.is_empty(), "{unlisted:?}"); + assert_eq!(unlisted.picked.as_deref(), Some("vdd-a")); + + // Set by an earlier build that kept no record: still Latch's. + let adopted = capture(&set, Some("vdd-a"), None); + assert!(adopted.conf.is_empty()); + assert_eq!(adopted.picked.as_deref(), Some("vdd-a")); + + // A reinstalled driver with a new ID: capture follows it. + let moved = capture(&set, Some("vdd-b"), Some("vdd-a")); + assert_eq!(moved.conf, [("output_name", "vdd-b".to_string())]); + assert_eq!(moved.picked.as_deref(), Some("vdd-b")); + + // No driver and no pick: nothing is touched. + let none = capture(&fresh, None, None); + assert_eq!( + none, + Capture { + conf: vec![], + picked: None + } + ); + } + + #[test] + fn an_explicit_display_is_kept_and_latchs_own_change_goes_back() { + let chosen = serde_json::json!({"output_name":"monitor", "dd_configuration_option":"ensure_only_display"}); + // The owner replaced Latch's pick: only the option Latch set goes back. + let replaced = capture(&chosen, Some("vdd-a"), Some("vdd-a")); + assert_eq!( + replaced.conf, + [("dd_configuration_option", "ensure_active".to_string())] + ); + assert_eq!(replaced.picked, None); + // Latch never picked a display here: the owner's settings stay. + let theirs = capture(&chosen, Some("vdd-a"), None); + assert!(theirs.conf.is_empty(), "{theirs:?}"); + assert_eq!(theirs.picked, None); + let primary = serde_json::json!({"output_name":"monitor", "dd_configuration_option":"ensure_primary"}); + assert!(capture(&primary, Some("vdd-a"), Some("vdd-a")) + .conf + .is_empty()); + } + #[test] fn the_step_lists_every_mode_keeps_the_file_and_restarts_only_on_change() { let s = setup_ps(); diff --git a/crates/host/windows/take-over-engine.ps1 b/crates/host/windows/take-over-engine.ps1 index 84789ad..749c3a4 100644 --- a/crates/host/windows/take-over-engine.ps1 +++ b/crates/host/windows/take-over-engine.ps1 @@ -28,21 +28,100 @@ function Get-ActiveRender { } } -function Get-EngineProcess { - Get-CimInstance -ClassName Win32_Process -Filter "Name='sunshine.exe'" | Select-Object -First 1 +# Session 0 has no desktop. An SSH or WinRM shell runs there, and an engine +# started from it could neither capture the screen nor take input, so +# nothing is stopped or started from there. Setup run that way hands the +# engine to the signed-in user's logon task instead. +$mySession = [System.Diagnostics.Process]::GetCurrentProcess().SessionId +if ($mySession -eq 0) { Write-Output "NOT_INTERACTIVE"; exit 2 } + +function Get-EngineProcesses { + Get-CimInstance -ClassName Win32_Process -Filter "Name='sunshine.exe'" | ForEach-Object { + $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner + [pscustomobject]@{ + Pid = $_.ProcessId + Session = $_.SessionId + User = "$($o.Domain)\$($o.User)" + } + } +} + +$services = "LatchStream", "BroLinkStream" +$me = "$env:USERDOMAIN\$env:USERNAME" + +# Ours is an engine this user runs in this session. Any other is foreign: +# the service's SYSTEM copy, one left in session 0 by a setup run over SSH, +# another user's, or one whose owner cannot be read. Callers wrap the list +# in @(): Windows PowerShell unrolls a one-item list into a lone object, +# which has no Count. +function Get-ForeignEngines { + Get-EngineProcesses | Where-Object { $_.User -ine $me -or $_.Session -ne $mySession } } -function Get-EngineOwner { - $p = Get-EngineProcess - if (-not $p) { return $null } - $o = Invoke-CimMethod -InputObject $p -MethodName GetOwner - [pscustomobject]@{ - Pid = $p.ProcessId - Session = $p.SessionId - User = "$($o.Domain)\$($o.User)" +# A service that is Running counts even before its engine shows up: the +# service wrapper starts the engine a few seconds after it reports Running. +function Test-ServiceUp { + @($services | Where-Object { (Get-Service -Name $_ -ErrorAction SilentlyContinue).Status -eq 'Running' }).Count -gt 0 +} + +# Every engine is looked at, not just the first listed: an older user engine +# listed first would otherwise hide a SYSTEM one the service started beside +# it. $LatchForce is set by setup, which has just rewritten the engine's +# login and settings, so even an engine of ours starts again to read them. +$all = @(Get-EngineProcesses) +$foreign = @(Get-ForeignEngines) +if ($all.Count -eq 0 -or $foreign.Count -gt 0 -or (Test-ServiceUp) -or $LatchForce) { + foreach ($service in $services) { + Set-Service $service -StartupType Manual -ErrorAction SilentlyContinue + Stop-Service $service -Force -ErrorAction SilentlyContinue + } + foreach ($p in $foreign) { Stop-Process -Id $p.Pid -Force -ErrorAction SilentlyContinue } + Start-Sleep -Seconds 2 + # Without administrator rights (the logon task, Latch Host itself) the + # service and a SYSTEM engine cannot be stopped. The user's own engine + # is then left running rather than killed for nothing. + $left = @(Get-ForeignEngines) + if ($left.Count -gt 0) { + Write-Output ("STILL_RUNNING {0} pid={1}" -f $left[0].User, $left[0].Pid) + exit 1 + } + if (Test-ServiceUp) { + Write-Output "STILL_RUNNING service" + exit 1 } + $own = @(Get-EngineProcesses) + if ($own.Count -gt 0) { + foreach ($p in $own) { Stop-Process -Id $p.Pid -Force -ErrorAction SilentlyContinue } + Start-Sleep -Seconds 1 + $left = @(Get-EngineProcesses) + if ($left.Count -gt 0) { + Write-Output ("STILL_RUNNING {0} pid={1}" -f $left[0].User, $left[0].Pid) + exit 1 + } + } + # The engine is a console program. Started plainly it owns a console + # window on the desktop being streamed, and closing that window ends + # the engine and with it the stream. SW_HIDE at creation keeps the + # console from ever showing. + $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [UInt16]0 } + $created = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ + CommandLine = "`"$exe`" `"$conf`"" + CurrentDirectory = $engineDir + ProcessStartupInformation = $startup + } + if ($created.ReturnValue -ne 0) { + Write-Output ("CREATE_FAILED {0}" -f $created.ReturnValue) + exit 1 + } + # platf::init disables Steam Speakers when they are the default; wait + # until that has run before putting a default back. + Start-Sleep -Seconds 6 } +$devices = @(Get-ActiveRender) +if ($devices.Count -eq 0) { Write-Output "NO_ACTIVE_RENDER"; exit 0 } +# Compiled only now: C# goes through csc, about a second of CPU, and a PC +# with no playback device at all never needs it. Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; @@ -75,44 +154,6 @@ public static class LatchAudio { } } "@ - -$me = "$env:USERDOMAIN\$env:USERNAME" -$sun = Get-EngineOwner -if (-not $sun -or $sun.User -ine $me) { - foreach ($service in "LatchStream", "BroLinkStream") { - Set-Service $service -StartupType Manual -ErrorAction SilentlyContinue - Stop-Service $service -Force -ErrorAction SilentlyContinue - } - Get-CimInstance -ClassName Win32_Process -Filter "Name='sunshine.exe'" | ForEach-Object { - Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue - } - Start-Sleep -Seconds 2 - $left = Get-EngineOwner - if ($left) { - Write-Output ("STILL_RUNNING {0} pid={1}" -f $left.User, $left.Pid) - exit 1 - } - # The engine is a console program. Started plainly it owns a console - # window on the desktop being streamed, and closing that window ends - # the engine and with it the stream. SW_HIDE at creation keeps the - # console from ever showing. - $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [UInt16]0 } - $created = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ - CommandLine = "`"$exe`" `"$conf`"" - CurrentDirectory = $engineDir - ProcessStartupInformation = $startup - } - if ($created.ReturnValue -ne 0) { - Write-Output ("CREATE_FAILED {0}" -f $created.ReturnValue) - exit 1 - } - # platf::init disables Steam Speakers when they are the default; wait - # until that has run before putting a default back. - Start-Sleep -Seconds 6 -} - -$devices = @(Get-ActiveRender) -if ($devices.Count -eq 0) { Write-Output "NO_ACTIVE_RENDER"; exit 0 } $pick = $devices | Where-Object { $_.Desc -match "Steam Streaming" -or $_.Friendly -match "Steam Streaming" } | Select-Object -First 1 if (-not $pick) { $pick = $devices | Select-Object -First 1 } $hr = [LatchAudio]::SetDefault($pick.Id) diff --git a/crates/stream/src/nvhttp.rs b/crates/stream/src/nvhttp.rs index c2603b6..b8a3147 100644 --- a/crates/stream/src/nvhttp.rs +++ b/crates/stream/src/nvhttp.rs @@ -19,6 +19,23 @@ pub const HTTPS_PORT: u16 = 47984; const TIMEOUT: Duration = Duration::from_secs(6); /// The host holds the first pairing reply until a PIN is entered. const PIN_WAIT: Duration = Duration::from_secs(90); +/// How long the host is given to register the first pairing request before +/// anyone is asked to enter the PIN, and how often the wait looks up. +const PIN_ASK_AFTER: Duration = Duration::from_millis(400); +const PIN_POLL: Duration = Duration::from_millis(100); + +/// Nobody entered the PIN on the PC before it stopped holding the first +/// pairing request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PinNotEntered; + +impl std::fmt::Display for PinNotEntered { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("nobody entered the PIN on the PC in time") + } +} + +impl std::error::Error for PinNotEntered {} #[derive(Debug, Clone, Default, PartialEq)] pub struct ServerInfo { @@ -93,6 +110,9 @@ pub struct Client<'a> { /// Host certificate (DER) learned at pairing; `None` before. server_cert: Option>, tls: Option>, + /// [`HTTP_PORT`] and [`HTTPS_PORT`], except against a test's fake host. + http_port: u16, + https_port: u16, } impl<'a> Client<'a> { @@ -106,9 +126,20 @@ impl<'a> Client<'a> { ip, server_cert, tls, + http_port: HTTP_PORT, + https_port: HTTPS_PORT, }) } + /// The same client against a fake host on other ports. Tests never bind + /// the real ones: a Mac sharing itself has Sunshine there. + #[cfg(test)] + fn with_ports(mut self, http: u16, https: u16) -> Self { + self.http_port = http; + self.https_port = https; + self + } + pub fn server_cert(&self) -> Option<&[u8]> { self.server_cert.as_deref() } @@ -127,7 +158,7 @@ impl<'a> Client<'a> { } fn get_http(&self, path: &str, query: &str, read_timeout: Duration) -> Result { - let addr = SocketAddr::new(self.ip, HTTP_PORT); + let addr = SocketAddr::new(self.ip, self.http_port); let mut s = TcpStream::connect_timeout(&addr, TIMEOUT)?; s.set_read_timeout(Some(read_timeout))?; s.set_write_timeout(Some(TIMEOUT))?; @@ -164,7 +195,7 @@ impl<'a> Client<'a> { } fn https_once(&self, tls: Arc, target: &str) -> Result { - let addr = SocketAddr::new(self.ip, HTTPS_PORT); + let addr = SocketAddr::new(self.ip, self.https_port); let mut tcp = TcpStream::connect_timeout(&addr, TIMEOUT)?; tcp.set_read_timeout(Some(TIMEOUT))?; tcp.set_write_timeout(Some(TIMEOUT))?; @@ -208,34 +239,44 @@ impl<'a> Client<'a> { }) } - /// [`Client::pair_cancellable`] without the cancel, for the live tests. + /// [`Client::pair_cancellable`] without the cancel, for the live tests: + /// `pending` runs once, beside the first request, when the host is + /// waiting for the PIN. #[cfg(test)] pub fn pair( &mut self, pin: &str, device_name: &str, - pending: impl FnOnce(), + pending: impl FnOnce() + Send, ) -> Result> { - self.pair_cancellable(pin, device_name, || { - pending(); - false + let mut pending = Some(pending); + std::thread::scope(|s| { + self.pair_cancellable(pin, device_name, |_| { + if let Some(p) = pending.take() { + s.spawn(p); + } + false + }) }) } - /// The Moonlight pairing handshake. `pending` is called once the host is - /// holding our first request and waiting for someone to enter `pin` on - /// its side; it returns `true` to cancel, which aborts that request. On - /// success the host certificate is pinned for the later HTTPS calls and - /// returned. + /// The Moonlight pairing handshake. The host holds our first request + /// until someone enters `pin` on its side. Meanwhile `poll` is called + /// about every 100 ms, from a moment after that request went out, with + /// whether the host has answered it; the last call says `true`, and the + /// handshake goes on at once. Entering the PIN is the caller's business, + /// beside this wait, never in front of it. `poll` returns `true` to + /// cancel, which aborts the request while it is held. On success the + /// host certificate is pinned for the later HTTPS calls and returned. pub fn pair_cancellable( &mut self, pin: &str, device_name: &str, - pending: impl FnOnce() -> bool, + poll: impl FnMut(bool) -> bool, ) -> Result> { let previous_tls = self.tls.clone(); let previous_cert = self.server_cert.clone(); - let result = self.pair_inner(pin, device_name, pending); + let result = self.pair_inner(pin, device_name, poll); if result.is_err() { self.tls = previous_tls; self.server_cert = previous_cert; @@ -248,7 +289,7 @@ impl<'a> Client<'a> { &mut self, pin: &str, device_name: &str, - pending: impl FnOnce() -> bool, + poll: impl FnMut(bool) -> bool, ) -> Result> { let salt: [u8; 16] = rand::random(); let mut salted = salt.to_vec(); @@ -266,24 +307,17 @@ impl<'a> Client<'a> { ); let ip = self.ip; let target = self.target("pair", &query); - let mut s = TcpStream::connect_timeout(&SocketAddr::new(ip, HTTP_PORT), TIMEOUT)?; + let mut s = TcpStream::connect_timeout(&SocketAddr::new(ip, self.http_port), TIMEOUT)?; let abort = s.try_clone()?; let handle = std::thread::spawn(move || -> Result { s.set_read_timeout(Some(PIN_WAIT))?; s.set_write_timeout(Some(TIMEOUT))?; - let r = http::exchange(&mut s, "GET", &target, &ip.to_string(), "")?; + let r = http::exchange(&mut s, "GET", &target, &ip.to_string(), "") + .map_err(held_request_error)?; check(&r.body)?; Ok(r.body) }); - std::thread::sleep(Duration::from_millis(400)); - if pending() { - let _ = abort.shutdown(std::net::Shutdown::Both); - let _ = handle.join(); - bail!("pairing cancelled"); - } - handle - .join() - .map_err(|_| anyhow!("pairing thread panicked"))?? + wait_for_first_reply(handle, &abort, poll)? }; if tag(&first, "paired") != Some("1") { bail!("the PC refused to start pairing"); @@ -413,6 +447,47 @@ impl<'a> Client<'a> { } } +/// The first pairing request's read gives up after [`PIN_WAIT`]: that is +/// nobody entering the PIN, not a network fault. +fn held_request_error(e: anyhow::Error) -> anyhow::Error { + if e.chain().any(|c| c.to_string() == "timed out") { + anyhow!(PinNotEntered) + } else { + e + } +} + +/// Wait for the host's answer to the first pairing request, which it holds +/// until the PIN is entered. From [`PIN_ASK_AFTER`] on, `poll` hears every +/// [`PIN_POLL`] whether the answer is in (the last call says so) and may +/// cancel the wait, which closes the connection under the held request. +/// The request's own read timeout bounds the whole wait. +fn wait_for_first_reply( + reply: std::thread::JoinHandle>, + connection: &TcpStream, + mut poll: impl FnMut(bool) -> bool, +) -> Result { + let started = std::time::Instant::now(); + loop { + let answered = reply.is_finished(); + if answered || started.elapsed() >= PIN_ASK_AFTER { + let cancel = poll(answered); + if answered { + break; + } + if cancel { + let _ = connection.shutdown(std::net::Shutdown::Both); + let _ = reply.join(); + bail!("pairing cancelled"); + } + } + std::thread::sleep(PIN_POLL); + } + reply + .join() + .map_err(|_| anyhow!("pairing thread panicked"))? +} + /// Every reply carries `status_code`; anything but 200 is an error whose /// text is in `status_message`. fn check(xml: &str) -> Result<()> { @@ -676,6 +751,186 @@ mod tests { assert_eq!(ecb(&cipher, &ct, false), vec![1u8; 32]); assert_eq!(urlencode("Example Mac"), "Example%20Mac"); } + + use parking_lot::Mutex; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Instant; + + /// One identity for every fake pairing; it plays the PC's certificate + /// too, which is all the first reply has to carry. + fn identity() -> &'static Identity { + static ID: std::sync::OnceLock = std::sync::OnceLock::new(); + ID.get_or_init(|| { + let dir = std::env::temp_dir().join(format!( + "latch-fake-pair-{}-{:08x}", + std::process::id(), + rand::random::() + )); + let id = Identity::load_or_create(&dir).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + id + }) + } + + type Seen = Arc>>; + + /// A stand-in for Sunshine's pairing port on 127.0.0.1. Each request's + /// target is recorded with when it arrived; `reply` answers it, taking + /// its time if it likes (a held first request), or says `None` to hold + /// the connection until the client closes it. + fn fake_host(reply: impl Fn(&str) -> Option + Send + Sync + 'static) -> (u16, Seen) { + use std::io::Read; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen: Seen = Arc::default(); + let reply = Arc::new(reply); + let log = seen.clone(); + std::thread::spawn(move || { + for mut s in listener.incoming().flatten() { + let (reply, log) = (reply.clone(), log.clone()); + std::thread::spawn(move || { + let Ok(Some(req)) = http::read_request(&mut s) else { + return; + }; + log.lock().push((req.path.clone(), Instant::now())); + match reply(&req.path) { + Some(xml) => { + let _ = http::write_bytes(&mut s, 200, "text/xml", xml.as_bytes()); + } + None => { + let _ = s.read(&mut [0u8; 1]); + } + } + }); + } + }); + (port, seen) + } + + fn arrived(seen: &Seen, phrase: &str) -> Option { + seen.lock() + .iter() + .find(|(path, _)| path.contains(phrase)) + .map(|(_, at)| *at) + } + + const NOT_PAIRED: &str = r#"0"#; + + fn client(port: u16) -> Client<'static> { + Client::new(identity(), IpAddr::from([127, 0, 0, 1]), None) + .unwrap() + .with_ports(port, port) + } + + /// The stuzha deadlock: the PIN is entered on the PC while whatever + /// hands it over keeps failing. The handshake must go on as soon as + /// the PC answers the first request, not when the hand-off gives up. + #[test] + fn the_handshake_goes_on_as_soon_as_the_pc_answers() { + let entered = Arc::new(AtomicBool::new(false)); + let cert = hex(identity().cert_pem.as_bytes()); + let (port, seen) = fake_host({ + let entered = entered.clone(); + move |path| { + if path.contains("phrase=getservercert") { + let t = Instant::now(); + while !entered.load(Ordering::SeqCst) && t.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(5)); + } + Some(format!( + r#"1{cert}"# + )) + } else if path.contains("clientchallenge=") { + Some(NOT_PAIRED.into()) + } else { + Some(r#""#.into()) + } + } + }); + let mut polls = Vec::new(); + let mut entered_at = None; + let err = client(port) + .pair_cancellable("1234", "test", |answered| { + polls.push(answered); + // Someone types the PIN on the PC; the hand-off never + // reports success and never cancels. + if entered_at.is_none() { + entered_at = Some(Instant::now()); + entered.store(true, Ordering::SeqCst); + } + false + }) + .unwrap_err(); + assert_eq!(err.to_string(), "pairing failed at the challenge step"); + let challenged = arrived(&seen, "clientchallenge=").expect("phase 2 was attempted"); + let waited = challenged.duration_since(entered_at.unwrap()); + assert!(waited < Duration::from_secs(1), "phase 2 waited {waited:?}"); + assert_eq!(polls.last(), Some(&true), "the last poll hears the answer"); + assert!(polls[..polls.len() - 1].iter().all(|a| !a), "{polls:?}"); + assert!( + arrived(&seen, "/unpair").is_some(), + "a failed pairing unpairs" + ); + } + + #[test] + fn cancel_ends_a_held_first_request_at_once() { + let (port, seen) = fake_host(|path| { + if path.contains("phrase=getservercert") { + None + } else { + Some(r#""#.into()) + } + }); + let start = Instant::now(); + let err = client(port) + .pair_cancellable("1234", "test", |_| { + start.elapsed() >= Duration::from_millis(300) + }) + .unwrap_err(); + assert_eq!(err.to_string(), "pairing cancelled"); + // Cancelled at 300 ms; well under the 90 s the request could be held. + assert!( + start.elapsed() < Duration::from_secs(10), + "{:?}", + start.elapsed() + ); + assert!( + arrived(&seen, "/unpair").is_some(), + "a cancelled pairing unpairs" + ); + } + + #[test] + fn a_refused_first_request_surfaces_at_once() { + let (port, _) = fake_host(|_| Some(NOT_PAIRED.into())); + let start = Instant::now(); + let mut polls = Vec::new(); + let err = client(port) + .pair_cancellable("1234", "test", |answered| { + polls.push(answered); + false + }) + .unwrap_err(); + assert_eq!(err.to_string(), "the PC refused to start pairing"); + // At once, against the 75 s the PIN hand-off used to stand in + // front of it: loose enough for a loaded CI runner. + assert!( + start.elapsed() < Duration::from_secs(10), + "{:?}", + start.elapsed() + ); + assert_eq!(polls, [true], "the caller hears that the wait is over"); + } + + #[test] + fn a_held_request_that_times_out_means_nobody_entered_the_pin() { + let e = held_request_error(anyhow!("timed out")); + assert!(e.downcast_ref::().is_some(), "{e:#}"); + assert_eq!(e.to_string(), "nobody entered the PIN on the PC in time"); + let e = held_request_error(anyhow!("connection closed")); + assert_eq!(e.to_string(), "connection closed"); + } } /// Against the Sunshine on this machine, with Latch Host entering the PIN: diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index b17f9d0..4e1c035 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -8,4 +8,9 @@ description = "Shared egui theme and widgets for the Latch host and client" [dependencies] latch-core.workspace = true -egui.workspace = true +# accesskit: the custom slider answers a screen reader's increment and +# decrement, as egui's own does. eframe turns the feature on anyway. +egui = { workspace = true, features = ["accesskit"] } +# The parser egui's fonts use, to check a system font before handing it +# over: egui panics on one it cannot read. +ab_glyph = "0.2" diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 933beed..14e0ac0 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -307,7 +307,20 @@ pub fn apply(ctx: &egui::Context) { // Popups, menus and tooltips appear and go at once: a fade would ask // for repaints the stream view does not want. style.animation_time = 0.1; - ctx.set_style(style); + // Latch has one look, dark, whatever the system's appearance. egui + // keeps a style per theme and picks one from the system's every frame, + // so on a Mac or PC in Light mode it drew with its stock light style, + // which has no caption or title text styles: the first caption panicked. + ctx.set_theme(egui::ThemePreference::Dark); + ctx.set_style_of(egui::Theme::Light, style.clone()); + ctx.set_style_of(egui::Theme::Dark, style); + // A menu whose button is no longer drawn (the toolbar folded into More, + // the stream ended, the page changed) would stay open in egui's memory + // with nothing on screen, blocking the stream's input and Escape. + ctx.on_end_pass( + "latch.orphan_menu", + Arc::new(crate::widgets::close_orphan_menu), + ); } fn font(bytes: &'static [u8]) -> Arc { @@ -377,10 +390,68 @@ fn fonts() -> FontDefinitions { for (family, list) in families { fonts.families.insert(family, list); } + // Last of all, the system's Korean and CJK font: none of the above has + // Hangul, so a Korean PC's error messages or an app named in Korean + // came out as empty boxes. + if let Some(bytes) = cjk_fallback() { + fonts + .font_data + .insert(CJK.into(), Arc::new(FontData::from_static(bytes))); + for list in fonts.families.values_mut() { + list.push(CJK.into()); + } + } fonts } -fn visuals() -> Visuals { +const CJK: &str = "SystemCJK"; + +/// The system font with Hangul and the CJK ideographs, read once per +/// process (it is 15-20 MB) and kept; None when the machine has none of +/// the usual ones, or the file does not parse (egui would panic on it). +fn cjk_fallback() -> Option<&'static [u8]> { + static FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); + *FONT.get_or_init(|| { + let bytes = cjk_candidates().iter().find_map(|p| usable_font(p))?; + Some(&*Box::leak(bytes.into_boxed_slice())) + }) +} + +/// The font file at `path`, if egui will be able to read it. +fn usable_font(path: &std::path::Path) -> Option> { + let bytes = std::fs::read(path).ok()?; + ab_glyph::FontRef::try_from_slice_and_index(&bytes, 0).ok()?; + Some(bytes) +} + +/// Where each OS keeps a font that covers Korean, first choice first. On a +/// Mac the smaller AppleGothic comes before Apple SD Gothic Neo, whose +/// collection of nine weights is 55 MB. +fn cjk_candidates() -> Vec { + use std::path::PathBuf; + if cfg!(target_os = "macos") { + vec![ + "/System/Library/Fonts/Supplemental/AppleGothic.ttf".into(), + "/System/Library/Fonts/AppleSDGothicNeo.ttc".into(), + ] + } else if cfg!(windows) { + let windir = std::env::var_os("WINDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + vec![windir.join("Fonts").join("malgun.ttf")] + } else { + [ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + ] + .map(PathBuf::from) + .to_vec() + } +} + +pub(crate) fn visuals() -> Visuals { let p = PALETTE; let mut v = Visuals::dark(); v.override_text_color = None; @@ -514,6 +585,57 @@ mod tests { // it, so it is always seen against the background. } + #[test] + fn a_light_mode_system_still_gets_the_dark_style() { + let ctx = egui::Context::default(); + apply(&ctx); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + // Captions use a text style only Latch's style has; egui's stock + // light style panicked on the first one. + let _ = ctx.run( + egui::RawInput { + system_theme: Some(theme), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + crate::caption(ui, "caption"); + crate::small_print(ui, "small print"); + }); + }, + ); + assert_eq!(ctx.theme(), egui::Theme::Dark); + assert_eq!(ctx.style().visuals.panel_fill, PALETTE.bg); + } + } + + #[test] + fn korean_text_is_drawn_when_the_system_has_a_font_for_it() { + let ctx = egui::Context::default(); + apply(&ctx); + let _ = ctx.run(Default::default(), |_| {}); + let has = ctx.fonts(|f| f.has_glyphs(&FontId::proportional(14.0), "연결이 거부되었습니다")); + assert_eq!(has, cjk_fallback().is_some()); + if cfg!(target_os = "macos") { + assert!(has, "every Mac has AppleGothic"); + } + } + + #[test] + fn a_font_file_that_does_not_parse_is_skipped() { + let dir = std::env::temp_dir().join(format!("latch-font-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let bad = dir.join("bad.ttf"); + std::fs::write(&bad, b"not a font at all").unwrap(); + assert!(usable_font(&bad).is_none()); + assert!(usable_font(&dir.join("missing.ttf")).is_none()); + let geist = include_bytes!("../assets/Geist-Regular.ttf"); + let good = dir.join("good.ttf"); + std::fs::write(&good, geist).unwrap(); + assert_eq!(usable_font(&good).as_deref(), Some(&geist[..])); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn the_scales_step_on_the_grid() { for s in [ diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index fcdb000..0fb761d 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -1131,7 +1131,7 @@ pub fn keep_in_view(response: &Response) { /// Where the menu open now sits on screen, or the visible part of an open /// [`select`]'s list, for layout checks. pub fn open_menu_rect(ctx: &egui::Context) -> Option { - let (id, rect): (Id, Rect) = ctx.data(|d| d.get_temp(open_menu_key()))?; + let (id, rect, _): (Id, Rect, u64) = ctx.data(|d| d.get_temp(open_menu_key()))?; ctx.memory(|m| m.is_popup_open(id)).then_some(rect) } @@ -1139,6 +1139,27 @@ fn open_menu_key() -> Id { Id::new("latch.open_menu") } +/// Note that the menu or list `id` was drawn at `rect` in this pass. +fn drawn_menu(ctx: &egui::Context, id: Id, rect: Rect) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(open_menu_key(), (id, rect, pass))); +} + +/// Run at the end of every pass (see [`crate::apply`]): close a menu or +/// list that egui still holds open but nothing drew this pass. Its button +/// went away (the toolbar folded into More, the stream ended, the page +/// changed), so nothing on screen could close it, and an open popup keeps +/// the stream's toolbar up and its keys and mouse from the PC. Popups that +/// are not Latch's own are left alone. +pub(crate) fn close_orphan_menu(ctx: &egui::Context) { + let Some((id, _, pass)) = ctx.data(|d| d.get_temp::<(Id, Rect, u64)>(open_menu_key())) else { + return; + }; + if pass != ctx.cumulative_pass_nr() && ctx.memory(|m| m.is_popup_open(id)) { + ctx.memory_mut(|m| m.close_popup()); + } +} + /// Open a popup menu under `trigger` while it is toggled on. A real egui /// popup: `any_popup_open` is true while it shows, Escape and a click /// outside close it. It hangs from the trigger's nearer edge, so a menu at @@ -1146,8 +1167,18 @@ fn open_menu_key() -> Id { /// it never runs past the window's top or bottom: it opens upwards when /// there is more room above, and scrolls when neither side has room for /// all of it (see [`place_popup`]). -fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R) -> Option { - let id = trigger.id.with("menu"); +/// +/// The menu is known by its `label`, not by the button's place: egui names +/// a widget by its position among its siblings, so a toolbar that gained a +/// tag, or a machine list that gained a row above, handed an open menu to +/// another button (another machine's Sleep). +fn popup_menu( + ui: &mut Ui, + trigger: &Response, + label: &str, + add: impl FnOnce(&mut Ui) -> R, +) -> Option { + let id = Id::new("latch.menu").with(label); if trigger.clicked() { ui.memory_mut(|m| m.toggle_popup(id)); } @@ -1191,9 +1222,13 @@ fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R ctx.request_discard("menu moved to fit the window"); } } - ctx.data_mut(|d| d.insert_temp(open_menu_key(), (id, shown.response.rect))); + drawn_menu(&ctx, id, shown.response.rect); let outside = trigger.clicked_elsewhere() && shown.response.clicked_elsewhere(); - if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Escape that closes the menu is used up here: otherwise the stream + // forwards it to the PC once the menu has gone, and a panel behind the + // menu closes on it too. + let escape = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if outside || escape { ui.memory_mut(|m| m.close_popup()); } Some(shown.inner.inner) @@ -1202,7 +1237,7 @@ fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R /// A quiet button with a chevron that opens a menu. pub fn menu_button(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> Option { let trigger = labelled_button(ui, Kind::Quiet, label, Some(Icon::ChevronDown), label); - popup_menu(ui, &trigger, add) + popup_menu(ui, &trigger, label, add) } /// An icon button that opens a menu; `label` is its accessible name. @@ -1213,7 +1248,7 @@ pub fn icon_menu( add: impl FnOnce(&mut Ui) -> R, ) -> Option { let trigger = icon_button(ui, icon, label); - popup_menu(ui, &trigger, add) + popup_menu(ui, &trigger, label, add) } fn menu_row(ui: &mut Ui, label: &str, checked: Option, enabled: bool) -> Response { @@ -1540,6 +1575,8 @@ pub fn select( let above = y - h / 2.0 - space::XS - (screen.top() + space::SM); let chrome = ui.spacing().menu_margin.sum().y + 2.0 * ui.visuals().window_stroke.width; let height = (below.max(above) - chrome).clamp(size::CONTROL, 320.0); + let selected: WidgetText = selected.into(); + let shown = selected.text().to_owned(); let mut list = None; let out = egui::ComboBox::from_id_salt(id) .selected_text(selected) @@ -1555,6 +1592,9 @@ pub fn select( ); }) .show_ui(ui, |ui| { + // The chosen row is marked by its wash and full-strength text: + // accent text on the accent wash read at 3.7:1, under AA. + ui.visuals_mut().selection.stroke = stroke(1.0, P.text); let out = clipped_scroll(ui, "latch.select", height, |ui| { ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().button_padding = Vec2::new(space::SM, 6.0); @@ -1564,15 +1604,33 @@ pub fn select( out.inner }); keep_in_view(&out.response); + // egui names the control with an empty string; a screen reader said + // only "pop up button". Name it after its setting row, and give the + // choice as its value. + let name: Option = ui.ctx().data(|d| d.get_temp(row_label_key())); + let enabled = ui.is_enabled(); + out.response.widget_info(|| WidgetInfo { + label: Some(name.clone().unwrap_or_else(|| shown.clone())), + current_text_value: Some(shown.clone()), + enabled, + ..WidgetInfo::new(WidgetType::ComboBox) + }); if let Some(rect) = list { // How egui's ComboBox names its popup. let popup = out.response.id.with("popup"); - ui.ctx() - .data_mut(|d| d.insert_temp(open_menu_key(), (popup, rect))); + drawn_menu(ui.ctx(), popup, rect); + // egui has just closed the list on Escape without using the key + // up: the Stream settings panel behind it would close on it too. + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); } out.inner } +/// Where [`setting_row`] leaves its label for the control it holds. +fn row_label_key() -> Id { + Id::new("latch.row_label") +} + /// A row in a [`select`]'s list: chosen when clicked, marked while chosen, /// and scrolled into view when Tab reaches it. pub fn select_option( @@ -1640,11 +1698,46 @@ pub fn slider( *value = value.saturating_sub(step); } } + // A screen reader's increment and decrement (VoiceOver's VO-Up and + // VO-Down) and its "set to", as egui's own slider takes them. + { + use egui::accesskit::{Action, ActionData}; + let (inc, dec, set) = ui.input(|i| { + ( + i.num_accesskit_action_requests(response.id, Action::Increment), + i.num_accesskit_action_requests(response.id, Action::Decrement), + i.accesskit_action_requests(response.id, Action::SetValue) + .filter_map(|r| match &r.data { + Some(ActionData::NumericValue(v)) => Some(*v), + _ => None, + }) + .last(), + ) + }); + *value = value.saturating_add(inc as u32).saturating_sub(dec as u32); + if let Some(v) = set { + *value = v.round().clamp(f64::from(lo), f64::from(hi)) as u32; + } + } *value = (*value).clamp(lo, hi); if *value != before { response.mark_changed(); } response.widget_info(|| WidgetInfo::slider(ui.is_enabled(), f64::from(*value), label)); + let now = *value; + ui.ctx().accesskit_node_builder(response.id, |b| { + use egui::accesskit::Action; + b.set_min_numeric_value(f64::from(lo)); + b.set_max_numeric_value(f64::from(hi)); + b.set_numeric_value_step(1.0); + b.add_action(Action::SetValue); + if now < hi { + b.add_action(Action::Increment); + } + if now > lo { + b.add_action(Action::Decrement); + } + }); if ui.is_rect_visible(rect) { let t = (*value - lo) as f32 / (hi - lo).max(1) as f32; let x = egui::lerp(rail.left()..=rail.right(), t); @@ -1826,12 +1919,18 @@ pub fn setting_row( } else { (size::ROW, 13.0) }; - split_row(ui, h, top, control, |ui| { + // The label is drawn after the control (see `split_row`); a select in + // the row takes its accessible name from here. + let key = row_label_key(); + ui.ctx().data_mut(|d| d.insert_temp(key, label.to_owned())); + let r = split_row(ui, h, top, control, |ui| { ui.label(RichText::new(label).color(P.text)); if let Some(h) = hint { caption(ui, h); } - }) + }); + ui.ctx().data_mut(|d| d.remove::(key)); + r } /// A settings row whose control is too wide to sit beside its label: the @@ -2118,8 +2217,11 @@ fn paint_mark(ui: &Ui, rect: egui::Rect, ink: Color32, accent: Color32) { } /// A paced activity indicator: unlike egui's immediate-repaint spinner this -/// asks for at most 30 frames a second, and only while it is on screen, so -/// it stays bounded when the low-latency stream renderer has vsync off. +/// asks for at most 30 frames a second, and only while it is inside the +/// visible part of the window, so it stays bounded when the low-latency +/// stream renderer has vsync off. While the window is not the one in front +/// (behind another, minimised) it turns four times a second: a page left +/// on a spinner for hours redrew the whole window 30 times a second. pub fn spinner(size: f32, color: egui::Color32) -> impl egui::Widget { move |ui: &mut egui::Ui| { let (rect, response) = @@ -2134,13 +2236,19 @@ pub fn spinner(size: f32, color: egui::Color32) -> impl egui::Widget { .collect(); ui.painter() .add(egui::Shape::line(points, egui::Stroke::new(1.5_f32, color))); - ui.ctx() - .request_repaint_after(std::time::Duration::from_millis(33)); + ui.ctx().request_repaint_after(spinner_pace( + ui.input(|i| i.viewport().focused.unwrap_or(true)), + )); } response } } +/// How soon a [`spinner`] asks to be drawn again. +fn spinner_pace(focused: bool) -> std::time::Duration { + std::time::Duration::from_millis(if focused { 33 } else { 250 }) +} + #[cfg(test)] mod tests { use super::*; @@ -2210,6 +2318,230 @@ mod tests { assert!(!place_popup(screen, high, 900.0, false).above); } + /// A context with Latch's style, after one empty pass. + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::apply(&ctx); + let _ = ctx.run(Default::default(), |_| {}); + ctx + } + + fn escape() -> egui::Event { + egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + } + } + + fn input(events: Vec) -> egui::RawInput { + egui::RawInput { + events, + ..Default::default() + } + } + + fn menu_id(label: &str) -> Id { + Id::new("latch.menu").with(label) + } + + #[test] + fn escape_that_closes_a_menu_is_used_up() { + let ctx = context(); + ctx.memory_mut(|m| m.open_popup(menu_id("Keys"))); + let mut later = None; + let _ = ctx.run(input(vec![escape()]), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + menu_button(ui, "Keys", |ui| menu_item(ui, "Escape")); + later = Some(ui.input(|i| i.key_pressed(egui::Key::Escape))); + }); + }); + assert!( + !ctx.memory(|m| m.any_popup_open()), + "Escape closes the menu" + ); + assert_eq!(later, Some(false), "and nothing after it sees the key"); + } + + #[test] + fn a_menu_stays_with_its_button_when_the_widgets_before_it_change() { + let ctx = context(); + ctx.memory_mut(|m| m.open_popup(menu_id("More for Gaming-PC"))); + for extra in [0, 1, 3] { + let _ = ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + // A row added above shifts every position-based id. + for _ in 0..extra { + let _ = ui.button("Connect"); + } + icon_menu(ui, Icon::More, "More for Gaming-PC", |ui| { + menu_item(ui, "Sleep") + }); + }); + }); + assert!( + open_menu_rect(&ctx).is_some(), + "still open with {extra} widgets before it" + ); + } + } + + #[test] + fn a_menu_whose_button_is_gone_is_closed() { + let ctx = context(); + ctx.memory_mut(|m| m.open_popup(menu_id("Keys"))); + let _ = ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + menu_button(ui, "Keys", |ui| menu_item(ui, "Escape")); + }); + }); + assert!(ctx.memory(|m| m.any_popup_open())); + // The toolbar folds into More: Keys is not drawn any more. + let _ = ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let _ = ui.button("More"); + }); + }); + assert!(!ctx.memory(|m| m.any_popup_open())); + // A popup that is not one of Latch's is left alone. + let theirs = Id::new("someone else's popup"); + ctx.memory_mut(|m| m.open_popup(theirs)); + let _ = ctx.run(Default::default(), |_| {}); + assert!(ctx.memory(|m| m.is_popup_open(theirs))); + } + + /// One pass of a "Resolution" setting row holding a select: what the + /// screen reader is told about the select, whether anything after it + /// still sees an Escape, and the id of the select's list. + fn resolution_row( + ctx: &egui::Context, + events: Vec, + ) -> (Option, bool, Id) { + let mut later = false; + let mut list = Id::NULL; + let out = ctx.run(input(events), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + setting_row(ui, "Resolution", None, |ui| { + // How egui's ComboBox names its list. + list = ui.make_persistent_id(Id::new("res")).with("popup"); + let mut pick = 1080; + select(ui, "res", "1080p", 160.0, |ui| { + for r in [720, 1080] { + select_option(ui, &mut pick, r, format!("{r}p")); + } + }); + }); + later = ui.input(|i| i.key_pressed(egui::Key::Escape)); + }); + }); + let node = out.platform_output.accesskit_update.and_then(|u| { + u.nodes + .into_iter() + .map(|(_, n)| n) + .find(|n| n.role() == egui::accesskit::Role::ComboBox) + }); + (node, later, list) + } + + #[test] + fn a_select_is_named_after_its_row_and_reads_its_choice() { + let ctx = context(); + ctx.enable_accesskit(); + let (node, _, _) = resolution_row(&ctx, vec![]); + let node = node.expect("the select is in the tree"); + assert_eq!(node.label(), Some("Resolution")); + assert_eq!(node.value(), Some("1080p")); + } + + #[test] + fn escape_that_closes_a_select_list_is_used_up() { + let ctx = context(); + let (_, _, list) = resolution_row(&ctx, vec![]); + ctx.memory_mut(|m| m.open_popup(list)); + let (_, later, _) = resolution_row(&ctx, vec![]); + assert!(!later); + assert!(ctx.memory(|m| m.is_popup_open(list)), "the list is open"); + let (_, later, _) = resolution_row(&ctx, vec![escape()]); + assert!( + !ctx.memory(|m| m.any_popup_open()), + "Escape closes the list" + ); + assert!(!later, "and the panel behind it does not see the key"); + // With the list closed, Escape is left for whoever wants it. + let (_, later, _) = resolution_row(&ctx, vec![escape()]); + assert!(later); + } + + #[test] + fn the_chosen_option_in_a_list_reads_at_aa() { + let v = crate::theme::visuals(); + let wash = v.selection.bg_fill; + for surface in [P.surface, P.raised] { + let a = f32::from(wash.a()) / 255.0; + let behind = lerp_color(surface, wash.to_opaque(), a); + assert!(contrast(P.text, behind) >= 4.5); + } + } + + #[test] + fn a_screen_reader_can_step_and_set_the_slider() { + use egui::accesskit::{Action, ActionData, ActionRequest}; + let ctx = context(); + ctx.enable_accesskit(); + let mut value = 50u32; + let pass = |ctx: &egui::Context, events, value: &mut u32| { + let mut id = Id::NULL; + let out = ctx.run(input(events), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + id = slider(ui, value, 2..=150, 180.0, "Bitrate").id; + }); + }); + (out, id) + }; + let (out, id) = pass(&ctx, vec![], &mut value); + let node = out + .platform_output + .accesskit_update + .unwrap() + .nodes + .into_iter() + .find(|(n, _)| *n == id.value().into()) + .map(|(_, n)| n) + .expect("the slider is in the tree"); + assert_eq!(node.min_numeric_value(), Some(2.0)); + assert_eq!(node.max_numeric_value(), Some(150.0)); + assert!(node.supports_action(Action::Increment)); + let request = |action, data| { + egui::Event::AccessKitActionRequest(ActionRequest { + action, + target: id.value().into(), + data, + }) + }; + pass(&ctx, vec![request(Action::Increment, None)], &mut value); + assert_eq!(value, 51); + pass(&ctx, vec![request(Action::Decrement, None)], &mut value); + pass(&ctx, vec![request(Action::Decrement, None)], &mut value); + assert_eq!(value, 49); + pass( + &ctx, + vec![request( + Action::SetValue, + Some(ActionData::NumericValue(400.0)), + )], + &mut value, + ); + assert_eq!(value, 150, "a value past the end is held to the range"); + } + + #[test] + fn a_spinner_slows_down_when_its_window_is_not_in_front() { + assert_eq!(spinner_pace(true).as_millis(), 33); + assert!(spinner_pace(false) >= std::time::Duration::from_millis(250)); + } + #[test] fn tones_have_distinct_marks_except_the_two_plain_ones() { assert_eq!(Tone::Neutral.color(), Tone::Info.color()); diff --git a/deploy/native/README.md b/deploy/native/README.md index d600ac9..26f294c 100644 --- a/deploy/native/README.md +++ b/deploy/native/README.md @@ -25,9 +25,21 @@ The `.service` files in this directory are installed in ordering without conflicting with Latch's automatic registration. `wait-engine.py` is installed as `/usr/local/libexec/latch-wait-engine.py`. +`latch-engine.service` and `latch-display.service` carry `CPUWeight=1000`: +the encoder and the X server it copies frames from get ten times the CPU +share of other user services, which matters on a small VPS that also runs +agents. Latch Host keeps the engine on the Linux profile +(`crates/host/src/setup.rs`, `LINUX_ENGINE_CONF`: no frame-rate floor, +`superfast`) and tells viewers the X screen's size and, on fewer than four +cores, a 30 fps ceiling. + `latch.service` pulls in `latch-engine.service`, `latch-desktop.service`, and `latch-display.service`. PulseAudio runs as -its standard user service. Check them as `ubuntu`: +its standard user service. Logging out of XFCE starts a fresh session +(`latch-desktop.service` restarts whenever it ends, retrying more slowly +if it keeps failing); like any automatic restart of a unit the others +require, it also restarts the engine and Latch Host, so a viewer +reconnects. Check them as `ubuntu`: ```sh systemctl --user status latch latch-engine latch-desktop latch-display @@ -45,6 +57,51 @@ in the `input` group: Sunshine must use its XTest fallback for this Xvfb session Granting `/dev/uinput` access makes input go to kernel devices that Xvfb does not consume, resulting in visible video but no functioning mouse or keyboard. +## Installing and updating latch-host + +Releases publish the Linux host as `latch-host-linux-arm64.tar.gz` and +`latch-host-linux-x64.tar.gz`, each with a `.sha256` beside it. They are built +and tested on Ubuntu 24.04 by the release workflow, so they need glibc 2.39 +or later. The Mac pushes updates to Windows hosts only; a Linux host is +updated by installing the next release the same way: + +```sh +tag=vX.Y.Z # the release to install +arch=arm64 # or x64 +base=https://github.com/MrBeldum/latch/releases/download/$tag +curl -fLO "$base/latch-host-linux-$arch.tar.gz" +curl -fLO "$base/latch-host-linux-$arch.tar.gz.sha256" +sha256sum -c "latch-host-linux-$arch.tar.gz.sha256" +tar xzf "latch-host-linux-$arch.tar.gz" +sudo install -m 0755 "latch-host-linux-$arch/latch-host" /usr/local/bin/latch-host +systemctl --user restart latch.service +``` + +The archive also carries this directory as `native/`. Re-running +`migrate-legacy.sh` from it (safe twice) reinstalls the units and +reloads systemd, which is how a change to them (such as the desktop's +restart policy) reaches a running node without restarting anything. + +## A desktop that takes the viewer's size + +Xvfb cannot change size, so the relay desktop is streamed at 1920x1080 (a +smaller size the Mac asks for is that desktop scaled down). An X server that +can resize (Xorg with the dummy driver) can instead switch to each stream's +size: give Sunshine a `global_prep_cmd` that runs `xrandr` with +`SUNSHINE_CLIENT_WIDTH` and `SUNSHINE_CLIENT_HEIGHT`, and set +`LATCH_DISPLAY_FOLLOWS_VIEWER=1` in `latch.service.d/desktop.conf` so Latch +Host stops reporting a fixed capture size. Use a mode at 60 Hz for every size +(`xrandr --newmode` with a pixel clock of 60 x htotal x vtotal): the +compositor and browsers draw at the mode's rate. + +That needs Sunshine 2026.1014 or later. Earlier ones send absolute pointer +positions through an XTest fallback that keeps the screen size it saw at +startup, so after a resize the pointer lands at that size's scale (twice as +far at 540p, two thirds as far at a 3024x1964 Match screen). Relative +movement, and a desktop that stays at its startup size, are fine with any +version. The Mac launches the Desktop again whenever it asks for another +size than it last did, since a prep command runs only at a launch. + ## Upgrading from versions before 4.1.0 Nodes set up before 4.1.0 use earlier names for their services, host binary, diff --git a/deploy/native/latch-desktop.service b/deploy/native/latch-desktop.service index 7062fbf..3a5242f 100644 --- a/deploy/native/latch-desktop.service +++ b/deploy/native/latch-desktop.service @@ -3,14 +3,22 @@ Description=Latch native XFCE desktop Requires=latch-display.service After=latch-display.service Wants=pulseaudio.service +# A headless desktop must never give up: instead of a start limit the +# retries slow down (2 s, growing to 2 min; see RestartSteps below). +StartLimitIntervalSec=0 [Service] Environment=DISPLAY=:1 Environment=XAUTHORITY=%t/latch.Xauthority ExecStartPre=/bin/sh -c 'for i in $(seq 1 50); do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 0.2; done; exit 1' ExecStart=/usr/bin/startxfce4 -Restart=on-failure -RestartSec=3 +# Log Out in XFCE ends the session with status 0, which on-failure would +# leave stopped: every later stream would show an empty screen until +# someone started this unit over SSH. A logout brings a fresh session back. +Restart=always +RestartSec=2 +RestartSteps=6 +RestartMaxDelaySec=120 [Install] WantedBy=default.target diff --git a/deploy/native/latch-display.service b/deploy/native/latch-display.service index dda40e9..bb2ed48 100644 --- a/deploy/native/latch-display.service +++ b/deploy/native/latch-display.service @@ -4,6 +4,8 @@ Description=Latch native headless display [Service] ExecStartPre=/bin/sh -c 'umask 077; touch "%t/latch.Xauthority"; /usr/bin/xauth -f "%t/latch.Xauthority" add :1 . "$(/usr/bin/mcookie)"' ExecStart=/usr/bin/Xvfb :1 -screen 0 1920x1080x24 -nolisten tcp -auth %t/latch.Xauthority +# Every captured frame is a copy the X server makes; see latch-engine.service. +CPUWeight=1000 Restart=on-failure RestartSec=3 diff --git a/deploy/native/latch-engine.service b/deploy/native/latch-engine.service index ef115cc..34f3062 100644 --- a/deploy/native/latch-engine.service +++ b/deploy/native/latch-engine.service @@ -10,6 +10,10 @@ Environment=XAUTHORITY=%t/latch.Xauthority WorkingDirectory=%h/.config/sunshine ExecStartPre=/bin/sh -c 'for i in $(seq 1 50); do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 0.2; done; exit 1' ExecStart=/usr/bin/sunshine %h/.config/sunshine/sunshine.conf +# The encoder runs on the CPU. When other user services (an agent, a build) +# want every core too, frames that miss their interval are dropped: give the +# engine ten times their share. It cannot raise its own priority as a user. +CPUWeight=1000 Restart=on-failure RestartSec=3 diff --git a/deploy/node/Dockerfile b/deploy/node/Dockerfile index 4d2fcf5..e571d26 100644 --- a/deploy/node/Dockerfile +++ b/deploy/node/Dockerfile @@ -16,7 +16,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* WORKDIR /src COPY . . -RUN cargo build --release -p latch-host +# --locked: build exactly the dependencies Cargo.lock pins and CI tested. +RUN cargo build --locked --release -p latch-host FROM ubuntu:24.04 ARG SUNSHINE_TAG=v2026.906.222525 @@ -43,7 +44,9 @@ RUN set -eux; \ apt-get update && apt-get install -y --no-install-recommends /tmp/sunshine.deb \ && rm -rf /tmp/sunshine.deb /var/lib/apt/lists/* -# Optional Tailscale userspace client so this container is its own node. +# Optional Tailscale client so this container is its own node. It runs with +# kernel networking (see docker-compose.yml), so Latch Host sees each peer's +# own tailnet address. COPY --from=docker.io/tailscale/tailscale:stable /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/ COPY --from=build /src/target/release/latch-host /usr/local/bin/latch-host diff --git a/deploy/node/NODE.md b/deploy/node/NODE.md index 66afd8c..a2cfa0c 100644 --- a/deploy/node/NODE.md +++ b/deploy/node/NODE.md @@ -13,7 +13,12 @@ It is not the packet relay. The relay kit stays in `deploy/relay/`. - Xvfb + XFCE (a virtual 1920×1080 desktop) - Sunshine (the same streaming engine Latch uses on Windows and macOS) - Latch's control service on TCP 47850 -- Optional Tailscale in userspace mode (`TS_AUTHKEY`) +- Optional Tailscale (`TS_AUTHKEY`), with kernel networking: the compose + file gives the container `/dev/net/tun` and `NET_ADMIN`. Latch Host + decides who may pair and use the control service from each peer's own + tailnet address; userspace Tailscale would hand it every peer from + `127.0.0.1`, which it trusts as the machine itself, so the node refuses + to start Tailscale without the device. ## One node @@ -47,12 +52,19 @@ issues a new node per `up`). Do not put `tag:relay` on these nodes. ## Same host as the peer relay The relay container uses host networking and `tag:relay`. A node container -should **not** use host networking: userspace Tailscale gives it a second -identity, so it shows up as a machine instead of being hidden as a relay. +should **not** use host networking: its own network namespace and its own +tailscaled give it a second identity, so it shows up as a machine instead +of being hidden as a relay. ## Build notes -The image compiles `latch-host` and installs the Ubuntu 24.04 Sunshine +The image compiles `latch-host` (with `--locked`, from the dependencies +`Cargo.lock` pins) and installs the Ubuntu 24.04 Sunshine package pinned to the same tag Windows setup uses (`v2026.906.222525`). The VPS in this project is aarch64; the Dockerfile picks the arm64 or amd64 `.deb` from `TARGETARCH`. + +`.dockerignore` keeps every `.env` out of the build context: the build +stage copies the whole repository, and a layer keeps what it copied, so an +auth key there would travel with any image built `--target build` and +pushed or saved. diff --git a/deploy/node/docker-compose.yml b/deploy/node/docker-compose.yml index 4f0352b..aec3dfc 100644 --- a/deploy/node/docker-compose.yml +++ b/deploy/node/docker-compose.yml @@ -29,8 +29,18 @@ services: - node-config:/root/.config - node-local:/root/.local - node-tailscale:/var/lib/tailscale - # Reachable only through Tailscale userspace. Do not publish GameStream - # ports on the public internet. + # Tailscale with kernel networking: peers reach Latch Host from their own + # tailnet address, which is what it checks before pairing a device or + # answering at all. In userspace mode every peer would arrive from + # 127.0.0.1 and look like this machine itself. The container keeps its + # own network namespace, so it is still its own tailnet machine. + cap_add: + - NET_ADMIN + - NET_RAW + devices: + - /dev/net/tun:/dev/net/tun + # Reachable only through Tailscale. Do not publish GameStream ports on + # the public internet. shm_size: "1gb" volumes: diff --git a/deploy/node/entrypoint.sh b/deploy/node/entrypoint.sh index 554d46e..284dd85 100755 --- a/deploy/node/entrypoint.sh +++ b/deploy/node/entrypoint.sh @@ -15,8 +15,15 @@ if [[ ! -x "$ENGINE" ]]; then fi if [[ -n "${TS_AUTHKEY:-}" ]]; then - log "starting Tailscale (userspace) as ${TS_HOSTNAME:-latch-node}" - tailscaled --tun=userspace-networking --state=/var/lib/tailscale/tailscaled.state \ + # Kernel networking only. In userspace mode tailscaled hands every peer's + # connection to Latch Host from 127.0.0.1, which it trusts as this machine: + # any device that can reach the node could pair and stop the service. + if [[ ! -c /dev/net/tun ]]; then + log "Tailscale needs /dev/net/tun and NET_ADMIN (see docker-compose.yml); not starting without them" + exit 1 + fi + log "starting Tailscale as ${TS_HOSTNAME:-latch-node}" + tailscaled --state=/var/lib/tailscale/tailscaled.state \ --socket=/var/run/tailscale/tailscaled.sock >/var/log/tailscaled.log 2>&1 & for _ in $(seq 1 20); do tailscale status >/dev/null 2>&1 && break @@ -38,17 +45,22 @@ startxfce4 >/var/log/xfce.log 2>&1 & sleep 1 CONF=/root/.config/sunshine/sunshine.conf +# X11 capture delivers a picture every frame interval, so a frame-rate floor +# above the stream's rate can only re-encode the last picture: 0 is +# Sunshine's default (half the stream's rate). superfast keeps keyframe text legible at +# the 30 fps a small CPU is asked for. Latch Host keeps the same keys current +# (crates/host/src/setup.rs, ENGINE_CONF and LINUX_ENGINE_CONF). cat >"$CONF" <<'EOF' system_tray = disabled origin_web_ui_allowed = pc credentials_file = /root/.config/sunshine/latch-web.json max_bitrate = 0 -minimum_fps_target = 60 +minimum_fps_target = 0 fec_percentage = 20 packetsize = 1184 amd_rc = cbr vaapi_rc = cbr -sw_preset = ultrafast +sw_preset = superfast sw_tune = zerolatency min_threads = 4 capture = x11 diff --git a/deploy/node/selftest.sh b/deploy/node/selftest.sh index 3e0e61c..d8f0e0c 100755 --- a/deploy/node/selftest.sh +++ b/deploy/node/selftest.sh @@ -14,9 +14,24 @@ need "$ROOT/Dockerfile" "latch-host" need "$ROOT/Dockerfile" "ubuntu:24.04" need "$ROOT/Dockerfile" "v2026.906.222525" need "$ROOT/entrypoint.sh" "latch-host --background" -need "$ROOT/entrypoint.sh" "userspace-networking" +need "$ROOT/docker-compose.yml" "/dev/net/tun:/dev/net/tun" +need "$ROOT/docker-compose.yml" "NET_ADMIN" +need "$ROOT/entrypoint.sh" "/dev/net/tun" +# Userspace Tailscale makes every peer look like 127.0.0.1 to Latch Host, +# which trusts loopback as this machine. +if grep -v '^[[:space:]]*#' "$ROOT/entrypoint.sh" | grep -q 'userspace-networking'; then + echo "entrypoint.sh must not run Tailscale in userspace mode" >&2 + fail=1 +fi +# Every .env holds a Tailscale auth key and must stay out of the build +# context, at any depth (deploy/node/.env, deploy/relay/.env). +REPO="$(cd "$ROOT/../.." && pwd)" +need "$REPO/.dockerignore" '^\*\*/\.env$' +need "$REPO/.dockerignore" '^\*\*/\.env\.\*$' +need "$ROOT/Dockerfile" "cargo build --locked" need "$ROOT/entrypoint.sh" "packetsize = 1184" -need "$ROOT/entrypoint.sh" "sw_preset = ultrafast" +need "$ROOT/entrypoint.sh" "sw_preset = superfast" +need "$ROOT/entrypoint.sh" "minimum_fps_target = 0" need "$ROOT/entrypoint.sh" "latch-engine" need "$ROOT/entrypoint.sh" '"name": "Desktop"' need "$ROOT/docker-compose.yml" "latch-node" diff --git a/deploy/relay/.env.example b/deploy/relay/.env.example index 453d3a1..f396c3d 100644 --- a/deploy/relay/.env.example +++ b/deploy/relay/.env.example @@ -30,5 +30,6 @@ RELAY_STATIC_ENDPOINTS= # applying relay prefs. Bump it if you log in by hand from the container logs. RELAY_WAIT_SECS=180 -# Pin the image if you want reproducible deploys. Peer relay needs 1.86 or later. +# Pin the image if you want reproducible deploys; a pin is updated only by bumping +# it, while install-relay.sh pulls latest on every run. Peer relay needs 1.86 or later. # TS_IMAGE=tailscale/tailscale:latest diff --git a/deploy/relay/RELAY.md b/deploy/relay/RELAY.md index 74a47df..48f8893 100644 --- a/deploy/relay/RELAY.md +++ b/deploy/relay/RELAY.md @@ -169,6 +169,16 @@ new public IP after a stop/start on an instance without a reserved address. opens the new port), then remove the old rule by hand. The relay picks the new port up on restart. +**Updating Tailscale.** The relay is an internet-facing tailscaled, and nothing inside +the container updates it: Compose fetches `tailscale/tailscale:latest` only when it has +no copy at all. Re-running `./install-relay.sh` pulls the current image first and +recreates the container only when the image changed (it prints the version it ends up +on); `docker compose pull && docker compose up -d` does the same by hand. The state +volume keeps the node, its address and its prefs. Streams through the relay fall back to +DERP for the few seconds the container restarts, so do it at a quiet time, monthly or +whenever Tailscale ships a security fix. A pinned `TS_IMAGE` is updated only by bumping +the pin. + **Turning it off:** `docker compose down`. Streams fall back to DERP by themselves. To keep the node but stop relaying: `docker exec latch-relay tailscale set --relay-server-port=""`. @@ -183,7 +193,7 @@ To keep the node but stop relaying: `docker exec latch-relay tailscale set --rel | `Relay prefs NOT applied` in the logs | The node never authenticated inside `RELAY_WAIT_SECS`. Finish the login, then `docker restart latch-relay`. | | Node authenticates but peers still say `relay "xxx"` | The grant is missing, a peer is older than 1.86, or the UDP port is not actually reachable. Check the cloud security list, and `iptables -L INPUT --line-numbers` for a rule *above* `REJECT`. | | Relay node disappears after a restart | The auth key was ephemeral. Create a non-ephemeral one. | -| `peer relay unavailable` at startup | The image is older than 1.86. Unpin or bump `TS_IMAGE`. | +| `peer relay unavailable` at startup | The image is older than 1.86. Bump a pinned `TS_IMAGE`, or re-run `./install-relay.sh`, which pulls `latest` (an unpinned image is only fetched on a pull). | ## Files @@ -194,4 +204,4 @@ To keep the node but stop relaying: `docker exec latch-relay tailscale set --rel | `selftest.sh` | Stub-driven regression tests for the installer's failure paths. No docker, no network, no root. | | `.env.example` | Every knob, no secrets. Copy to `.env`. | | `.env` | **Your auth key. Gitignored. Never commit it, never paste it into a shell with tracing on.** | -| `install-relay.sh` | Idempotent installer described above. | +| `install-relay.sh` | Idempotent installer described above. Pulls the image on every run. | diff --git a/deploy/relay/install-relay.sh b/deploy/relay/install-relay.sh index 9b3a38f..8bcf864 100755 --- a/deploy/relay/install-relay.sh +++ b/deploy/relay/install-relay.sh @@ -365,6 +365,12 @@ config_failed() { exit 1 } +# Compose only fetches an image it does not have, so without this a relay +# would run the tailscaled it was first installed with for good. A failed +# pull (registry down) keeps the copy already here; up -d recreates the +# container only when the image changed, and the state volume keeps the node. +say "pulling the relay image" +docker compose pull --quiet relay || warn "could not pull the relay image; starting the one already here" say "starting relay" docker compose up -d @@ -399,6 +405,7 @@ echo docker exec "$CONTAINER" tailscale status || true echo say "RelayServerPort = $actual, read back from the node's own prefs" +say "tailscale $(docker exec "$CONTAINER" tailscale version 2>/dev/null | head -n 1 || true)" docker exec "$CONTAINER" tailscale debug prefs 2>/dev/null | grep -i 'relayserverstatic' || true if [ -n "$FIREWALL_MISSING" ]; then echo diff --git a/deploy/relay/selftest.sh b/deploy/relay/selftest.sh index fac8a3c..92f93dc 100755 --- a/deploy/relay/selftest.sh +++ b/deploy/relay/selftest.sh @@ -79,6 +79,7 @@ make_stubs() { # $1 = sandbox dir wait=${wait%\"}; wait=${wait#\"} printf 'name: latch-relay\nservices:\n relay:\n environment:\n RELAY_PORT: "%s"\n RELAY_WAIT_SECS: "%s"\n' "$port" "$wait" exit 0 ;; + pull) [ "${STUB_PULL_FAIL:-}" = 1 ] && exit 1; exit 0 ;; up) echo "Container latch-relay Started"; exit 0 ;; esac ;; volume) @@ -102,6 +103,8 @@ make_stubs() { # $1 = sandbox dir fi; exit 0 ;; "latch-relay tailscale status") echo "100.64.0.1 latch-relay me@ linux -"; exit 0 ;; + "latch-relay tailscale version") + printf '1.104.1\n tailscale commit: stub\n'; exit 0 ;; esac ;; inspect) cat "$STUB_DIR/started_at"; exit 0 ;; @@ -236,6 +239,7 @@ check "exits nonzero" "$([ "$rc" -ne 0 ] && echo 0 || echo 1)" check "names the bad value in full, untruncated" "$(has "$d/out" "RELAY_PORT='99999999'" && echo 0 || echo 1)" check "no firewall rule was touched" "$(! grep -q '^ip6\?tables -[IA]' "$d/calls.log" && echo 0 || echo 1)" check "no container was started" "$(! has "$d/calls.log" 'compose up' && echo 0 || echo 1)" +check "no image was pulled" "$(! has "$d/calls.log" 'compose pull' && echo 0 || echo 1)" echo "== case 2b: malformed RELAY_WAIT_SECS ==" d=$(sandbox) @@ -244,6 +248,7 @@ rc=0 run_case "$d" || rc=$? check "exits nonzero" "$([ "$rc" -ne 0 ] && echo 0 || echo 1)" check "no container was started" "$(! has "$d/calls.log" 'compose up' && echo 0 || echo 1)" +check "no image was pulled" "$(! has "$d/calls.log" 'compose pull' && echo 0 || echo 1)" echo "== case 3: authenticated node, no auth key in .env ==" d=$(sandbox) @@ -265,6 +270,7 @@ run_case "$d" || rc=$? check "exits nonzero" "$([ "$rc" -ne 0 ] && echo 0 || echo 1)" check "prints resume instructions" "$(has "$d/out" 'Re-run ./install-relay.sh' && echo 0 || echo 1)" check "no container was started" "$(! has "$d/calls.log" 'compose up' && echo 0 || echo 1)" +check "no image was pulled" "$(! has "$d/calls.log" 'compose pull' && echo 0 || echo 1)" echo "== case 5: an ACCEPT rule exists but sits below the catch-all REJECT ==" d=$(sandbox) @@ -391,6 +397,26 @@ check "exits nonzero" "$([ "$rc" -ne 0 ] && echo 0 || echo 1)" check "restarted once, not twice" "$([ "$(cat "$d/restarts")" = 1 ] && echo 0 || echo 1)" check "does not print Done" "$(! has "$d/out" 'Done. Next' && echo 0 || echo 1)" +echo "== case 14: the image is pulled before the relay starts ==" +d=$(sandbox) +printf 'TS_AUTHKEY=tskey-not-a-real-key\nRELAY_PORT=40000\n' >"$d/kit/.env" +rc=0 +STUB_PREFS_PORT=40000 run_case "$d" || rc=$? +check "exits zero" "$([ "$rc" -eq 0 ] && echo 0 || echo 1)" +pull_at=$(grep -n 'compose pull' "$d/calls.log" | head -n 1 | cut -d: -f1) +up_at=$(grep -n 'compose up' "$d/calls.log" | head -n 1 | cut -d: -f1) +check "pulls, then starts" "$([ -n "$pull_at" ] && [ -n "$up_at" ] && [ "$pull_at" -lt "$up_at" ] && echo 0 || echo 1)" +check "says which tailscale runs" "$(has "$d/out" 'tailscale 1.104.1' && echo 0 || echo 1)" + +echo "== case 15: a failed pull still starts the relay ==" +d=$(sandbox) +printf 'TS_AUTHKEY=tskey-not-a-real-key\nRELAY_PORT=40000\n' >"$d/kit/.env" +rc=0 +STUB_PREFS_PORT=40000 STUB_PULL_FAIL=1 run_case "$d" || rc=$? +check "exits zero" "$([ "$rc" -eq 0 ] && echo 0 || echo 1)" +check "warns about the pull" "$(has "$d/out" 'could not pull the relay image' && echo 0 || echo 1)" +check "starts the relay anyway" "$(has "$d/calls.log" 'compose up' && echo 0 || echo 1)" + echo "== cleanup owns only its workdir ==" check "decoy sibling still present" "$([ -f "$DECOY/keep-me" ] && echo 0 || echo 1)" diff --git a/docs/MACOS.md b/docs/MACOS.md index 3e263f9..dae8af3 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -48,8 +48,9 @@ quarantine flag is how you tell macOS you fetched it deliberately. ``` builds for `aarch64-apple-darwin` (Xcode command line tools and CMake are -needed for the C parts), bundles `dist/Latch.app`, and installs it to -`/Applications`. A locally built app never carries the quarantine flag. +needed for the C parts), bundles `dist.noindex/Latch.app`, and installs it to +`/Applications`. The `.noindex` folder keeps build copies out of app search. +A locally built app never carries the quarantine flag. ### Signing @@ -59,9 +60,9 @@ notarize: ```bash CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./scripts/bundle-macos.sh -ditto -c -k --keepParent dist/Latch.app dist/Latch.zip -xcrun notarytool submit dist/Latch.zip --keychain-profile "notary" --wait -xcrun stapler staple dist/Latch.app +ditto -c -k --keepParent dist.noindex/Latch.app dist.noindex/Latch.zip +xcrun notarytool submit dist.noindex/Latch.zip --keychain-profile "notary" --wait +xcrun stapler staple dist.noindex/Latch.app ``` ## First run @@ -117,16 +118,21 @@ the Mac cursor's position is sent 1:1 instead and nothing is captured. | **Keys** | Ctrl+Alt+Delete, Windows key, Alt+Tab, Escape, Print Screen; and **Command acts as Ctrl** | | **Stats** | The Stream performance panel: frame rate and bitrate against what was asked for, round trip, packet loss, encode, assembly, queue and decode times, and the decoder. **Copy diagnostics** puts it all on the clipboard | | **Full screen** | Toggle; the setting decides how a session starts | -| **PC** (or **Mac**, **Machine**) | Sleep, Restart…, Shut down… (the latter two ask first); **Update Latch Host…** for a PC whose host is older than 3.1 | +| **PC** (or **Mac**, **Machine**) | Sleep…, Restart…, Shut down…, each asking first, when the machine's owner allows them; Sleep only on a Windows PC, the one kind a wake packet can bring back. **Update Latch Host…** for a PC whose host is older than 3.1 | | **Stream settings** | The same controls as Settings: quality profile, resolution, frame rate, bitrate and codec. **Apply and reconnect** restarts the session with them in a second or two; Escape or the close button leaves them as they were | | **Disconnect** | End the session | On a narrow window everything but **Stream settings** and **Disconnect** folds into one **More** menu. -Everything else on the keyboard goes to the PC as pressed. With **Command -acts as Ctrl** on (the default), ⌘C, ⌘V, ⌘Z and the rest do on Windows what -they do on the Mac, and ⌘ can stay held across several of them. +Everything else on the keyboard goes to the PC as pressed, in the order it +was pressed. With **Command acts as Ctrl** on (the default), ⌘C, ⌘V, ⌘Z and +the rest do on Windows and Linux what they do on the Mac, and ⌘ can stay +held across several of them; a Mac at the other end always gets ⌘ as +Command. While the stream has the keys, ⌘Q, ⌘H and ⌘+/⌘−/⌘0 go to the PC +too (⌘Q closes the PC's window, not Latch): quit or hide Latch from the menu +bar or the Dock, after **Disconnect**, or while the toolbar or a panel has +the focus. ⌘Tab and ⌘Space stay on the Mac. ### Clipboard @@ -134,8 +140,34 @@ With Latch Host 3.1 or newer on the PC, the clipboard follows you both ways: text copied on the PC is in the Mac's clipboard a second later, and ⌘V on the PC pastes the text the Mac has (Latch sends it to the PC first, then presses Ctrl+V there). Text only, up to 32 KB; an image or a file on -either clipboard is left alone. Short notices under the toolbar say when -something crossed. With an older host, ⌘V pastes what the PC last copied. +either clipboard is left alone, so ⌘V with a screenshot on the Mac's +clipboard, or after copying a file on the PC, pastes what the PC itself last +copied. Keys typed right after ⌘V wait for the paste to land (up to two +seconds). Short notices under the toolbar say when something crossed. With +an older host, ⌘V pastes what the PC last copied. + +A Linux machine gets ⌘C, ⌘X and ⌘V as Ctrl+Insert, Shift+Delete and +Shift+Insert, which GTK and Qt apps, Firefox, Chromium and terminals all +take for copy, cut and paste; Ctrl+C in a terminal would interrupt the +running program. Latch Host there puts pasted text in the selection as +well as the clipboard, because terminals paste the selection on +Shift+Insert. To copy in a terminal, select the text and press ⌘C. + +A Linux VPS draws a desktop of one fixed size and encodes on the CPU, so +Latch never asks it for more than that size: a larger resolution setting +gets the desktop's own size, a smaller one (720p, say) is scaled down. On +a machine with fewer than four cores, Latch asks for at most 30 fps at +1080p, and more for a smaller picture in proportion to the pixels it +saves: up to 67 fps at 720p, 43 at 900p, 120 at 540p. To get 60 fps from +such a machine, pick 720p (or 540p) and 60 fps. + +## Only connecting from this Mac + +On a Mac, **Share this Mac** on the **Sharing** page decides whether Latch +runs in the background so other machines can connect here. Turn it off and +this Mac only connects to the others: the background service and the +streaming engine stop, opening Latch no longer starts them, and nothing of +Latch runs once its window closes. **Share this Mac** turns it back on. ### Laggy? Look at the path @@ -177,13 +209,13 @@ it from this network (see below). | Setting | What it changes | |---------|-----------------| | Quality | **Recommended** (the default) asks for this screen's own size at 50 Mbps, on every path. **Smooth** is 1080p · 60 fps · 20 Mbps, **Balanced** match screen · 60 · 50, **Sharp** match screen · 60 · 100. **Custom** uses the rows below; changing one of them switches to it | -| Resolution | **Match screen** is this display's own pixel size (3024×1964 on a 14" MacBook Pro), so the picture fills it exactly. **1080p**, **1440p** and **4K** are the standard 16:9 sizes, 1920×1080, 2560×1440 and 3840×2160; on a display of another shape they leave a bar above and below. The list shows the pixel size of each. The PC switches its display to the size asked for; a PC with no monitor needs a virtual display that lists that size, which setup on the PC arranges for the Virtual Display Driver | +| Resolution | **Match screen** is this display's own pixel size (3024×1964 on a 14" MacBook Pro), so the picture fills it exactly. **540p**, **720p**, **900p**, **1080p**, **1440p** and **4K** are the standard 16:9 sizes, 960×540, 1280×720, 1600×900, 1920×1080, 2560×1440 and 3840×2160; on a display of another shape they leave a bar above and below. The sizes below 1080p are for a PC that encodes in software or a slow network: fewer pixels leave more bitrate for each and a higher frame rate. The list shows the pixel size of each. The PC switches its display to the size asked for; a PC with no monitor needs a virtual display that lists that size, which setup on the PC arranges for the Virtual Display Driver | | Frame rate | 30 to 240; the PC's display is switched to match when it can | | Bitrate | 2 to 150 Mbps; arrow keys step by 1, Shift-arrow by 10. The encoder holds this rate. Large swings mean the path is dropping packets | | Codec | Auto (HEVC when both ends can), HEVC, H.264 | | Open streams full screen | How a session starts | | Capture the mouse | The toolbar's Mouse choice, remembered | -| Command acts as Ctrl | Off makes ⌘ the Windows key (Mac only) | +| Command acts as Ctrl | Off makes ⌘ the Windows key on Windows and Linux machines; a Mac always gets ⌘ as Command | | App to open | The app on the PC to launch; "Desktop" is the whole screen. The list fills in after the first connection | | Offer to sleep a machine after a session | Off by default. Asleep, Tailscale is off there; this Mac can only wake it from its own network | | Keep Latch up to date | Updates this app and the Windows PCs' Latch Host; **Check now** checks at once | diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 102eaaa..6fc08a7 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -18,13 +18,16 @@ streaming engine, which ships in the same zip, does the capture. 1. Unzip the release and run `latch-host.exe`, or run `install-host.ps1` to copy it (and the bundled engine archive) to `%LOCALAPPDATA%\Latch`, add shortcuts, and register the background service to start at logon. + The script stops a running Latch Host to replace it and starts the + service again; `-NoStart` only keeps the window closed. 2. Open the **Sharing** tab and click **Set up as administrator**. One UAC prompt runs a script that: - unpacks the bundled engine archive into `%ProgramFiles%\Latch\engine` (downloads that pinned release if the - archive is not beside the exe), and gives its executables Latch's - name and icon so Task Manager and the volume mixer show "Latch - Streaming" (their copyright and licence strings are kept); + archive is not beside the exe, or is not that release), and gives + its executables Latch's name and icon so Task Manager and the volume + mixer show "Latch Streaming" (their copyright and licence strings are + kept); - writes the engine's settings: tray and toasts off, web UI on loopback only, the PC's display switched to the size and frame rate a Mac asks for and put back at disconnect, no bitrate ceiling, and frames kept @@ -47,12 +50,40 @@ streaming engine, which ships in the same zip, does the capture. The script's transcript is in `%LOCALAPPDATA%\Latch\setup.log` and is shown on the Sharing tab if something fails. Every step is idempotent; run setup again after fixing whatever it complained about. + + Run setup at the PC, not through a stream. Windows shows the + administrator prompt on its secure desktop, which a stream can neither + show nor click, so a viewer would see a frozen picture until someone at + the PC answered it; setup also restarts the engine, which ends the + stream. While a viewer is connected the first click only says so, and a + second click within two minutes runs setup anyway. 3. The Sharing tab says **Shared** with a green dot. Leave the window closed; the service keeps running. Advanced engine settings are not exposed anywhere in Latch: Latch configures the engine itself. +### Over SSH + +An SSH (or WinRM) session runs in session 0, which has no desktop: an +engine or a Latch Host started there could not see the screen. Both steps +account for it when run from an administrator's SSH session: + +- `install-host.ps1` starts the background service in the signed-in + user's session through a one-off scheduled task (`LatchHostStart`, no + trigger; it stays registered and does nothing on its own). If nobody is + signed in it says so, and the Run key starts the service at the next + sign-in. +- `latch-host.exe --setup-elevated --local-app-data "%LOCALAPPDATA%"` + installs the `LatchEngineUser` logon task for that account, stops the + engine service, and has the task start the engine in the signed-in + user's session. With nobody signed in, the `LatchStream` service keeps + running the engine (as SYSTEM, which streams the console) until setup + next runs at the PC. + +Check the result by streaming from the Mac: `/v1/status` saying the engine +runs does not show which session it runs in (`/v1/display` does). + ## Slow streams: the network, or the encoder The **This PC** section of the Sharing tab has a **Network** line from @@ -86,7 +117,7 @@ requests, all JSON: | `GET /v1/clipboard` | The clipboard as text, with Windows' clipboard sequence number | | `POST /v1/clipboard {"text"}` | Replaces the clipboard, so a ⌘V on the Mac pastes the Mac's text | | `POST /v1/update` | A new `latch-host.exe`; see Updates | -| `GET /v1/display` | Why a capture may be black: monitors, the desktop's sampled brightness, HDR state, the engine's capture settings | +| `GET /v1/display` | Why a capture may be black: monitors, the desktop's sampled brightness, HDR state, the engine's capture settings, which session each engine process runs in, and the engine's service under any name it has had | | `POST /v1/display {"advanced_color"}` | Turns the HDR (advanced colour) desktop off or on | A request is answered only if it comes from loopback or from a Tailscale @@ -140,6 +171,30 @@ driver was installed after setup, choose **Run setup again** on the Sharing tab; its setup list says when sizes are missing. Installing a display does not require pairing the Mac again. +With capture set to automatic, Latch prefers the Virtual Display Driver +even while a physical monitor is connected. During a stream the virtual +display is the only active display, so games follow its requested size; +the engine restores the physical displays when the stream disconnects. +A driver reinstalled under a new ID is followed. A capture display chosen +in the engine's own settings is kept, and the display option Latch set for +the virtual display goes back to setup's; after setup, Latch changes no +other engine setting. Standard 1080p, 1440p and 4K streams are 16:9 and +leave bars on a taller Mac screen; **Match screen** uses the Mac's aspect +ratio. + +While a viewer is connected and the session uses a single display, Latch +keeps a custom game cursor visible in the picture. A cursor whose visible +pixels sit to the right of or below a transparent hotspot would otherwise +disappear at the bottom or right edge; Latch stops it where its nearest +visible pixel is still on screen. A cursor drawn at its hotspot, like every +standard Windows pointer and most game cursors, still reaches the last +pixel, so edge scrolling and controls at the edge keep working. The bounds +follow changes in resolution and cursor shape, respect the game's own +confinement, and release when the game hides its cursor for camera control +or the viewer disconnects. The check runs as a windowless process beside +Latch in the desktop session, without elevation; a Latch started over SSH +runs it through a user task, `LatchCursorUser`. + ## Using an engine you installed yourself Latch streams through whichever compatible engine is already on the PC diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index bae6b50..8d6356f 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -18,14 +18,12 @@ if [[ -z "$BIN" ]]; then echo "build the app first: cargo build --release -p latch-host" >&2 exit 1 fi -APP="$ROOT/dist/Latch.app" +# Spotlight excludes .noindex folders, so this build copy stays out of app search. +APP="$ROOT/dist.noindex/Latch.app" # The workspace version, so the bundle cannot drift from the binary inside it. VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -# dist/ is a staging copy. Keep Spotlight from listing it next to -# /Applications/Latch.app. -touch "$ROOT/dist/.metadata_never_index" cp "$BIN" "$APP/Contents/MacOS/Latch" chmod +x "$APP/Contents/MacOS/Latch" # The Finder/Dock icon: the ink tile with the logo knocked out, built by @@ -53,6 +51,8 @@ cat > "$APP/Contents/Info.plist" <NSHighResolutionCapable NSLocalNetworkUsageDescription Latch sends the wake-up packet to your PC over the local network. + NSAppleEventsUsageDescription + Latch asks System Events to restart or shut down this Mac when you choose that from another machine. PLIST @@ -64,8 +64,21 @@ IDENTITY="${CODESIGN_IDENTITY:--}" if [[ "$IDENTITY" == "-" ]]; then codesign --force --sign "$IDENTITY" "$APP" else - # Notarization requires the hardened runtime and a secure timestamp. - codesign --force --options runtime --timestamp --sign "$IDENTITY" "$APP" + # Notarization requires the hardened runtime and a secure timestamp. The + # hardened runtime blocks Apple Events unless the app is entitled to send + # them: a shared Mac restarts and shuts down through System Events. + ENTITLEMENTS="$(mktemp -t latch-entitlements).plist" + cat > "$ENTITLEMENTS" <<'ENT' + + + + + com.apple.security.automation.apple-events + + +ENT + codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$APP" + rm -f "$ENTITLEMENTS" fi codesign --verify --deep --strict "$APP" if [[ "$IDENTITY" == "-" ]]; then diff --git a/scripts/install-host.ps1 b/scripts/install-host.ps1 index cdae8dc..82cc4c6 100644 --- a/scripts/install-host.ps1 +++ b/scripts/install-host.ps1 @@ -2,6 +2,12 @@ # engine archive into %LOCALAPPDATA%\Latch, add Start Menu and Desktop # shortcuts, and open it. The app itself runs the one administrator step # (engine, firewall, Wake-on-LAN) when you click "Set up this PC". +# +# -NoStart leaves the window closed. The background service this stops to +# replace the exe always comes back, in the signed-in user's session: run +# over SSH (session 0, no desktop) it is started there through a one-off +# scheduled task, since a copy started in the SSH session could not see the +# desktop and ends with it. [CmdletBinding()] param( [string]$Exe = "", @@ -81,6 +87,38 @@ if (Test-Path $OldDir) { Write-Host "Shortcut: Desktop\Latch Host.lnk" Write-Host "Open Latch Host and click 'Set up this PC' once." Write-Host "If you already ran setup from the unzipped copy, run it once more from this install so the firewall allows $DestExe." -if (-not $NoStart) { +if ((Get-Process -Id $PID).SessionId -eq 0) { + # The task runs as this user, unelevated like the Run key, in the session + # they are signed in to. Task Scheduler's defaults would stop the host + # after 72 hours, or on battery, and run it at below-normal priority. + $TaskName = "LatchHostStart" + $up = $false + try { + $action = New-ScheduledTaskAction -Execute $DestExe -Argument "--background" -WorkingDirectory $DestDir + $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -Priority 5 + Register-ScheduledTask -TaskName $TaskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null + Start-ScheduledTask -TaskName $TaskName + foreach ($i in 1..20) { + Start-Sleep -Milliseconds 750 + try { + Invoke-RestMethod -Uri "http://127.0.0.1:47850/v1/status" -UseBasicParsing -TimeoutSec 2 | Out-Null + $up = $true + break + } catch {} + } + } catch { + Write-Host "Could not start Latch Host in the signed-in session: $_" + } + # The task has no trigger, so left registered it never runs by itself; + # removing it could end the copy it just started. + if ($up) { + Write-Host "Latch Host is running in the signed-in user's session." + } else { + Write-Host "Nobody is signed in to this PC's desktop: Latch Host starts at the next sign-in." + } +} elseif ($NoStart) { + Start-Process -FilePath $DestExe -ArgumentList "--background" -WorkingDirectory $DestDir +} else { Start-Process -FilePath $DestExe -WorkingDirectory $DestDir } diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh index fbcdc58..2d88996 100755 --- a/scripts/install-macos.sh +++ b/scripts/install-macos.sh @@ -7,7 +7,7 @@ cd "$ROOT" rustup target add aarch64-apple-darwin >/dev/null cargo build --release -p latch-host --target aarch64-apple-darwin bash "$ROOT/scripts/bundle-macos.sh" -APP="$ROOT/dist/Latch.app" +APP="$ROOT/dist.noindex/Latch.app" if [[ ! -d "$APP" ]]; then echo "bundle failed" >&2 exit 1 @@ -15,8 +15,7 @@ fi xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true rm -rf /Applications/Latch.app cp -R "$APP" /Applications/Latch.app -# The staging bundle in dist/ would otherwise appear as a second Latch -# in Spotlight and Launchpad. Unregister it; the real app is in /Applications. +# Unregister the staging copy; the installed app is in /Applications. LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" "$LSREGISTER" -u "$APP" 2>/dev/null || true "$LSREGISTER" -f /Applications/Latch.app 2>/dev/null || true