diff --git a/.cargo/config.toml b/.cargo/config.toml index 77c4490..45b4bab 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,9 +3,9 @@ CMAKE_POLICY_VERSION_MINIMUM = "3.5" # `cargo test` and `cargo run` keep settings, identities and logs under # target/ rather than in the real per-user data directory, so a test run -# never reads or rewrites an installed BroLink's files. A shipped binary -# does not see this; see `brolink_core::config::data_dir`. -BROLINK_DATA_DIR = { value = "target/brolink-data", relative = true } +# never reads or rewrites an installed Latch's files. A shipped binary +# does not see this; see `latch_core::config::data_dir`. +LATCH_DATA_DIR = { value = "target/latch-data", relative = true } # The host is a standalone exe. Without this it needs vcruntime140.dll, # which is not inbox on a fresh Windows install. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 301e77e..ad7143e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,17 +31,17 @@ jobs: shell: powershell run: | $ErrorActionPreference = 'Stop' - $env:BROLINK_DUMP_EXE = "C:\Users\O'Brien `$lab\AppData\Local\BroLink\brolink-host.exe" - $env:BROLINK_DUMP_ADAPTER = "Ethernet 'Realtek' `"2`" `$(x)" - $env:BROLINK_DUMP_ADAPTER_DESC = "Realtek PCIe GbE (`"x`")" + $env:LATCH_DUMP_EXE = "C:\Users\O'Brien `$lab\AppData\Local\Latch\latch-host.exe" + $env:LATCH_DUMP_ADAPTER = "Ethernet 'Realtek' `"2`" `$(x)" + $env:LATCH_DUMP_ADAPTER_DESC = "Realtek PCIe GbE (`"x`")" $files = @('scripts\install-host.ps1', 'crates\host\windows\take-over-engine.ps1') foreach ($migrate in '0', '1') { foreach ($dry in '0', '1') { $out = Join-Path $env:RUNNER_TEMP "setup-$migrate$dry.ps1" - $env:BROLINK_DUMP_SETUP_SCRIPT = $out - $env:BROLINK_DUMP_MIGRATE = $migrate - $env:BROLINK_DUMP_DRY_RUN = $dry - cargo test -q -p brolink-host --lib the_generated_script_can_be_dumped_for_a_syntax_check + $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 if ($LASTEXITCODE -ne 0) { throw "dumping the setup script failed" } $files += $out } @@ -56,8 +56,8 @@ jobs: run: cargo build --workspace --release - uses: actions/upload-artifact@v4 with: - name: brolink-host-windows - path: target/release/brolink-host.exe + name: latch-host-windows + path: target/release/latch-host.exe if-no-files-found: error test-macos: @@ -74,7 +74,7 @@ jobs: - name: test run: cargo test --workspace - name: release app - run: cargo build --release -p brolink-host --target aarch64-apple-darwin + run: cargo build --release -p latch-host --target aarch64-apple-darwin - name: bundle run: bash scripts/bundle-macos.sh @@ -93,7 +93,7 @@ jobs: - name: test run: cargo test --workspace - name: release host - run: cargo build --release -p brolink-host + run: cargo build --release -p latch-host deploy-selftest: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68ba400..6501c90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - name: test run: cargo test --workspace - name: build app - run: cargo build --release -p brolink-host --target aarch64-apple-darwin + run: cargo build --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 @@ -38,11 +38,25 @@ jobs: run: | cp docs/MACOS.md dist/README-MACOS.md cp LICENSE NOTICE dist/ - cd dist && tar czf brolink-macos-arm64.tar.gz BroLink.app README-MACOS.md LICENSE NOTICE + cd dist && tar czf latch-macos-arm64.tar.gz Latch.app README-MACOS.md LICENSE NOTICE + # A 4.0 updater downloads brolink-macos-arm64.tar.gz and installs the + # BroLink.app inside it. The same app under that folder name is what + # lets it update at all; on its first run it moves itself to Latch.app + # and takes over the launch agent (crates/host/src/unix_setup.rs, + # 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 - uses: actions/upload-artifact@v4 with: - name: brolink-macos-arm64 - path: dist/brolink-macos-arm64.tar.gz + name: latch-macos-arm64 + path: | + dist/latch-macos-arm64.tar.gz + dist/brolink-macos-arm64.tar.gz if-no-files-found: error windows: @@ -54,7 +68,7 @@ jobs: - name: test run: cargo test --workspace - name: build - run: cargo build --release -p brolink-host + run: cargo build --release -p latch-host - name: pin matches setup.rs shell: pwsh run: | @@ -74,22 +88,36 @@ jobs: shell: pwsh run: | New-Item -ItemType Directory -Force dist | Out-Null - Copy-Item target\release\brolink-host.exe dist\ + Copy-Item target\release\latch-host.exe dist\ Copy-Item docs\WINDOWS.md dist\README-WINDOWS.md Copy-Item scripts\install-host.ps1, LICENSE, NOTICE dist\ $zip = Join-Path dist $env:ENGINE_ZIP $url = "https://github.com/LizardByte/Sunshine/releases/download/$($env:ENGINE_TAG)/$($env:ENGINE_ZIP)" - curl.exe -sSL -A brolink -o $zip $url + curl.exe -sSL -A latch -o $zip $url if ($LASTEXITCODE -ne 0) { throw "downloading $url failed (curl exit $LASTEXITCODE)" } $got = (Get-FileHash -Algorithm SHA256 -LiteralPath $zip).Hash.ToLower() if ($got -ne $env:ENGINE_SHA256) { throw "engine archive is not $($env:ENGINE_TAG): expected $($env:ENGINE_SHA256), got $got" } - Compress-Archive -Path dist\* -DestinationPath brolink-windows-x64.zip + Compress-Archive -Path dist\* -DestinationPath latch-windows-x64.zip + # A 4.0 Mac takes brolink-host.exe out of brolink-windows-x64.zip and + # sends it to each PC. The new program under the old name is what lets + # it update the PC; nothing in 4.1 or later reads this asset. + - name: package for 4.0 updaters + shell: pwsh + run: | + New-Item -ItemType Directory -Force legacy | Out-Null + Copy-Item target\release\latch-host.exe legacy\brolink-host.exe + Copy-Item docs\WINDOWS.md legacy\README-WINDOWS.md + Copy-Item scripts\install-host.ps1, LICENSE, NOTICE legacy\ + Copy-Item (Join-Path dist $env:ENGINE_ZIP) legacy\ + Compress-Archive -Path legacy\* -DestinationPath brolink-windows-x64.zip - uses: actions/upload-artifact@v4 with: - name: brolink-windows-x64 - path: brolink-windows-x64.zip + name: latch-windows-x64 + path: | + latch-windows-x64.zip + brolink-windows-x64.zip if-no-files-found: error publish: @@ -133,7 +161,7 @@ jobs: esac gh release create "${GITHUB_REF_NAME}" \ --repo "${GITHUB_REPOSITORY}" \ - --title "BroLink ${GITHUB_REF_NAME}" \ + --title "Latch ${GITHUB_REF_NAME}" \ $NOTES \ $PRE \ artifacts/*/* diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd24f5..10b5637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,36 @@ 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 - +## 4.1.0 (2026-10-02) + +- **BroLink is now Latch, by Bardbro.** The app, the repository + (`github.com/MrBeldum/latch`), the release files (`latch-macos-arm64.tar.gz`, + `latch-windows-x64.zip`), the executables (`Latch.app`, `latch-host.exe`), + the services and login items, the environment variables (`LATCH_*`) and + bardbro.com all carry the new name, with a new logo: a square with a + corner cut away, and a blue square in the cut. Bardbro is the + project behind it, for cloud gaming. +- An installed BroLink updates itself to Latch. Settings, the pairing + identity, paired devices and the streaming engine carry over. On a Mac + the app moves from `BroLink.app` to `Latch.app` the first time it runs + and its login item with it; on Linux `latch.service` replaces + `brolink.service`; on Windows the host keeps working in place, and + `install-host.ps1` followed by **Set up as administrator** moves it to + `%LOCALAPPDATA%\Latch`, the `LatchStream` service and the new firewall + rule names. Machines on either name still find each other, and a 4.1 Mac + can still update a 4.0 PC. The release also carries + `brolink-macos-arm64.tar.gz` and `brolink-windows-x64.zip`, which are the + same builds under the old file names, because a 4.0 updater looks for + nothing else. Everything that still says BroLink is in + `crates/core/src/legacy.rs` and `crates/host/src/legacy.rs`. +- `LATCH_DATA_DIR` and `LATCH_GITHUB_TOKEN` replace the `BROLINK_` names, + which still work. The Docker node and relay read `LATCH_*`. +- The About page names Bardbro and links to bardbro.com. The accent + colour for focus and progress is the logo's blue, where it was violet. - The window is redesigned around one design system, in the product site's black, white and hairlines, set in Geist and Geist Mono. Colour now means something: green for ready, amber for needs attention, red - for broken, and the logo's violet only for keyboard focus and + for broken, and the logo's blue only for keyboard focus and progress. Every text colour meets WCAG AA on every surface it sits on; the old footer and captions did not. - Tabs across the top: **Machines**, **Sharing** and **Settings** @@ -65,7 +89,7 @@ GitHub release notes, so each version gets a heading of the form Tailscale account. Since 4.0.0 it answered any machine Tailscale could name, so a machine shared in from someone else's tailnet could pair with the PC, sleep or restart it, read and write its clipboard, and push it a - new `brolink-host.exe`. A tagged server such as the relay VPS belongs to + new `latch-host.exe`. A tagged server such as the relay VPS belongs to no account, so it answers the members of its own tailnet, which keeps a Mac able to open the VPS desktop. - Text copied on a Mac or Linux machine you are streaming from now reaches @@ -77,15 +101,15 @@ GitHub release notes, so each version gets a heading of the form - A Windows PC whose pushed update crashes on start keeps the version it had. The service used to exit as soon as the new executable had been launched, so a replacement that died straight away left the PC with no - BroLink service until someone logged in again. It now waits two seconds, + Latch service until someone logged in again. It now waits two seconds, and puts the previous executable back if the new one has already exited. The PC's log says so, and the Mac warns once instead of sending the same release again every three minutes. - Update downloads try each of a server's addresses in turn. Only the first one the name resolved to was ever tried, so one unreachable address (an IPv6 one on a network without IPv6, say) failed the whole update check. -- `brolink-host.exe`'s version details (Explorer's Details tab) named the - wrong licence, MIT; they now say GPL-3.0-or-later, the licence BroLink +- `latch-host.exe`'s version details (Explorer's Details tab) named the + wrong licence, MIT; they now say GPL-3.0-or-later, the licence Latch and moonlight-common-c are under. - Windows setup no longer breaks on a network adapter whose name holds a `"` or a `$`. The name was written into a double-quoted PowerShell @@ -100,7 +124,7 @@ GitHub release notes, so each version gets a heading of the form would try to send other Windows PCs their update with a Mac-only unzip, failing every few minutes. Its settings now say that updates arrive from the Mac. -- Installing BroLink Host through the stream (for a PC on 3.0) can finish +- Installing Latch Host through the stream (for a PC on 3.0) can finish on a Mac. The Mac served the file from a socket that macOS left non-blocking, so sending the executable stopped as soon as the network buffer filled and the PC rejected the truncated download. @@ -117,10 +141,10 @@ GitHub release notes, so each version gets a heading of the form and the network line only when the NAT itself changes. - When the Mac's sound output changes mid-stream (AirPods connect, say), the stream's stats name the device the sound moved to. macOS already - moved it; BroLink kept naming the old one and logged a warning. + moved it; Latch kept naming the old one and logged a warning. - A host update that lands is no longer reported as a failure. A PC can take the new executable and restart before its reply reaches the Mac; - the Mac then retried, heard "this PC already runs BroLink Host 4.0.2", + the Mac then retried, heard "this PC already runs Latch Host 4.0.2", and showed "Could not update" in red. It now asks the PC which version it runs before calling a push failed. - Cancel stops a connection attempt straight away while the PC is not @@ -176,11 +200,11 @@ GitHub release notes, so each version gets a heading of the form duplicate every ten seconds for the rest of the session and a service launchd started at login is no longer killed by its own registration. Turning the login item off in the panel leaves the running service alone. -- Only a Windows PC accepts a pushed `brolink-host.exe`; a Mac or a +- Only a Windows PC accepts a pushed `latch-host.exe`; a Mac or a container answers 400 instead of trying to swap it in, and the Mac only sends one to Windows machines. - Elevated Windows setup approved with another administrator's password - reads and writes the launching user's BroLink folder, so the engine login + reads and writes the launching user's Latch folder, so the engine login it configures is that user's. - The updater takes the window's locks in the window's order; the reverse could hang the app when an update was ready just as a stream ended. @@ -206,15 +230,15 @@ GitHub release notes, so each version gets a heading of the form packets, no extra 500 kbps tax, and the bitrate is sent at launch as well as over RTSP. - The streaming engine is not a second app. Status, logs, the Dock and - the app list say BroLink; only Desktop is launched. + the app list say Latch; only Desktop is launched. -- BroLink is a mesh. Every install lists every other machine on the +- Latch is a mesh. Every install lists every other machine on the Tailscale account — Windows, Mac, Linux, a VPS container — and Connect opens that desktop. The same app shares this machine: Mac and Linux install the streaming engine; Windows still does. There is no designated host or client role. - `deploy/node/` is a Docker kit for a VPS or any container: a virtual - desktop, the streaming engine, BroLink's control service, and optional + desktop, the streaming engine, Latch's control service, and optional Tailscale userspace so the container is its own machine. Several copies on one host are several machines. The packet relay stays `deploy/relay/`. - Stream bitrate holds the target instead of swinging with the scene. @@ -245,7 +269,7 @@ GitHub release notes, so each version gets a heading of the form - Every Tailscale CLI call is bounded (10 s; the per-request `whois` 2 s), and its output is drained while it runs, so a slow or chatty CLI cannot hang the window or the control service. -- Host setup runs `brolink-host --setup-elevated` after UAC, writes its +- Host setup runs `latch-host --setup-elevated` after UAC, writes its script under `%SystemRoot%\Temp` (administrators only) and passes the engine take-over helper as an encoded command, instead of running a user-writable `setup.ps1`. Repair setup registers the engine service when @@ -254,7 +278,7 @@ GitHub release notes, so each version gets a heading of the form username is not expanded. The logon task for the take-over helper runs with limited rights. - The engine login is written as the engine's own hashed credentials file - (`brolink-web.json`, `credentials_file =` in `sunshine.conf`) instead of + (`latch-web.json`, `credentials_file =` in `sunshine.conf`) instead of `sunshine --creds` on a command line, and the engine's web API is called with the password on stdin (`curl --config -`), so neither the setup log nor the process list shows it. The Docker node does the same through @@ -268,7 +292,7 @@ GitHub release notes, so each version gets a heading of the form until moonlight's detached termination thread has nowhere to call; a decode unit whose buffer list overruns its declared length is refused. - The migration check reads the installed engine's real kind instead of - labelling every install "BroLink". The macOS launch agent and Linux user + labelling every install "Latch". The macOS launch agent and Linux user unit escape the executable path, and a failure to register them is reported instead of ignored. Spawned engine and service processes are reaped. A settings save failure shows in the window. @@ -300,7 +324,7 @@ GitHub release notes, so each version gets a heading of the form - The Dock icon is the bundle icns, in macOS's rounded app-icon shape. eframe was replacing it at runtime with a 64-pixel square of the logo, and the icns had been pre-masked with transparent corners, which macOS - 26 draws as a square plate. `scripts/make-macos-icon.py` now fills the + 26 draws as a square plate. `scripts/make-icons.py` now fills the canvas and lets the system apply the shape. - The Windows host icon (Explorer, taskbar, Alt-Tab, the window, and the in-app header) is the same filled tile. The logo's outer pad had left a @@ -320,7 +344,7 @@ GitHub release notes, so each version gets a heading of the form your relay or through Tailscale's; a long round trip adds delay, not a bitrate or frame-rate cap. The engine's own bitrate ceiling is cleared and it is asked to keep frames flowing while the desktop is still. A PC set up - by an earlier BroLink gets the same profile applied once by the service, + by an earlier Latch gets the same profile applied once by the service, between sessions. The quick profiles are now Smooth (1080p · 60 fps · 12 Mbps), Balanced (match screen · 60 · 35) and Sharp (match screen · 60 · 65). @@ -349,7 +373,7 @@ GitHub release notes, so each version gets a heading of the form - A private Tailscale peer relay you run yourself. `deploy/relay/` is a Docker kit for a VPS (UDP 40000, iptables above the cloud REJECT, state volume, wait-for-Running before `tailscale set`). Tailscale's own DERP - stays the fallback; BroLink adds no relay protocol of its own. Settings + stays the fallback; Latch adds no relay protocol of its own. Settings shows a Relay card with the exact state: no relay on this network, a relay that this device is not granted, or ready. Needs Tailscale 1.86 or later on every device. @@ -357,14 +381,14 @@ GitHub release notes, so each version gets a heading of the form goes up to 1440p60 at 40 Mbps by round trip; the DERP and direct tiers are unchanged. Tagged relay nodes never appear in the PC list. - The Windows streaming engine now unpacks from the pinned lite archive to - `C:\Program Files\BroLink\engine` as the "BroLink Streaming" service: + `C:\Program Files\Latch\engine` as the "Latch Streaming" service: no Apps & Features entry, Start Menu shortcut, tray icon or web-UI link. - Firewall rules are named "BroLink". An engine installed earlier by an + Firewall rules are named "Latch". An engine installed earlier by an MSI is migrated in order — copy state, verify, start, prove it listens, then uninstall the MSI — and keeps its pairing and web login; a `config.bak` is left beside it and the old install folder is removed - once the MSI is gone. The engine's processes appear as "BroLink - Streaming" with BroLink's icon in Task Manager, the volume mixer and + once the MSI is gone. The engine's processes appear as "Latch + Streaming" with Latch's icon in Task Manager, the volume mixer and firewall prompts: setup rewrites their version block and icon in place and keeps their copyright and licence strings. The host offers **Update this PC** after @@ -420,7 +444,7 @@ GitHub release notes, so each version gets a heading of the form A hardening release: both apps parse the network more strictly, update themselves more carefully, and the stream validates what the PC sends -before acting on it. Nothing changes in how you use BroLink. +before acting on it. Nothing changes in how you use Latch. ### Control API @@ -428,7 +452,7 @@ before acting on it. Nothing changes in how you use BroLink. proper HTTP line endings and versions, and are refused when they carry chunked encoding, duplicate framing headers or a malformed `Content-Length`. Bodies grow only as bytes arrive. -- BroLink Host checks who is asking before it reads a single header, +- Latch Host checks who is asking before it reads a single header, keeps at most sixteen connections open, and turns away browser requests (including ones a rebound hostname makes look local). @@ -438,10 +462,10 @@ before acting on it. Nothing changes in how you use BroLink. publishes before installing it, stages the new app on the same volume so a failed copy cannot leave a half-installed app, and no longer passes its own path through a shell when relaunching. -- BroLink Host accepts only a 64-bit Windows executable image, installs +- Latch Host accepts only a 64-bit Windows executable image, installs one update at a time, and puts the previous executable back if the new one fails to start. -- BroLink Host no longer rewrites the Windows power plan every few +- Latch Host no longer rewrites the Windows power plan every few seconds; **Keep this PC awake while plugged in** now does exactly that. ### Stream @@ -473,15 +497,15 @@ you across. - The Mac checks GitHub about every six hours and twenty seconds after it starts (Settings has the switch and **Check now**). A newer app is downloaded, checked against the SHA-256 GitHub publishes and against its - own code signature, moved over `/Applications/BroLink.app` once no + own code signature, moved over `/Applications/Latch.app` once no stream is running, and relaunched. -- The Mac sends the new `brolink-host.exe` to every PC whose BroLink Host +- The Mac sends the new `latch-host.exe` to every PC whose Latch Host reports an older version, over the same Tailscale-authenticated control API that can put the PC to sleep (`POST /v1/update`). The host verifies the digest, that the bytes are a Windows executable and a newer version, swaps the file in beside itself and restarts; a running stream is not interrupted. A PC that is asleep gets it the next time the Mac sees it. -- A PC still on 3.0 has no update route. **PC → Update BroLink Host…** in +- A PC still on 3.0 has no update route. **PC → Update Latch Host…** in the stream toolbar installs 3.1 through the stream: the Mac serves the executable on its own Tailscale address to that PC only, presses Win+R, types one PowerShell line and presses Enter; the script checks the @@ -504,12 +528,12 @@ you across. HEVC reference-frame invalidation, so a lost packet costs a repair frame rather than a keyframe. An explicit HEVC choice no longer falls back to H.264. Stats show packet loss, path and quality. -- BroLink Host reads which encoder Sunshine settled on from its log; the +- Latch Host reads which encoder Sunshine settled on from its log; the lobby and the host window warn when it is software encoding. ### Clipboard -- BroLink Host serves `/v1/clipboard` (text only, up to 32 KB). While +- Latch Host serves `/v1/clipboard` (text only, up to 32 KB). While streaming, text copied on the PC is in the Mac's clipboard within a second, and ⌘V on the PC first sends the Mac's text across, then presses Ctrl+V there. Notices under the toolbar say when something @@ -524,7 +548,7 @@ you across. ### Staying reachable -- BroLink Host starts with Windows by default and sets that again at every +- Latch Host starts with Windows by default and sets that again at every service start unless the owner turned it off. It holds the PC awake while plugged in (a new setting, on by default; setup also zeroes the AC sleep timers). The Mac's offer to sleep the PC after a session is off by @@ -539,7 +563,7 @@ you across. format (48 kHz stereo f32 that the DAC refused was silence, with the reason only in the log). A short pre-roll avoids a click on the first packet; stats and a toast say where sound goes, or why it does not. -- BroLink Host reads Sunshine's log for a failed audio capture (no +- Latch Host reads Sunshine's log for a failed audio capture (no speakers, a missing sink). The lobby and the host window say so, with how to add a virtual device. @@ -564,7 +588,7 @@ you across. held. - VideoToolbox recreates its session after sleep/wake or a GPU reset instead of staying on a black picture until reconnect. -- An old BroLink Host window no longer kills a just-updated service in a +- An old Latch Host window no longer kills a just-updated service in a loop; it relaunches itself from the new exe. - Setup scripts are UTF-8 with a BOM, so a Korean Windows username or adapter name is not mangled, and the Sunshine installer path is quoted @@ -593,7 +617,7 @@ you across. ## 2.0.0 (2026-09-05) -- Built on Sunshine, Moonlight and Tailscale; BroLink keeps wake, pairing +- Built on Sunshine, Moonlight and Tailscale; Latch keeps wake, pairing and power. ## 1.2.0 (2026-09-05) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d648c2f..16bc832 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,16 +12,16 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` -`BROLINK_SKIP_C=1` skips the C build for a quick cross-target check, for -example `BROLINK_SKIP_C=1 cargo check -p brolink-client --target +`LATCH_SKIP_C=1` skips the C build for a quick cross-target check, for +example `LATCH_SKIP_C=1 cargo check -p latch-client --target aarch64-apple-darwin` from Windows after `rustup target add`. -`cargo test` and `cargo run` keep BroLink's settings, pairing identity and -logs in `target/brolink-data` (`.cargo/config.toml` sets -`BROLINK_DATA_DIR`), so neither touches an installed BroLink's files. To +`cargo test` and `cargo run` keep Latch's settings, pairing identity and +logs in `target/latch-data` (`.cargo/config.toml` sets +`LATCH_DATA_DIR`), so neither touches an installed Latch's files. To point a development build at an installed one's data on purpose, set -`BROLINK_DATA_DIR` yourself, for example to `%LOCALAPPDATA%\BroLink` on -the development PC before `cargo run -p brolink-host -- --background`. +`LATCH_DATA_DIR` yourself, for example to `%LOCALAPPDATA%\Latch` on +the development PC before `cargo run -p latch-host -- --background`. ## Where things live @@ -31,11 +31,11 @@ the development PC before `cargo run -p brolink-host -- --background`. windows or Tailscale. The vendored moonlight-common-c, what was trimmed from it and its two local patches are described in `third_party/moonlight-common-c/VERSION`. -- `crates/client`: the viewer UI (a library plus `brolink-client`). +- `crates/client`: the viewer UI (a library plus `latch-client`). `session.rs` is the path from a listed machine to a live stream; `stream.rs` is the toolbar and input while streaming; `video.rs` draws frames with wgpu. -- `crates/host`: the node (`brolink-host`): control service, engine setup, +- `crates/host`: the node (`latch-host`): control service, engine setup, and the unified window that both views other machines and shares this one. Windows administrator work stays in the generated setup script in `setup.rs`. macOS/Linux setup is `unix_setup.rs`. @@ -51,7 +51,7 @@ keep their meaning. ## Seeing the UI without a Mac or a PC ```bash -cargo test -p brolink-client -p brolink-host snapshots -- --ignored +cargo test -p latch-client -p latch-host snapshots -- --ignored ``` renders every screen to `target/ui-snapshots/*.png`: each state of the @@ -69,19 +69,19 @@ something new adds it there, with accesskit info and a focus ring. ## Tests against a real Sunshine -With Sunshine running on the development PC and BroLink Host's service -started (`cargo run -p brolink-host -- --background`, pointed at the +With Sunshine running on the development PC and Latch Host's service +started (`cargo run -p latch-host -- --background`, pointed at the installed data directory as above so it has the engine login): ```bash -cargo test -p brolink-stream pair_real -- --ignored --nocapture # five-phase pairing -cargo test -p brolink-stream stream_real -- --ignored --nocapture # ten seconds of video -BROLINK_DEV_LOCAL=1 cargo test -p brolink-client stream_snapshot -- --ignored --nocapture -cargo test -p brolink-client wake_test_real -- --ignored # wake packet round trip +cargo test -p latch-stream pair_real -- --ignored --nocapture # five-phase pairing +cargo test -p latch-stream stream_real -- --ignored --nocapture # ten seconds of video +LATCH_DEV_LOCAL=1 cargo test -p latch-client stream_snapshot -- --ignored --nocapture +cargo test -p latch-client wake_test_real -- --ignored # wake packet round trip ``` -`BROLINK_DEV_LOCAL=1` also makes the client app list this PC at 127.0.0.1, -so `cargo run -p brolink-client` on Windows streams the PC to itself for +`LATCH_DEV_LOCAL=1` also makes the client app list this PC at 127.0.0.1, +so `cargo run -p latch-client` on Windows streams the PC to itself for trying the toolbar and input. The Windows client decodes with OpenH264 in software; that path exists for @@ -91,18 +91,18 @@ On a Mac, the snapshot test can use an already saved PC and its existing pairing. It verifies decoded video, renders the stream, and disconnects: ```bash -BROLINK_TEST_PC='Gaming-PC' cargo test -p brolink-client stream_snapshot -- --ignored --nocapture -cargo test -p brolink-client decoded_frame_reaches -- --ignored +LATCH_TEST_PC='Gaming-PC' cargo test -p latch-client stream_snapshot -- --ignored --nocapture +cargo test -p latch-client decoded_frame_reaches -- --ignored ``` -`stream_real` streams from another machine too: `BROLINK_TEST_IP` is its -Tailscale address, `BROLINK_TEST_DIR` a directory holding copies of the files in the +`stream_real` streams from another machine too: `LATCH_TEST_IP` is its +Tailscale address, `LATCH_TEST_DIR` a directory holding copies of the files in the Mac's `identity/` directory and the PC's certificate as `server.der`, and -`BROLINK_TEST_WIDTH`, `_HEIGHT`, `_FPS`, `_BITRATE_KBPS`, `_SECONDS` and -`BROLINK_TEST_HEVC=1` shape the stream. +`LATCH_TEST_WIDTH`, `_HEIGHT`, `_FPS`, `_BITRATE_KBPS`, `_SECONDS` and +`LATCH_TEST_HEVC=1` shape the stream. -`BROLINK_TEST_CODEC=h264` or `hevc` selects the codec. A black capture fails -the live test by default; `BROLINK_TEST_EXPECT_BLACK=1` instead verifies the +`LATCH_TEST_CODEC=h264` or `hevc` selects the codec. A black capture fails +the live test by default; `LATCH_TEST_EXPECT_BLACK=1` instead verifies the persistent black-picture diagnosis when testing a PC without an active display. The GPU test uses a synthetic NV12 image to check that the picture is drawn with the correct orientation and range and retained between frames. diff --git a/Cargo.lock b/Cargo.lock index c4f01d0..195261e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,103 +688,6 @@ dependencies = [ "piper", ] -[[package]] -name = "brolink-client" -version = "4.0.2" -dependencies = [ - "anyhow", - "brolink-core", - "brolink-stream", - "brolink-ui", - "eframe", - "egui", - "egui-wgpu", - "egui_kittest", - "image", - "parking_lot", - "rand", - "semver", - "serde", - "serde_json", - "toml 0.8.23", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "brolink-core" -version = "4.0.2" -dependencies = [ - "anyhow", - "directories", - "if-addrs", - "image", - "rustls", - "semver", - "serde", - "serde_json", - "sha2", - "toml 0.8.23", - "tracing", - "webpki-roots", -] - -[[package]] -name = "brolink-host" -version = "4.0.2" -dependencies = [ - "anyhow", - "arboard", - "brolink-client", - "brolink-core", - "brolink-ui", - "clap", - "eframe", - "egui_kittest", - "image", - "parking_lot", - "rand", - "semver", - "serde", - "serde_json", - "toml 0.8.23", - "tracing", - "tracing-subscriber", - "windows 0.58.0", - "winresource", -] - -[[package]] -name = "brolink-stream" -version = "4.0.2" -dependencies = [ - "aes", - "aes-gcm", - "anyhow", - "audiopus_sys", - "brolink-core", - "cbc", - "cc", - "cpal", - "openh264", - "parking_lot", - "rand", - "rcgen", - "rsa", - "rustls", - "sha2", - "tracing", - "x509-parser", -] - -[[package]] -name = "brolink-ui" -version = "4.0.2" -dependencies = [ - "brolink-core", - "egui", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -2209,6 +2112,103 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "latch-client" +version = "4.1.0" +dependencies = [ + "anyhow", + "eframe", + "egui", + "egui-wgpu", + "egui_kittest", + "image", + "latch-core", + "latch-stream", + "latch-ui", + "parking_lot", + "rand", + "semver", + "serde", + "serde_json", + "toml 0.8.23", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "latch-core" +version = "4.1.0" +dependencies = [ + "anyhow", + "directories", + "if-addrs", + "image", + "rustls", + "semver", + "serde", + "serde_json", + "sha2", + "toml 0.8.23", + "tracing", + "webpki-roots", +] + +[[package]] +name = "latch-host" +version = "4.1.0" +dependencies = [ + "anyhow", + "arboard", + "clap", + "eframe", + "egui_kittest", + "image", + "latch-client", + "latch-core", + "latch-ui", + "parking_lot", + "rand", + "semver", + "serde", + "serde_json", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "windows 0.58.0", + "winresource", +] + +[[package]] +name = "latch-stream" +version = "4.1.0" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "audiopus_sys", + "cbc", + "cc", + "cpal", + "latch-core", + "openh264", + "parking_lot", + "rand", + "rcgen", + "rsa", + "rustls", + "sha2", + "tracing", + "x509-parser", +] + +[[package]] +name = "latch-ui" +version = "4.1.0" +dependencies = [ + "egui", + "latch-core", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index be660af..d37bdff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,20 +9,20 @@ members = [ ] [workspace.package] -version = "4.0.2" +version = "4.1.0" edition = "2021" license = "GPL-3.0-or-later" -authors = ["BroLink Contributors"] +authors = ["Latch Contributors"] description = "Your machines, on any of your machines" -repository = "https://github.com/MrBeldum/brolink" -homepage = "https://github.com/MrBeldum/brolink" +repository = "https://github.com/MrBeldum/latch" +homepage = "https://github.com/MrBeldum/latch" readme = "README.md" [workspace.dependencies] -brolink-core = { path = "crates/core", version = "4.0.0" } -brolink-stream = { path = "crates/stream", version = "4.0.0" } -brolink-ui = { path = "crates/ui", version = "4.0.0" } -brolink-client = { path = "crates/client", version = "4.0.0" } +latch-core = { path = "crates/core", version = "4.0.0" } +latch-stream = { path = "crates/stream", version = "4.0.0" } +latch-ui = { path = "crates/ui", version = "4.0.0" } +latch-client = { path = "crates/client", version = "4.0.0" } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/NOTICE b/NOTICE index cd702ec..bb57af1 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,5 @@ -BroLink -Copyright (c) 2026 BroLink Contributors +Latch +Copyright (c) 2026 Latch Contributors Licensed under the GNU General Public License, version 3 or later (LICENSE). Compiled into the client (crates/stream): @@ -15,14 +15,14 @@ Compiled into the client (crates/stream): It is built from source, so Cisco's binary patent grant does not cover it. https://github.com/cisco/openh264 -Shipped inside brolink-windows-x64.zip and installed by setup: +Shipped inside latch-windows-x64.zip and installed by setup: Sunshine, GPL-3.0, LizardByte. The unmodified Windows lite archive (Sunshine-Windows-AMD64-lite.zip); the version is pinned in crates/host/src/setup.rs and .github/workflows/release.yml and its source is at https://github.com/LizardByte/Sunshine On the PC, setup gives the unpacked sunshine.exe and sunshinesvc.exe - BroLink's description, product name and icon (crates/host/src/brand.rs); + Latch's description, product name and icon (crates/host/src/brand.rs); their copyright, licence and version strings are kept as shipped. Used when present, never redistributed: diff --git a/README.md b/README.md index 707e74f..d2b63f1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ -# BroLink + + + Latch + + +# Latch + +Latch is made by [Bardbro](https://bardbro.com), a personal project for +cloud gaming. Your machines, on any of your machines: stream a desktop full screen over -Tailscale. Mac, Windows, Linux, and a VPS container all run the same BroLink +Tailscale. Mac, Windows, Linux, and a VPS container all run the same Latch app. Each one lists the others and can Connect; each one can share its own desktop. On a Mac, frames are decoded with VideoToolbox. On Windows and Linux the -viewer uses OpenH264. Sharing uses the streaming engine BroLink installs +viewer uses OpenH264. Sharing uses the streaming engine Latch installs (Sunshine): bundled in the Windows zip, downloaded on first Mac setup, and shipped in the Docker node image for a VPS. @@ -15,11 +23,11 @@ machine answers only peers signed in to the same Tailscale account. ## How a session goes -1. Open BroLink. **Machines** lists every machine on your Tailscale +1. Open Latch. **Machines** lists every machine on your Tailscale account, with a dot and a line for what it can do right now. The **Sharing** tab sets up the streaming engine so others can Connect here. -2. Click **Connect**. If a Windows PC is asleep, BroLink wakes it and waits. - The first time, it pairs by itself: the PIN goes to BroLink on that +2. Click **Connect**. If a Windows PC is asleep, Latch wakes it and waits. + The first time, it pairs by itself: the PIN goes to Latch on that machine over Tailscale, which enters it for you. 3. The desktop appears, full screen by default, and nothing else: the mouse is captured the moment you click the picture, as in a game, so @@ -43,18 +51,18 @@ machine answers only peers signed in to the same Tailscale account. 1. Install [Tailscale](https://tailscale.com/download/windows) and sign in with the account you use on the Mac. -2. Download `brolink-windows-x64.zip` from the - [latest release](https://github.com/MrBeldum/brolink/releases/latest) - and unzip it. Run `brolink-host.exe`, or `install-host.ps1` for +2. Download `latch-windows-x64.zip` from the + [latest release](https://github.com/MrBeldum/latch/releases/latest) + and unzip it. Run `latch-host.exe`, or `install-host.ps1` for shortcuts and start-at-logon. 3. On the **Sharing** tab, click **Set up as administrator**. One administrator prompt installs the bundled streaming engine as a Windows service (if none is installed), gives - BroLink a login to it, opens the control port to your tailnet only, + Latch a login to it, opens the control port to your tailnet only, turns Fast Startup off and arms the network card for Wake-on-LAN. The same window lists every other machine on the account; **Connect** opens their desktop. -Advanced engine settings are not exposed; BroLink configures the engine +Advanced engine settings are not exposed; Latch configures the engine itself. Details in [docs/WINDOWS.md](docs/WINDOWS.md). ### Mac (Apple Silicon) @@ -64,20 +72,20 @@ command downloads the app, clears the quarantine flag and copies it to `/Applications`: ```bash -curl -fsSL https://raw.githubusercontent.com/MrBeldum/brolink/main/scripts/install-macos-release.sh | bash +curl -fsSL https://raw.githubusercontent.com/MrBeldum/latch/main/scripts/install-macos-release.sh | bash ``` -Or download `brolink-macos-arm64.tar.gz` from the -[latest release](https://github.com/MrBeldum/brolink/releases/latest) and +Or download `latch-macos-arm64.tar.gz` from the +[latest release](https://github.com/MrBeldum/latch/releases/latest) and ```bash -tar xzf brolink-macos-arm64.tar.gz -xattr -dr com.apple.quarantine BroLink.app -open BroLink.app +tar xzf latch-macos-arm64.tar.gz +xattr -dr com.apple.quarantine Latch.app +open Latch.app ``` The `xattr` step is needed for a browser download because the app is -ad-hoc signed rather than Developer-ID signed. Open BroLink and set up +ad-hoc signed rather than Developer-ID signed. Open Latch and set up sharing on the **Sharing** tab so a Windows PC (or another Mac) can Connect here; macOS will ask for Screen Recording the first time. Details, including the toolbar and keyboard behaviour, in [docs/MACOS.md](docs/MACOS.md). @@ -85,7 +93,7 @@ toolbar and keyboard behaviour, in [docs/MACOS.md](docs/MACOS.md). ### Linux / VPS (Docker) A container that shares a virtual desktop on the tailnet, so Mac and -Windows BroLink can Connect to it. Several copies on one host are several +Windows Latch can Connect to it. Several copies on one host are several machines. See [deploy/node/NODE.md](deploy/node/NODE.md). ```bash @@ -99,16 +107,35 @@ relay, as this project's own VPS does: [deploy/native/README.md](deploy/native/R The packet relay (when two NATs cannot punch through) is a separate kit: [deploy/relay/RELAY.md](deploy/relay/RELAY.md). +## Coming from BroLink + +Latch was called BroLink until 4.1.0. An installed BroLink updates itself +to Latch and keeps its settings, its pairing and its streaming engine, and +every machine that updates can still talk to one that has not yet. + +- **Mac:** the app moves from `BroLink.app` to `Latch.app` the first time + it runs, and its login item with it. +- **Windows:** the host updates in place and keeps working. To move it to + the new folder and names (`%LOCALAPPDATA%\Latch`, `latch-host.exe`, the + `LatchStream` service), run `install-host.ps1` from the new zip, then + **Set up as administrator** once on the **Sharing** tab. +- **Linux:** the first start registers `latch.service` and retires + `brolink.service`. The files in `deploy/native/` have new names; see + [deploy/native/README.md](deploy/native/README.md). + +The environment variables beginning `BROLINK_` are now `LATCH_`; for +`LATCH_DATA_DIR` and `LATCH_GITHUB_TOKEN` the old names still work. + ## Updates -BroLink keeps itself current. Every few hours the Mac app asks GitHub for +Latch keeps itself current. Every few hours the Mac app asks GitHub for the latest release. A newer app is downloaded, checked against the digest GitHub publishes and its own code signature, swapped into `/Applications` -once no stream is running, and relaunched. A newer BroLink Host is sent +once no stream is running, and relaunched. A newer Latch Host is sent from the Mac to every PC whose host reports an older version, over the same Tailscale-authenticated control API that can put the PC to sleep; the host verifies the digest, replaces its executable and restarts. That path -updates only `brolink-host.exe`. The Windows zip on GitHub also contains +updates only `latch-host.exe`. The Windows zip on GitHub also contains the pinned engine archive for first-time setup; a host update does not install or migrate the engine. A PC that is asleep gets the host update the next time the Mac sees it. Nothing is downloaded on the PC, and @@ -116,7 +143,7 @@ no GitHub login is needed there. Public releases need no GitHub login. If the repository is private, the Mac uses the GitHub token git has stored for github.com, -`BROLINK_GITHUB_TOKEN`, or `github_token` in `client.toml`. Settings has the +`LATCH_GITHUB_TOKEN`, or `github_token` in `client.toml`. Settings has the switch and a **Check now** button. Hosts installed before 3.1 do not have the update route: install that release on the PC once (through the stream works), after which updates are automatic. @@ -124,14 +151,14 @@ after which updates are automatic. ## Staying reachable A PC nobody can get to in person stays reachable when three things hold. -BroLink Host starts with Windows by default and turns this back on at every +Latch Host starts with Windows by default and turns this back on at every start unless the owner switches it off in the host window. The streaming engine runs as a Windows service, so streaming works even before anyone logs in. And the PC's Tailscale node key must not expire: Tailscale keys expire after 180 days unless key expiry is disabled for that machine in the [admin console](https://login.tailscale.com/admin/machines), and an expired -key needs a sign-in at the PC. BroLink shows the expiry of every machine it +key needs a sign-in at the PC. Latch shows the expiry of every machine it lists, this Mac included, until expiry is disabled. Pairing and the PC's addresses are saved on the Mac, so PCs stay listed even while Tailscale on the Mac is off. @@ -140,7 +167,7 @@ the Mac is off. A wake packet has to reach the PC's network card on the PC's own network. Tailscale cannot deliver it, because the sleeping PC's Tailscale is asleep -too. BroLink sends the packet to the LAN broadcast, to the PC's LAN address +too. Latch sends the packet to the LAN broadcast, to the PC's LAN address and to the PC's public address. | Where the Mac is | Asleep | Shut down | @@ -149,7 +176,7 @@ and to the PC's public address. | Elsewhere | needs the PC's router to forward UDP 9 to the PC, or a Tailscale subnet router on that network | same, and the firmware condition | **Test waking it from this network** in a machine's **…** menu settles it for the network you are -on: it sends the packet while the PC is awake and asks BroLink there whether +on: it sends the packet while the PC is awake and asks Latch there whether it arrived. Setup on the PC takes care of Windows' side (Fast Startup off, the adapter's wake keywords, wake allowed in power management). Wired Ethernet is strongly preferred; most Wi-Fi adapters cannot wake a PC. @@ -165,9 +192,9 @@ Ethernet is strongly preferred; most Wi-Fi adapters cannot wake a PC. firewall rule setup adds is scoped to `100.64.0.0/10`. - The stream is the GameStream protocol (moonlight-common-c on the viewer, Sunshine on the host), encrypted, inside Tailscale (WireGuard), with - certificate pairing on top; BroLink pins the PC's certificate after the + certificate pairing on top; Latch pins the PC's certificate after the first pairing. -- No BroLink account or password exists. The engine login BroLink +- No Latch account or password exists. The engine login Latch generates stays on the PC. - Remote power actions can be turned off in the host window. @@ -177,7 +204,7 @@ Needs Rust, a C compiler (MSVC Build Tools or Xcode command line tools) and CMake (for the Opus decoder). ```powershell -cargo build --release -p brolink-host # Windows +cargo build --release -p latch-host # Windows ``` ```bash @@ -200,7 +227,7 @@ crates/stream the stream client: pairing, launch, protocol, decode, audio crates/host node: control service, engine setup, unified window (view + share) crates/client viewer UI: machine list, wake, pair, stream window and toolbar crates/ui theme and widgets shared by both windows -deploy/node/ Docker kit: virtual desktop + engine + BroLink on a VPS +deploy/node/ Docker kit: virtual desktop + engine + Latch on a VPS deploy/native/ systemd user units for the same desktop without Docker deploy/relay/ Docker kit: Tailscale peer relay third_party/ moonlight-common-c (GPL-3.0), vendored; VERSION says what and why @@ -210,8 +237,8 @@ scripts/ installers and the macOS bundle ## License -GPL-3.0-or-later; see [LICENSE](LICENSE). BroLink compiles in +GPL-3.0-or-later; see [LICENSE](LICENSE). Latch compiles in moonlight-common-c (GPL-3.0) and ships Sunshine's Windows lite archive (GPL-3.0) unmodified; on the PC, setup gives the unpacked executables -BroLink's name and icon and keeps their copyright and licence strings. +Latch's name and icon and keeps their copyright and licence strings. Third-party notices are in [NOTICE](NOTICE). diff --git a/crates/client/BroLink.icns b/crates/client/BroLink.icns deleted file mode 100644 index 5a6d061..0000000 Binary files a/crates/client/BroLink.icns and /dev/null differ diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 8615bf5..7cf0a4b 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -1,23 +1,23 @@ [package] -name = "brolink-client" +name = "latch-client" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "BroLink: view and share machines on your tailnet" +description = "Latch: view and share machines on your tailnet" [lib] -name = "brolink_client" +name = "latch_client" path = "src/lib.rs" [[bin]] -name = "brolink-client" +name = "latch-client" path = "src/main.rs" [dependencies] -brolink-core.workspace = true -brolink-stream.workspace = true -brolink-ui.workspace = true +latch-core.workspace = true +latch-stream.workspace = true +latch-ui.workspace = true anyhow.workspace = true serde = { workspace = true } serde_json.workspace = true diff --git a/crates/client/Latch.icns b/crates/client/Latch.icns new file mode 100644 index 0000000..8fc96c4 Binary files /dev/null and b/crates/client/Latch.icns differ diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index ecb3131..34ed685 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -12,29 +12,30 @@ use crate::session::{ use crate::share; use crate::stream::{self, Action, Env}; use crate::update; -use brolink_core::api::PowerAction; -use brolink_core::tailscale; -use brolink_stream::Event; -use brolink_ui::{self as ui, column, space, Icon, Tone, PALETTE as P}; use eframe::egui; +use latch_core::api::PowerAction; +use latch_core::tailscale; +use latch_stream::Event; +use latch_ui::{self as ui, column, space, Icon, Tone, PALETTE as P}; use parking_lot::Mutex; use semver::Version; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; -const RELAY_NONE: &str = "When two machines can't reach each other directly, Tailscale's relays carry the stream. For a shorter detour, run your own relay with BroLink's deploy kit."; +const RELAY_NONE: &str = "When two machines can't reach each other directly, Tailscale's relays carry the stream. For a shorter detour, run your own relay with Latch's deploy kit."; const RELAY_READY: &str = "Your relay carries streams between machines that can't connect directly."; const RELAY_OFFLINE: &str = "Your relay is offline. Until it is back, Tailscale's relays carry streams between machines that can't connect directly."; -const RELAY_CHECKING: &str = "A relay is on this tailnet, but BroLink couldn't confirm that this device may use it. That does not mean access is denied. Peer relays need Tailscale 1.86 or later on every device."; +const RELAY_CHECKING: &str = "A relay is on this tailnet, but Latch couldn't confirm that this device may use it. That does not mean access is denied. Peer relays need Tailscale 1.86 or later on every device."; const RELAY_UNAVAILABLE: &str = "A relay is on this tailnet but isn't available to this device yet. That does not mean access is denied: check that the relay is configured, and add this grant to the tailnet policy if it is missing."; #[cfg(test)] const RELAY_UNGRANTED: &str = "A relay node is online but this device is not granted access."; const RELAY_GRANT: &str = "{\n \"src\": [\"autogroup:member\"],\n \"dst\": [\"tag:relay\"],\n \"app\": {\n \"tailscale.com/cap/relay\": []\n }\n}"; const RELAY_DOCS: &str = "https://tailscale.com/docs/features/peer-relay"; -const DOWNLOAD_URL: &str = "https://github.com/MrBeldum/brolink/releases/latest"; -const SOURCE_URL: &str = "https://github.com/MrBeldum/brolink"; +const DOWNLOAD_URL: &str = "https://github.com/MrBeldum/latch/releases/latest"; +const SOURCE_URL: &str = "https://github.com/MrBeldum/latch"; +const MAKER_URL: &str = "https://bardbro.com"; /// How long a one-line result stays on the machine list. const NOTICE_FOR: Duration = Duration::from_secs(12); @@ -117,7 +118,7 @@ pub struct ClientApp { /// its way. Asked for once per stream, by the thread that answers. video_help: Arc>>, asked_about_video: bool, - /// An install of BroLink Host through the stream, while it runs and a + /// An install of Latch Host through the stream, while it runs and a /// little after. handover: Option, display_at_connect: (u32, u32), @@ -225,7 +226,7 @@ impl ClientApp { tracing::warn!("could not save settings: {e:#}"); self.notice = Some(( Tone::Danger, - format!("Couldn't save settings: {e}. They apply until BroLink quits."), + format!("Couldn't save settings: {e}. They apply until Latch quits."), Instant::now(), )); } @@ -420,7 +421,7 @@ impl ClientApp { self.view.toast( Tone::Warning, format!( - "{} has no sound to send: it reports “{problem}”. Its Sharing page in BroLink says more.", + "{} has no sound to send: it reports “{problem}”. Its Sharing page in Latch says more.", live.pc ), ); @@ -665,7 +666,7 @@ impl eframe::App for ClientApp { self.shortcuts(ctx); ui::top_bar(ctx, "top", |ui| { - self.brand.lockup(ui, "BroLink"); + self.brand.lockup(ui, "Latch"); ui.add_space(space::XL); let mut pages = vec![(Page::Machines, "Machines", chord("1"))]; if self.share_page.is_some() { @@ -692,7 +693,7 @@ impl eframe::App for ClientApp { }); ui::bottom_bar(ctx, "bottom", |ui| { - let mut line = format!("BroLink {}", env!("CARGO_PKG_VERSION")); + let mut line = format!("Latch {}", env!("CARGO_PKG_VERSION")); if !disc.login.is_empty() { line.push_str(&format!(" · {}", disc.login)); } @@ -782,7 +783,7 @@ impl ClientApp { ui, Tone::Danger, "Tailscale isn't installed", - Some("BroLink finds your machines and connects to them through Tailscale. Install it and sign in with the account your other machines use; they appear here within seconds."), + Some("Latch finds your machines and connects to them through Tailscale. Install it and sign in with the account your other machines use; they appear here within seconds."), |ui| { if ui::primary_button(ui, "Get Tailscale").clicked() { let url = if cfg!(windows) { @@ -896,10 +897,10 @@ impl ClientApp { ui::strong(ui, "No other machines yet"); ui::caption( ui, - "Install BroLink on another computer and sign in to Tailscale there with the same account. It appears here within a few seconds.", + "Install Latch on another computer and sign in to Tailscale there with the same account. It appears here within a few seconds.", ); ui.add_space(space::XS); - if ui::link(ui, "Download BroLink").clicked() { + if ui::link(ui, "Download Latch").clicked() { ui.ctx().open_url(egui::OpenUrl::new_tab(DOWNLOAD_URL)); } } @@ -929,7 +930,7 @@ impl ClientApp { ), None => ( Tone::Neutral, - "Starting BroLink's background service…".to_string(), + "Starting Latch's background service…".to_string(), ), Some(_) if running => (Tone::Accent, format!("{noun} · Setting up sharing…")), Some(s) if shared && s.setup.is_empty() => ( @@ -1064,14 +1065,14 @@ impl ClientApp { }); } - /// Start installing the newest BroLink Host on the PC through the + /// Start installing the newest Latch Host on the PC through the /// stream. See `handover.rs`. fn start_handover( &mut self, ctx: &egui::Context, ip: std::net::Ipv4Addr, name: &str, - input: brolink_stream::Input, + input: latch_stream::Input, disc: &Discovery, ) { let Some(mac_ip) = disc.self_ip else { @@ -1095,7 +1096,7 @@ impl ClientApp { return; } } - let token = brolink_core::update::token(self.cfg.github_token.as_deref()); + let token = latch_core::update::token(self.cfg.github_token.as_deref()); self.handover = Some(Handover::start( release, token, @@ -1106,7 +1107,7 @@ impl ClientApp { )); self.view.toast( Tone::Neutral, - format!("Installing BroLink Host on {name} through the stream…"), + format!("Installing Latch Host on {name} through the stream…"), ); } @@ -1125,7 +1126,7 @@ impl ClientApp { self.view.toast( Tone::Success, format!( - "{name} now runs BroLink Host {}. Updates arrive by themselves from here on.", + "{name} now runs Latch Host {}. Updates arrive by themselves from here on.", p.version.map(|v| v.to_string()).unwrap_or_default() ), ); @@ -1140,8 +1141,8 @@ impl ClientApp { ui::banner( ui, Tone::Accent, - "Installing a BroLink update", - Some("BroLink restarts itself in a moment. New connections wait until it has."), + "Installing a Latch update", + Some("Latch restarts itself in a moment. New connections wait until it has."), |_| {}, ); return; @@ -1150,7 +1151,7 @@ impl ClientApp { let (title, explain) = match &prog.step { Step::Waking => ( format!("Waking {pc}"), - "BroLink sends wake packets every few seconds. A sleeping machine can take up to two minutes to come back.", + "Latch sends wake packets every few seconds. A sleeping machine can take up to two minutes to come back.", ), Step::Waiting => ( format!("Waiting for {pc}"), @@ -1158,7 +1159,7 @@ impl ClientApp { ), Step::Pairing { .. } => ( format!("Pairing with {pc}"), - "This happens once per machine. BroLink on that machine enters this PIN by itself; nothing needs typing unless it says otherwise below.", + "This happens once per machine. Latch on that machine enters this PIN by itself; nothing needs typing unless it says otherwise below.", ), _ => (format!("Connecting to {pc}"), ""), }; @@ -1370,7 +1371,7 @@ impl ClientApp { if ui::toggle_row( ui, &mut self.cfg.auto_update, - "Keep BroLink up to date", + "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."), ) { self.dirty = true; @@ -1442,7 +1443,7 @@ impl ClientApp { ui::section(ui, "About", |ui| { ui::setting_row( ui, - &format!("BroLink {}", env!("CARGO_PKG_VERSION")), + &format!("Latch {}", env!("CARGO_PKG_VERSION")), Some("Free software under the GNU GPL, version 3 or later. It builds on Moonlight, Sunshine, Opus and the Geist typeface."), |ui| { if ui::link(ui, "Source code").clicked() { @@ -1451,6 +1452,17 @@ impl ClientApp { }, ); ui::row_separator(ui); + ui::setting_row( + ui, + "Bardbro", + Some("Latch is made by Bardbro, a personal project for cloud gaming."), + |ui| { + if ui::link(ui, "bardbro.com").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(MAKER_URL)); + } + }, + ); + ui::row_separator(ui); ui.add_space(space::XS); ui::disclosure(ui, "about-notices", "Open-source notices", false, |ui| { ui::well(ui, |ui| { @@ -1485,7 +1497,7 @@ fn sentence(s: &str) -> String { } } -/// "This Mac", "This PC": what to call the machine BroLink runs on. +/// "This Mac", "This PC": what to call the machine Latch runs on. fn this_noun(os: &str) -> &'static str { let os = if os.is_empty() { if cfg!(windows) { @@ -1652,7 +1664,7 @@ fn describe(pc: &Pc) -> (Tone, String) { let os = os_label(&pc.os); let at = pc.ip.map(|ip| format!(" · {ip}")).unwrap_or_default(); // A phone on the tailnet is listed, but it will never share a desktop: - // "BroLink isn't installed" would send someone looking for an app. + // "Latch isn't installed" would send someone looking for an app. let lower = pc.os.to_ascii_lowercase(); if lower == "ios" || lower == "android" || lower == "ipados" { let name = if lower == "android" { "Android" } else { "iOS" }; @@ -1666,7 +1678,7 @@ fn describe(pc: &Pc) -> (Tone, String) { return ( Tone::Neutral, match seen { - Some(t) => format!("{os} · Last seen {}", brolink_core::dates::ymd(t)), + Some(t) => format!("{os} · Last seen {}", latch_core::dates::ymd(t)), None => format!("{os} · Not seen yet"), }, ); @@ -1675,7 +1687,7 @@ fn describe(pc: &Pc) -> (Tone, String) { return if pc.can_wake() { ( Tone::Neutral, - format!("{os} · Asleep or off · BroLink can wake it"), + format!("{os} · Asleep or off · Latch can wake it"), ) } else { ( @@ -1696,8 +1708,8 @@ fn describe(pc: &Pc) -> (Tone, String) { format!("{os}{at} · Sharing needs attention there"), ), (Some(_), false) => (Tone::Neutral, format!("{os}{at} · Not shared yet")), - (None, true) => (Tone::Success, format!("{os}{at}{path} · Without BroLink")), - (None, false) => (Tone::Neutral, format!("{os}{at} · BroLink isn't installed")), + (None, true) => (Tone::Success, format!("{os}{at}{path} · Without Latch")), + (None, false) => (Tone::Neutral, format!("{os}{at} · Latch isn't installed")), } } @@ -1726,7 +1738,7 @@ fn path_warnings(disc: &Discovery) -> Vec { if let Some(h) = &pc.host { if h.streamer.encoder == "software" { out.push(format!( - "{} encodes video in software: it has no GPU encoder BroLink can use, so every CPU core does the work. A still desktop holds the bitrate you set; fast motion may drop frames.", + "{} encodes video in software: it has no GPU encoder Latch can use, so every CPU core does the work. A still desktop holds the bitrate you set; fast motion may drop frames.", pc.name )); } @@ -1737,7 +1749,7 @@ fn path_warnings(disc: &Discovery) -> Vec { )); } if let Ok(v) = Version::parse(&h.version) { - if !brolink_core::update::host_can_receive_update(&v) { + if !latch_core::update::host_can_receive_update(&v) { out.push(update::old_host_message(&pc.name, &v)); } } @@ -1812,12 +1824,12 @@ mod tests { assert!(line(&pc).contains("Asleep")); assert!(line(&pc).contains("can wake it")); pc.online = true; - assert!(line(&pc).contains("BroLink isn't installed")); + assert!(line(&pc).contains("Latch isn't installed")); assert_eq!(describe(&pc).0, Tone::Neutral); pc.sunshine = true; - assert!(line(&pc).contains("Without BroLink")); + assert!(line(&pc).contains("Without Latch")); assert!(line(&pc).len() < 80, "{}", line(&pc)); - pc.host = Some(brolink_core::api::Status::default()); + pc.host = Some(latch_core::api::Status::default()); assert_eq!( describe(&pc), (Tone::Success, "Unknown · 203.0.113.10".to_string()) @@ -1859,9 +1871,9 @@ mod tests { rtt_ms: Some(200), ..Default::default() }, - host: Some(brolink_core::api::Status { + host: Some(latch_core::api::Status { version: "3.0.0".into(), - streamer: brolink_core::api::Streamer { + streamer: latch_core::api::Streamer { encoder: "software".into(), audio_problem: "Unable to initialize audio capture. The stream will not have audio.".into(), @@ -1884,7 +1896,7 @@ mod tests { "{}", w[2] ); - assert!(w[3].contains("Update BroLink Host"), "{}", w[3]); + assert!(w[3].contains("Update Latch Host"), "{}", w[3]); assert_eq!( host_audio_problem(&Mutex::new(disc.clone()), &gaming_pc.node_id).as_deref(), Some("Unable to initialize audio capture. The stream will not have audio.") @@ -1939,7 +1951,7 @@ mod tests { ); for s in [RELAY_NONE, RELAY_READY, RELAY_UNAVAILABLE] { assert!(!s.to_lowercase().contains("shared")); - assert!(!s.contains("BroLink relay")); + assert!(!s.contains("Latch relay")); } assert_eq!( @@ -2077,7 +2089,7 @@ mod tests { /// ignored ones write PNGs for review: /// /// ```text -/// cargo test -p brolink-client -p brolink-host snapshots -- --ignored +/// cargo test -p latch-client -p latch-host snapshots -- --ignored /// ``` /// /// Files land in `target/ui-snapshots/` as @@ -2089,8 +2101,8 @@ pub(crate) mod snapshots { use super::*; use crate::config::KnownPc; use crate::session::Relay; - use brolink_core::api::{Status, Streamer}; use egui_kittest::kittest::Queryable; + use latch_core::api::{Status, Streamer}; pub const MIN: egui::Vec2 = egui::vec2(640.0, 420.0); pub const TYPICAL: egui::Vec2 = egui::vec2(1280.0, 800.0); @@ -2130,13 +2142,13 @@ pub(crate) mod snapshots { fn a_status(name: &str, os: &str) -> Status { Status { - app: "brolink".into(), + app: latch_core::APP_ID.into(), version: env!("CARGO_PKG_VERSION").into(), name: name.into(), os: os.into(), power_allowed: true, streamer: Streamer { - kind: "BroLink".into(), + kind: latch_core::STREAMER_KIND.into(), installed: true, running: true, api_ok: true, @@ -2195,7 +2207,7 @@ pub(crate) mod snapshots { } /// The three machines most screens show: one ready and relayed, one - /// asleep, one that streams without BroLink. + /// asleep, one that streams without Latch. pub fn pcs() -> Discovery { let mut gaming = ready_pc("n1", "Gaming-PC", "windows", "100.64.0.10", 210, false); gaming.key_expiry_days = Some(176); @@ -2213,7 +2225,7 @@ pub(crate) mod snapshots { ..Default::default() }; let mut d = found(vec![gaming, asleep_pc("n2", "Office"), den]); - d.self_nat = Some(brolink_core::api::NatReport { + d.self_nat = Some(latch_core::api::NatReport { udp: true, ipv4: true, ipv6: false, @@ -2522,12 +2534,12 @@ pub(crate) mod snapshots { Setup::new(pcs()).step( "Gaming-PC", Step::Pairing { pin: "4821".into() }, - "BroLink on that machine isn't answering, so it can't enter the PIN. Open BroLink there and set up sharing, then try again.", + "Latch on that machine isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, then try again.", ), ), ( "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 BroLink shows whether Wake-on-LAN is ready."), + 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."), ), ( "disconnected", @@ -2561,7 +2573,7 @@ pub(crate) mod snapshots { u.ready = Some(Version::new(4, 1, 0)); u.notice = Some(( Tone::Info, - "BroLink 4.1.0 is downloaded and installs when no stream is running." + "Latch 4.1.0 is downloaded and installs when no stream is running." .into(), )); }), @@ -2885,7 +2897,7 @@ pub(crate) mod snapshots { }}"#; fn discovery_from_status(json: &str) -> Discovery { - let st = brolink_core::tailscale::parse_status(json).expect("fixture"); + let st = latch_core::tailscale::parse_status(json).expect("fixture"); Discovery { login: "user@example.com".into(), refreshed: Some(Instant::now()), @@ -3142,7 +3154,7 @@ mod window_fit { /// Streams from the Sunshine on this machine into the window for a few /// seconds and saves what the window shows: -/// `BROLINK_DEV_LOCAL=1 cargo test -p brolink-client stream_snapshot -- --ignored --nocapture` +/// `LATCH_DEV_LOCAL=1 cargo test -p latch-client stream_snapshot -- --ignored --nocapture` #[cfg(test)] mod live_snapshot { use super::*; @@ -3164,28 +3176,28 @@ mod live_snapshot { move |cc| { let mut app = ClientApp::with_shared(cc, disc, prog, ClientConfig::load()); app.cfg.stream.fullscreen = false; - if std::env::var_os("BROLINK_TEST_PC").is_none() { + if std::env::var_os("LATCH_TEST_PC").is_none() { app.cfg.stream.resolution = Resolution::P1080; app.cfg.stream.codec = Codec::H264; } - if let Ok(codec) = std::env::var("BROLINK_TEST_CODEC") { + if let Ok(codec) = std::env::var("LATCH_TEST_CODEC") { app.cfg.stream.codec = match codec.as_str() { "h264" => Codec::H264, "hevc" => Codec::Hevc, - _ => panic!("BROLINK_TEST_CODEC must be h264 or hevc"), + _ => panic!("LATCH_TEST_CODEC must be h264 or hevc"), }; } app } }); harness.run_steps(2); - let pc = if let Ok(name) = std::env::var("BROLINK_TEST_PC") { + let pc = if let Ok(name) = std::env::var("LATCH_TEST_PC") { let cfg = ClientConfig::load(); let (id, known) = cfg .pcs .iter() .find(|(id, pc)| **id == name || pc.name == name) - .expect("BROLINK_TEST_PC must name a saved PC"); + .expect("LATCH_TEST_PC must name a saved PC"); Pc { node_id: id.clone(), name: known.name.clone(), @@ -3204,10 +3216,10 @@ mod live_snapshot { sunshine: true, known: Some(KnownPc { server_cert: std::fs::read( - std::env::temp_dir().join("brolink-pair-test/server.der"), + std::env::temp_dir().join("latch-pair-test/server.der"), ) .ok() - .map(|d| brolink_stream::nvhttp::hex(&d)), + .map(|d| latch_stream::nvhttp::hex(&d)), ..Default::default() }), ..Default::default() @@ -3262,7 +3274,7 @@ mod live_snapshot { std::thread::sleep(Duration::from_millis(16)); } eprintln!("decoded {decoded} frames"); - let expect_black = std::env::var_os("BROLINK_TEST_EXPECT_BLACK").is_some(); + let expect_black = std::env::var_os("LATCH_TEST_EXPECT_BLACK").is_some(); let mut restarted = !expect_black; if expect_black && black == Some(true) { use egui_kittest::kittest::Queryable; diff --git a/crates/client/src/clipboard.rs b/crates/client/src/clipboard.rs index 0298b29..264581c 100644 --- a/crates/client/src/clipboard.rs +++ b/crates/client/src/clipboard.rs @@ -1,4 +1,4 @@ -//! The clipboard both ways while a stream runs, through BroLink Host's +//! The clipboard both ways while a stream runs, through Latch Host's //! `/v1/clipboard` on the PC. //! //! The Moonlight protocol carries keys and pictures, not clipboards, so ⌘V @@ -9,8 +9,8 @@ //! then presses Ctrl+V there. Hosts before 3.1 have no such route; the //! worker notices the 404 and says so once. -use brolink_core::api::{Ack, Clipboard, CLIPBOARD_PATH}; -use brolink_core::{http, CONTROL_PORT}; +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}; diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index ddc0e1f..8c781f0 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -1,5 +1,5 @@ //! Settings and what has been learned about each PC, in -//! `~/Library/Application Support/dev.brolink.BroLink/client.toml`. +//! `~/Library/Application Support/com.bardbro.Latch/client.toml`. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -18,14 +18,14 @@ pub enum Resolution { impl Resolution { /// 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 `brolink_core::screens`. + /// itself. See `latch_core::screens`. pub fn pixels(self, native: (u32, u32)) -> (u32, u32) { - let [p1080, p1440, p2160] = brolink_core::screens::STANDARD; + let [p1080, p1440, p2160] = latch_core::screens::STANDARD; match self { Resolution::P1080 => p1080, Resolution::P1440 => p1440, Resolution::P2160 => p2160, - Resolution::Native => brolink_core::screens::even(native), + Resolution::Native => latch_core::screens::even(native), } } @@ -215,7 +215,7 @@ impl Default for ClientConfig { impl ClientConfig { pub fn load() -> Self { - let mut c: Self = brolink_core::config::load(FILE); + let mut c: Self = latch_core::config::load(FILE); if c.stream.quality == Quality::Auto { c.stream.resolution = Resolution::Native; } @@ -256,11 +256,11 @@ impl ClientConfig { /// One read-modify-write of the file, under the process-wide config lock. pub fn update(edit: impl FnOnce(&mut Self)) -> anyhow::Result<()> { - brolink_core::config::update(FILE, edit) + latch_core::config::update(FILE, edit) } pub fn forget_pin_on_mismatch(&mut self, node_id: &str, err: &anyhow::Error) -> bool { - if !brolink_stream::nvhttp::is_pin_mismatch(err) { + if !latch_stream::nvhttp::is_pin_mismatch(err) { return false; } if let Some(pc) = self.pcs.get_mut(node_id) { @@ -316,14 +316,14 @@ mod tests { Resolution::P1080.describe((3024, 1964)), "1080p · 1920 × 1080" ); - let modes = brolink_core::screens::stream_modes(); + let modes = latch_core::screens::stream_modes(); for r in [ Resolution::P1080, Resolution::P1440, Resolution::P2160, Resolution::Native, ] { - for &screen in brolink_core::screens::SCREENS { + for &screen in latch_core::screens::SCREENS { assert!( modes.contains(&r.pixels(screen)), "{r:?} on {screen:?} is a listed mode" @@ -399,7 +399,7 @@ mod tests { ..Default::default() }, ); - let mismatch = anyhow::Error::from(brolink_stream::nvhttp::PinMismatch); + let mismatch = anyhow::Error::from(latch_stream::nvhttp::PinMismatch); assert!(c.forget_pin_on_mismatch("n", &mismatch)); assert_eq!(c.pcs["n"].server_cert, None); diff --git a/crates/client/src/display.rs b/crates/client/src/display.rs index 1b6aaaf..a7396fb 100644 --- a/crates/client/src/display.rs +++ b/crates/client/src/display.rs @@ -6,8 +6,8 @@ //! sentence a person can act on rather than a dump of display settings. use anyhow::Result; -use brolink_core::api::DisplayRequest; -use brolink_core::{http, CONTROL_PORT}; +use latch_core::api::DisplayRequest; +use latch_core::{http, CONTROL_PORT}; use serde_json::Value; use std::net::Ipv4Addr; use std::time::Duration; diff --git a/crates/client/src/handover.rs b/crates/client/src/handover.rs index 4737612..a18e430 100644 --- a/crates/client/src/handover.rs +++ b/crates/client/src/handover.rs @@ -1,7 +1,7 @@ -//! Getting a new BroLink Host onto a PC whose host is too old to take +//! 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. //! -//! The Mac serves the new `brolink-host.exe` and a small PowerShell script +//! 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. //! Then it presses Win+R on the PC through the stream, types one line //! (`powershell … irm http://:47851/u.ps1 | iex`) and presses Enter. @@ -13,10 +13,10 @@ use crate::input::{self, press_chord}; use anyhow::{anyhow, Context, Result}; -use brolink_core::http; -use brolink_core::update::{self, Release}; -use brolink_core::HANDOVER_PORT; -use brolink_stream::Input; +use latch_core::http; +use latch_core::update::{self, Release}; +use latch_core::HANDOVER_PORT; +use latch_stream::Input; use parking_lot::Mutex; use semver::Version; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; @@ -114,7 +114,7 @@ impl Handover { .version .as_ref() .map(|v| v.to_string()) - .unwrap_or_else(|| "the new BroLink Host".into()); + .unwrap_or_else(|| "the new Latch Host".into()); match &p.phase { Phase::Preparing => format!("Fetching {v} from GitHub…"), Phase::Serving => format!( @@ -154,30 +154,30 @@ pub fn command(mac_ip: Ipv4Addr) -> String { /// the usual install folder, and replaces that file. pub fn script(mac_ip: Ipv4Addr, version: &Version, sha256_hex: &str) -> String { format!( - r#"# BroLink Host {version}: installed from your Mac through the stream. + r#"# Latch Host {version}: installed from your Mac through the stream. $ErrorActionPreference = 'Stop' $src = 'http://{mac_ip}:{port}' -$host.UI.RawUI.WindowTitle = 'BroLink Host update' -Write-Host "Installing BroLink Host {version} from your Mac..." -$p = (Get-Process brolink-host -ErrorAction SilentlyContinue | Select-Object -First 1).Path -if (-not $p) {{ $p = Join-Path $env:LOCALAPPDATA 'BroLink\brolink-host.exe' }} +$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 +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 Write-Host "Downloading to $p.new" -Invoke-WebRequest -UseBasicParsing "$src/brolink-host.exe" -OutFile "$p.new" +Invoke-WebRequest -UseBasicParsing "$src/latch-host.exe" -OutFile "$p.new" $h = (Get-FileHash "$p.new" -Algorithm SHA256).Hash if ($h -ne '{sha}') {{ Remove-Item "$p.new" -Force; throw "the download did not match its SHA-256" }} 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 brolink-host -ErrorAction SilentlyContinue | Stop-Process -Force +Get-Process latch-host -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 Remove-Item "$p.old" -Force -ErrorAction SilentlyContinue -New-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name BroLinkHost -Value "`"$p`" --background" -PropertyType String -Force | Out-Null +New-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name LatchHost -Value "`"$p`" --background" -PropertyType String -Force | Out-Null Start-Process -FilePath $p -ArgumentList '--background' -WorkingDirectory $dir -Write-Host "Done: BroLink Host {version} is running. This window closes in 5 seconds." +Write-Host "Done: Latch Host {version} is running. This window closes in 5 seconds." Start-Sleep 5 "#, version = version, @@ -221,7 +221,7 @@ fn run( }; progress.lock().version = Some(rel.version.clone()); ctx.request_repaint(); - let exe = crate::update::host_exe(&rel, token).context("fetch brolink-host.exe")?; + let exe = crate::update::host_exe(&rel, token).context("fetch latch-host.exe")?; let sha = update::sha256_hex(&exe); let script = script(mac_ip, &rel.version, &sha); let listener = TcpListener::bind(SocketAddr::from((mac_ip, HANDOVER_PORT))) @@ -277,7 +277,7 @@ fn run( script.as_bytes(), ); } - ("GET", "/brolink-host.exe") => { + ("GET", "/latch-host.exe") => { tracing::info!("handover: {peer} fetching the executable"); let r = http::write_bytes(&mut stream, 200, "application/octet-stream", &exe); @@ -332,8 +332,8 @@ 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 brolink-host"), "{s}"); - assert!(s.contains("BroLink\\brolink-host.exe"), "{s}"); + assert!(s.contains("Get-Process latch-host"), "{s}"); + assert!(s.contains("Latch\\latch-host.exe"), "{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 fb9aa37..611d537 100644 --- a/crates/client/src/input.rs +++ b/crates/client/src/input.rs @@ -1,11 +1,11 @@ //! Keyboard and mouse translation: egui events in, Moonlight input calls out. -use brolink_stream::ffi::{ +use egui::{Key, Modifiers, PointerButton}; +use latch_stream::ffi::{ BUTTON_LEFT, BUTTON_MIDDLE, BUTTON_RIGHT, BUTTON_X1, BUTTON_X2, MODIFIER_ALT, MODIFIER_CTRL, MODIFIER_META, MODIFIER_SHIFT, }; -use brolink_stream::Input; -use egui::{Key, Modifiers, PointerButton}; +use latch_stream::Input; use std::collections::BTreeSet; use std::ffi::c_char; diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 283fd84..aeb3c0c 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1,4 +1,4 @@ -//! BroLink viewer: machine list, pairing, stream window. +//! Latch viewer: machine list, pairing, stream window. pub mod app; pub mod clipboard; @@ -14,17 +14,17 @@ pub mod stream; pub mod update; pub mod video; -/// How a BroLink window opens: the brolink-host app and the development +/// How a Latch window opens: the latch-host app and the development /// viewer alike. pub fn native_options() -> eframe::NativeOptions { let icon = if cfg!(target_os = "macos") { // eframe calls NSApplication.setApplicationIconImage with this - // bitmap, which replaces BroLink.icns in the Dock with an unmasked + // bitmap, which replaces Latch.icns in the Dock with an unmasked // square. An empty icon leaves the bundle icon in place. egui::IconData::default() } else { egui::IconData { - rgba: brolink_core::icon::render(64), + rgba: latch_core::icon::render(64), width: 64, height: 64, } @@ -33,7 +33,7 @@ pub fn native_options() -> eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1100.0, 720.0]) .with_min_inner_size([640.0, 420.0]) - .with_title("BroLink") + .with_title("Latch") .with_icon(icon), renderer: eframe::Renderer::Wgpu, // A decoded frame goes to the screen as soon as it is drawn rather diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index ccd9771..89515d7 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -//! The viewer on its own, for development: `cargo run -p brolink-client`. -//! What ships is `brolink-host`, which is this window plus sharing. +//! The viewer on its own, for development: `cargo run -p latch-client`. +//! What ships is `latch-host`, which is this window plus sharing. fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -12,9 +12,9 @@ fn main() -> anyhow::Result<()> { .with_target(false) .init(); eframe::run_native( - "BroLink", - brolink_client::native_options(), - Box::new(|cc| Ok(Box::new(brolink_client::app::ClientApp::new(cc)))), + "Latch", + latch_client::native_options(), + Box::new(|cc| Ok(Box::new(latch_client::app::ClientApp::new(cc)))), ) .map_err(|e| anyhow::anyhow!("{e}")) } diff --git a/crates/client/src/path.rs b/crates/client/src/path.rs index 5f62f82..3381542 100644 --- a/crates/client/src/path.rs +++ b/crates/client/src/path.rs @@ -9,9 +9,9 @@ //! never turn a high RTT or the presence of a relay into a quality cap. use crate::config::{Quality, Resolution, StreamSettings}; -use brolink_core::api::NatReport; -use brolink_core::tailscale::derp_city; -use brolink_ui::Tone; +use latch_core::api::NatReport; +use latch_core::tailscale::derp_city; +use latch_ui::Tone; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Path { @@ -151,7 +151,7 @@ pub fn explain( } (None, Some(m)) => { let mut t = format!( - "BroLink Host 3.1 on {pc} would report that side of the network and say which router is in the way." + "Latch Host 3.1 on {pc} would report that side of the network and say which router is in the way." ); if m.hard == Some(true) && !m.portmap { t.push_str(" This Mac's network is a hard NAT with no UPnP, which is often enough on its own; another network here may connect directly."); @@ -399,7 +399,7 @@ mod tests { // An old host reports nothing: say so, and what the Mac knows. let t = explain("Gaming-PC", &relayed, None, Some(&mac_hard)).unwrap(); - assert!(t.contains("BroLink Host 3.1"), "{t}"); + assert!(t.contains("Latch Host 3.1"), "{t}"); assert!(t.contains("often enough on its own"), "{t}"); let t = explain("Gaming-PC", &relayed, None, None).unwrap(); assert!(t.contains("UPnP"), "{t}"); diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index fb09f01..ef00e62 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -5,10 +5,10 @@ use crate::clipboard; use crate::config::{ClientConfig, Codec, KnownPc, StreamSettings}; use crate::path::{self, Path}; use anyhow::{anyhow, bail, Result}; -use brolink_core::api::{Ack, NatReport, PinRequest, PowerAction, PowerRequest, Status}; -use brolink_core::{http, tailscale, wake, CONTROL_PORT, SUNSHINE_PORT}; -use brolink_stream::session::Server; -use brolink_stream::{Client, Event, FrameSlot, Identity, Input, Session, Settings}; +use latch_core::api::{Ack, NatReport, PinRequest, PowerAction, PowerRequest, Status}; +use latch_core::{http, tailscale, wake, CONTROL_PORT, SUNSHINE_PORT}; +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::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; @@ -29,7 +29,7 @@ const NETCHECK_EVERY: Duration = Duration::from_secs(15 * 60); /// cheap local call; the ACL changes rarely, so 30 s is plenty. const PEER_RELAY_EVERY: Duration = Duration::from_secs(30); -/// A machine on the tailnet BroLink can open, as far as this node can tell. +/// A machine on the tailnet Latch can open, as far as this node can tell. #[derive(Debug, Clone, Default, PartialEq)] pub struct Pc { pub node_id: String, @@ -39,7 +39,7 @@ pub struct Pc { pub ip: Option, /// Tailscale's view; lags a wake-up by up to half a minute. pub online: bool, - /// The BroLink control service answered. + /// The Latch control service answered. pub host: Option, /// Sunshine's port answered. pub sunshine: bool, @@ -200,7 +200,7 @@ fn learn(scan: &Discovery) { } if pc.online { // Coarse on purpose: the file is rewritten only when this moves. - let now = brolink_core::dates::now_unix(); + let now = latch_core::dates::now_unix(); if entry .last_seen_unix .is_none_or(|t| now.saturating_sub(t) > 600) @@ -241,10 +241,10 @@ pub fn scan(cfg: &ClientConfig) -> Discovery { }; let mut peers = st.machine_peers(); let local; - if std::env::var_os("BROLINK_DEV_LOCAL").is_some() { + if std::env::var_os("LATCH_DEV_LOCAL").is_some() { local = tailscale::Node { id: "local".into(), - host_name: format!("{} (this machine)", brolink_core::config::machine_name()), + host_name: format!("{} (this machine)", latch_core::config::machine_name()), os: std::env::consts::OS.into(), tailscale_ips: vec!["127.0.0.1".into()], online: true, @@ -253,7 +253,7 @@ pub fn scan(cfg: &ClientConfig) -> Discovery { peers.push(&local); } let mut pcs: Vec = peers.into_iter().map(|n| probe_peer(cfg, n)).collect(); - // A tag:relay node that also runs BroLink (a VPS desktop on the same + // 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); @@ -342,7 +342,7 @@ fn probe_peer(cfg: &ClientConfig, n: &tailscale::Node) -> Pc { fn host_status(ip: Ipv4Addr, timeout: Duration) -> Option { http::get_json::((ip, CONTROL_PORT), "/v1/status", timeout) .ok() - .filter(|s| s.app == "brolink") + .filter(|s| latch_core::legacy::is_node_app(&s.app)) } fn port_open(ip: Ipv4Addr, port: u16, timeout: Duration) -> bool { @@ -458,7 +458,7 @@ pub struct Live { pub settings: StreamSettings, /// The path as measured right before connecting. pub path: Path, - /// Clipboard both ways, through BroLink Host on the PC. + /// Clipboard both ways, through Latch Host on the PC. pub clipboard: clipboard::Sync, } @@ -499,7 +499,7 @@ impl Target { public_ip: parse(k.and_then(|k| k.public_ip.as_ref())), server_cert: k .and_then(|k| k.server_cert.as_deref()) - .and_then(brolink_stream::nvhttp::unhex), + .and_then(latch_stream::nvhttp::unhex), path: pc.path.clone(), }) } @@ -570,7 +570,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { // 1. Wake it if nothing answers. if !t.online && !port_open(t.ip, SUNSHINE_PORT, Duration::from_millis(1200)) { let mac = t.mac.ok_or_else(|| { - anyhow!("{} isn't answering, and BroLink doesn't know how to wake it yet. Turn it on once while BroLink runs there, so this machine can learn how.", t.name) + anyhow!("{} isn't answering, and Latch doesn't know how to wake it yet. Turn it on once while Latch runs there, so this machine can learn how.", t.name) })?; let start = Instant::now(); let mut last_wake = Instant::now() - Duration::from_secs(60); @@ -594,7 +594,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { } if start.elapsed() > Duration::from_secs(120) { bail!( - "{} 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 BroLink shows whether Wake-on-LAN is ready.", + "{} 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.", t.name ); } @@ -614,7 +614,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { ); if start.elapsed() > Duration::from_secs(60) { bail!( - "{} is on, but nothing is streaming from it. Open BroLink there and set up sharing on its Sharing tab.", + "{} is on, but nothing is streaming from it. Open Latch there and set up sharing on its Sharing tab.", t.name ); } @@ -623,11 +623,11 @@ fn run(c: &Connect, generation: u64) -> Result<()> { // 3. Pair if this Mac is not known to the PC yet. report(Step::Launching, "Checking pairing…".into()); - let identity = Identity::load_or_create(&brolink_core::config::data_dir()?.join("identity"))?; + 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 mut info = match client.server_info() { Ok(i) => i, - Err(e) if brolink_stream::nvhttp::is_pin_mismatch(&e) => { + Err(e) if latch_stream::nvhttp::is_pin_mismatch(&e) => { // The engine was reinstalled; the saved cert is the old one. // Forget it and pair again so the user does not have to edit // client.toml. @@ -645,7 +645,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { 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, &brolink_core::config::machine_name(), || { + let der = client.pair_cancellable(&pin, &latch_core::config::machine_name(), || { submit_pin(t.ip, &pin, &c.progress, &c.ctx); stale(&c.progress, generation) })?; @@ -781,8 +781,7 @@ fn wants_hevc(codec: Codec, server_codecs: i32) -> bool { const SCM_HEVC_MASK: i32 = 0x0F00; codec != Codec::H264 && server_codecs & SCM_HEVC_MASK != 0 - && brolink_stream::video::supported_formats() & brolink_stream::ffi::VIDEO_FORMAT_MASK_H265 - != 0 + && latch_stream::video::supported_formats() & latch_stream::ffi::VIDEO_FORMAT_MASK_H265 != 0 } /// `f` until it succeeds, `times` tries at most, giving up early once the @@ -807,12 +806,12 @@ fn retry( Err(last) } -/// Hand the PIN to BroLink Host on the PC, which types it into Sunshine. If -/// there is no BroLink Host, the PIN stays on screen for someone at the PC. +/// 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: brolink_core::config::machine_name(), + name: latch_core::config::machine_name(), }; // Pairing draws no spinner, so a new reason has to ask for its frame. let show = |detail: String| { @@ -837,8 +836,8 @@ fn submit_pin(ip: Ipv4Addr, pin: &str, progress: &Mutex, ctx: &egui::C show(reason); } Err(e) => { - tracing::info!("BroLink Host did not take the PIN: {e}"); - show("BroLink on that machine isn't answering, so it can't enter the PIN. Open BroLink there and set up sharing, then try again.".into()); + 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()); } } std::thread::sleep(Duration::from_millis(800)); @@ -849,7 +848,7 @@ fn remember_cert(node_id: &str, name: &str, der: &[u8]) { if let Err(e) = ClientConfig::update(|cfg| { let e = cfg.pcs.entry(node_id.to_string()).or_default(); e.name = name.to_string(); - e.server_cert = Some(brolink_stream::nvhttp::hex(der)); + e.server_cert = Some(latch_stream::nvhttp::hex(der)); }) { tracing::warn!("could not save the PC's certificate: {e:#}"); } @@ -870,14 +869,14 @@ pub fn power(ip: Ipv4Addr, action: PowerAction) -> Result<()> { } } -/// Send the wake packet to a PC that is awake and ask BroLink Host on it +/// Send the wake packet to a PC that is awake and ask Latch Host on it /// whether the packet arrived: the same path a real wake would take. pub fn wake_test(pc: &Pc) -> Result { let ip = pc.ip.ok_or_else(|| anyhow!("no address for {}", pc.name))?; wake_only(pc)?; std::thread::sleep(Duration::from_millis(1500)); let st = host_status(ip, Duration::from_secs(3)) - .ok_or_else(|| anyhow!("BroLink on {} isn't answering", pc.name))?; + .ok_or_else(|| anyhow!("Latch on {} isn't answering", pc.name))?; Ok(st.wake_packet_age_secs.is_some_and(|s| s <= 5)) } @@ -886,7 +885,7 @@ pub fn wake_only(pc: &Pc) -> Result { let t = Target::from_pc(pc).ok_or_else(|| anyhow!("no address for {}", pc.name))?; let mac = t.mac.ok_or_else(|| { anyhow!( - "BroLink doesn't know how to wake {} yet; turn it on once while BroLink runs there", + "Latch doesn't know how to wake {} yet; turn it on once while Latch runs there", pc.name ) })?; @@ -923,10 +922,10 @@ mod tests { } } - /// Against BroLink Host running on this machine: the packet goes out on - /// the LAN and the host reports it. `cargo test -p brolink-client wake_test_real -- --ignored` + /// Against Latch Host running on this machine: the packet goes out on + /// the LAN and the host reports it. `cargo test -p latch-client wake_test_real -- --ignored` #[test] - #[ignore = "needs BroLink Host running on this machine"] + #[ignore = "needs Latch Host running on this machine"] fn wake_test_real() { let ip: Ipv4Addr = "127.0.0.1".parse().unwrap(); let host = host_status(ip, Duration::from_secs(2)).expect("host status"); @@ -988,7 +987,7 @@ mod tests { assert_eq!(t.server_cert, Some(vec![0x30, 0x82])); } - /// `cargo test -p brolink-client scan_real -- --ignored --nocapture` + /// `cargo test -p latch-client scan_real -- --ignored --nocapture` #[test] #[ignore = "needs Tailscale"] fn scan_real_tailnet() { diff --git a/crates/client/src/settings.rs b/crates/client/src/settings.rs index 80f6b6b..b9d0a38 100644 --- a/crates/client/src/settings.rs +++ b/crates/client/src/settings.rs @@ -2,7 +2,7 @@ //! two cannot drift apart. Rows only: the caller supplies the group. use crate::config::{Codec, Preset, Quality, Resolution, StreamSettings}; -use brolink_ui::{self as ui, space}; +use latch_ui::{self as ui, space}; /// The one-line choice at the top: a named profile, or the values as set. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/client/src/share.rs b/crates/client/src/share.rs index 6a41048..17b4cd2 100644 --- a/crates/client/src/share.rs +++ b/crates/client/src/share.rs @@ -3,7 +3,7 @@ //! Sharing page through [`SharePage`]; the viewer only shows a summary row //! in the machine list and hosts the page in its window. -use brolink_core::api::Status; +use latch_core::api::Status; use parking_lot::Mutex; use std::sync::Arc; diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 5486b87..047c7cd 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -16,12 +16,12 @@ use crate::input::{self, press_chord, Held}; use crate::path::Path; use crate::session::Live; use crate::video; -use brolink_core::api::PowerAction; -use brolink_ui::{self as ui, size, space, theme, Kv, Tone, PALETTE as P}; use egui::{ Align2, Color32, CursorIcon, Event, Frame, Id, Margin, Pos2, Rect, RichText, Vec2, ViewportCommand, }; +use latch_core::api::PowerAction; +use latch_ui::{self as ui, size, space, theme, Kv, Tone, PALETTE as P}; use semver::Version; use std::time::{Duration, Instant}; @@ -77,7 +77,7 @@ pub enum Action { Power(PowerAction), Fullscreen(bool), ToggleCmd, - /// Install a new BroLink Host on the PC through the stream. + /// Install a new Latch Host on the PC through the stream. InstallHost, ApplySettings(StreamSettings), /// Whether a click on the picture captures the mouse. @@ -285,7 +285,7 @@ impl View { )); } #[cfg(test)] - let stats = brolink_stream::Stats { + let stats = latch_stream::Stats { video_problem: stats.video_problem.clone().or(self.problem.clone()), ..stats }; @@ -414,7 +414,7 @@ impl View { ctx: &egui::Context, env: &Env<'_>, region: Rect, - stats: &brolink_stream::Stats, + stats: &latch_stream::Stats, ) { let live = env.live; let mut close = false; @@ -474,7 +474,7 @@ impl View { } ui.add_space(space::XS); if ui::secondary_button(ui, "Copy diagnostics").clicked() { - ctx.copy_text(format!("BroLink {}\n{}\nRequested: {} × {}, {} fps, {} Mbps\n{stats:#?}", + ctx.copy_text(format!("Latch {}\n{}\nRequested: {} × {}, {} fps, {} Mbps\n{stats:#?}", env!("CARGO_PKG_VERSION"), path.label(), live.requested.0, live.requested.1, live.requested.2, live.settings.bitrate_kbps / 1000)); } @@ -547,7 +547,7 @@ impl View { if !overflow { let tag = ui::tag(ui, Some(path.tone()), &path.label()); if path.relayed() { - tag.on_hover_text("The route in use right now. A relay adds delay; BroLink never lowers the bitrate for it."); + tag.on_hover_text("The route in use right now. A relay adds delay; Latch never lowers the bitrate for it."); } else if path.direct == Some(true) { tag.on_hover_text("Packets go straight to the machine."); } @@ -622,7 +622,7 @@ impl View { question(ctx, "stream-install", rect, w, |ui| { ui.label( RichText::new(format!( - "Install BroLink Host {} on {}?", + "Install Latch Host {} on {}?", latest.as_deref().unwrap_or("(latest)"), live.pc )) @@ -632,7 +632,7 @@ impl View { ui::muted( ui, format!( - "{} runs {running}, which can't take updates over the network. BroLink presses Win+R on it, types one line that fetches the new version from this machine over Tailscale, and presses Enter. A PowerShell window opens there, swaps the file and restarts the service; the stream stays up.", + "{} runs {running}, which can't take updates over the network. Latch presses Win+R on it, types one line that fetches the new version from this machine over Tailscale, and presses Enter. A PowerShell window opens there, swaps the file and restarts the service; the stream stays up.", live.pc ), ); @@ -722,9 +722,9 @@ impl View { } if let Some((running, _)) = &env.old_host { ui::menu_separator(ui); - let label = format!("Update BroLink Host… (runs {running})"); + 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 BroLink Host through this stream, so later updates arrive by themselves.") + .on_hover_text("Installs the newest Latch Host through this stream, so later updates arrive by themselves.") .clicked() { self.confirm_install = true; @@ -750,7 +750,7 @@ impl View { if live.clipboard.unsupported() { ui::menu_note( ui, - "Clipboard sync needs BroLink Host 3.1 or later there (PC menu → Update BroLink Host).", + "Clipboard sync needs Latch Host 3.1 or later there (PC menu → Update Latch Host).", ); } else { ui::menu_note( @@ -943,7 +943,7 @@ impl View { screen: Rect, video: Rect, env: &Env<'_>, - stats: &brolink_stream::Stats, + stats: &latch_stream::Stats, in_gap: bool, ) { let live = env.live; @@ -1097,7 +1097,7 @@ impl View { } return; } - // Ctrl+Alt is BroLink's host key. It is read before the keys-to-PC + // 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). @@ -1277,7 +1277,7 @@ pub fn old_host( latest: Option<&Version>, ) -> Option<(Version, Option)> { let v = Version::parse(host_version).ok()?; - if brolink_core::update::host_can_receive_update(&v) { + if latch_core::update::host_can_receive_update(&v) { return None; } Some((v, latest.cloned())) @@ -1434,7 +1434,7 @@ pub(crate) mod tests { /// toolbar and overlays with no picture behind them. pub(crate) fn dummy_live(ctx: &egui::Context) -> Live { let (tx, rx) = std::sync::mpsc::channel(); - let frames = std::sync::Arc::new(brolink_stream::FrameSlot::default()); + let frames = std::sync::Arc::new(latch_stream::FrameSlot::default()); let path = crate::path::Path { direct: Some(false), relay: "tok".into(), @@ -1442,15 +1442,15 @@ pub(crate) mod tests { ..Default::default() }; let settings = crate::config::StreamSettings::default(); - let session = brolink_stream::Session::start( - brolink_stream::session::Server { + let session = latch_stream::Session::start( + latch_stream::session::Server { address: "10.255.255.1".into(), app_version: "7.1.431.-1".into(), gfe_version: "3.23.0.74".into(), rtsp_url: "rtsp://10.255.255.1:48010".into(), codec_mode_support: 1, }, - brolink_stream::Settings { + latch_stream::Settings { width: 1920, height: 1080, fps: 60, @@ -1484,7 +1484,7 @@ pub(crate) mod tests { fn paint(ctx: &egui::Context, f: &mut Fixture) { if !f.applied { - brolink_ui::apply(ctx); + latch_ui::apply(ctx); f.applied = true; return; } @@ -1766,10 +1766,10 @@ pub(crate) mod tests { let screen = Rect::from_min_size(Pos2::ZERO, size); h.get_by_label(trigger).simulate_click(); h.run_steps(3); - let menu = brolink_ui::open_menu_rect(&h.ctx).expect("the menu is open"); + let menu = latch_ui::open_menu_rect(&h.ctx).expect("the menu is open"); assert_inside(&format!("{trigger} menu at {size:?}"), menu, screen); tab_to(h, last); - let menu = brolink_ui::open_menu_rect(&h.ctx).expect("Tab keeps the menu open"); + let menu = latch_ui::open_menu_rect(&h.ctx).expect("Tab keeps the menu open"); assert_inside(&format!("{trigger} menu after Tab"), menu, screen); let item = node_rect(&h.get_by_label(last)); assert_inside(&format!("{last:?} in the {trigger} menu"), item, menu); @@ -1785,7 +1785,7 @@ pub(crate) mod tests { /// The last item each toolbar menu can show: the power items end the /// More menu, and with an old host the update item ends them. - const LAST_POWER_ITEM: &str = "Update BroLink Host… (runs 3.0.0)"; + const LAST_POWER_ITEM: &str = "Update Latch Host… (runs 3.0.0)"; #[test] fn every_toolbar_menu_fits_the_smallest_window_and_scrolls_to_its_end() { @@ -1990,10 +1990,10 @@ pub(crate) mod tests { .simulate_click(); h.run_steps(3); let screen = Rect::from_min_size(Pos2::ZERO, min); - let list = brolink_ui::open_menu_rect(&h.ctx).expect("the list is open"); + let list = latch_ui::open_menu_rect(&h.ctx).expect("the list is open"); assert_inside("the frame rate list", list, screen); tab_to(&mut h, "240 fps"); - let list = brolink_ui::open_menu_rect(&h.ctx).expect("Tab keeps the list open"); + let list = latch_ui::open_menu_rect(&h.ctx).expect("Tab keeps the list open"); assert_inside("240 fps", node_rect(&h.get_by_label("240 fps")), list); save( h.render().unwrap(), @@ -2100,12 +2100,12 @@ pub(crate) mod tests { "toasts", { let mut v = View::default(); - v.toast(Tone::Warning, "Gaming-PC has no sound to send: it reports “Unable to initialize audio capture”. Its Sharing page in BroLink says more."); + v.toast(Tone::Warning, "Gaming-PC has no sound to send: it reports “Unable to initialize audio capture”. Its Sharing page in Latch says more."); v.set_poor(true); v }, Extra { - handover: Some("Installing BroLink Host 4.0.2 on Gaming-PC: waiting for it to fetch the file…".into()), + handover: Some("Installing Latch Host 4.1.0 on Gaming-PC: waiting for it to fetch the file…".into()), ..windows() }, None, diff --git a/crates/client/src/update.rs b/crates/client/src/update.rs index fe34d7b..2421258 100644 --- a/crates/client/src/update.rs +++ b/crates/client/src/update.rs @@ -1,11 +1,11 @@ -//! Keeping this app and every BroLink Host it can see on the newest release. +//! Keeping this app and every Latch Host it can see on the newest release. //! //! The Mac does the fetching for everyone (with a GitHub login if the -//! repository is private; see `brolink_core::update`). Every few hours it asks +//! repository is private; see `latch_core::update`). Every few hours it asks //! GitHub for the latest release. A newer app is downloaded, verified against //! GitHub's digest and its own code signature, and swapped into place once no //! stream is running; the app then relaunches itself. A newer host is -//! downloaded once, and `brolink-host.exe` is sent to each PC whose host +//! downloaded once, and `latch-host.exe` is sent to each PC whose host //! already speaks `/v1/update` (3.1+) and reports an older version, over //! the same Tailscale-authenticated control API that can put the PC to //! sleep. A 3.0 host cannot take that (it caps request bodies at 64 KiB @@ -16,10 +16,10 @@ use crate::config::ClientConfig; use crate::session::{Discovery, Live, Progress}; use anyhow::{anyhow, bail, Context, Result}; -use brolink_core::api::{Ack, Status, UPDATE_PATH, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; -use brolink_core::update::{self, Release, HOST_EXE, MAC_ASSET, WINDOWS_ASSET}; -use brolink_core::{http, CONTROL_PORT}; -use brolink_ui::Tone; +use latch_core::api::{Ack, Status, UPDATE_PATH, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; +use latch_core::update::{self, Release, HOST_EXE, MAC_ASSET, WINDOWS_ASSET}; +use latch_core::{http, legacy, CONTROL_PORT}; +use latch_ui::Tone; use parking_lot::Mutex; use semver::Version; use std::collections::{BTreeMap, BTreeSet}; @@ -79,7 +79,7 @@ pub fn spawn( state.lock().message = if cfg!(windows) { "New versions arrive from the Mac on your Tailscale account.".into() } else { - "BroLink updates itself on a Mac; install new releases here by hand.".into() + "Latch updates itself on a Mac; install new releases here by hand.".into() }; return; } @@ -110,7 +110,7 @@ pub fn spawn( st.latest = Some(r.version.clone()); st.release = Some(r.clone()); st.message = if r.is_newer_than(&update::current()) { - format!("BroLink {} is available.", r.version) + format!("Latch {} is available.", r.version) } else { format!("Up to date (v{}).", update::current()) }; @@ -138,19 +138,19 @@ pub fn spawn( st.notice = Some(( Tone::Info, format!( - "BroLink {v} is downloaded and installs when no stream is running." + "Latch {v} is downloaded and installs when no stream is running." ), )); } Ok(None) => { state.lock().message = format!( - "BroLink {} is available. This copy is not in an app bundle, so it is not replaced.", + "Latch {} is available. This copy is not in an app bundle, so it is not replaced.", rel.version ); } Err(e) => { state.lock().message = - format!("Could not download BroLink {}: {e}.", rel.version); + format!("Could not download Latch {}: {e}.", rel.version); tracing::warn!("self-update: {e:#}"); // Try again at the next check rather than every tick. release = None; @@ -183,7 +183,7 @@ pub fn spawn( let mut st = state.lock(); st.ready = None; st.notice = None; - st.message = format!("Could not install BroLink {v}: {e}."); + st.message = format!("Could not install Latch {v}: {e}."); tracing::error!("self-update: {e:#}"); progress.lock().updating = false; release = None; @@ -245,7 +245,7 @@ pub fn spawn( st.rolled_back .insert(pc.node_id.clone(), rel.version.clone()); st.message = format!( - "BroLink Host {} did not start on {}, so it kept {v}. It is not sent again until BroLink restarts; its log says why.", + "Latch Host {} did not start on {}, so it kept {v}. It is not sent again until Latch restarts; its log says why.", rel.version, pc.name ); st.notice = Some((Tone::Warning, st.message.clone())); @@ -262,7 +262,7 @@ pub fn spawn( Ok(()) => { st.delivered.insert(pc.node_id.clone(), rel.version.clone()); st.message = format!( - "Sent BroLink Host {} to {}; it restarts by itself.", + "Sent Latch Host {} to {}; it restarts by itself.", rel.version, pc.name ); st.notice = Some((Tone::Info, st.message.clone())); @@ -293,7 +293,7 @@ pub fn spawn( /// Where downloads for `rel` live: `/updates//`. fn updates_dir(rel: &Release) -> Result { - let dir = brolink_core::config::data_dir()? + let dir = latch_core::config::data_dir()? .join("updates") .join(&rel.tag); std::fs::create_dir_all(&dir)?; @@ -346,7 +346,7 @@ fn fetch_asset(rel: &Release, name: &str, token: Option<&str>) -> Result Option { let exe = std::env::current_exe().ok()?; - // .../BroLink.app/Contents/MacOS/BroLink + // .../Latch.app/Contents/MacOS/Latch let app = exe.parent()?.parent()?.parent()?; (app.extension().is_some_and(|e| e == "app")).then(|| app.to_path_buf()) } @@ -373,7 +373,7 @@ fn prepare_self(rel: &Release, token: Option<&str>) -> Result> { } let tarball = fetch_asset(rel, MAC_ASSET, token)?; let unpacked = updates_dir(rel)?.join("unpacked"); - let app = unpacked.join("BroLink.app"); + let app = unpacked.join("Latch.app"); if !app.exists() { let _ = std::fs::remove_dir_all(&unpacked); std::fs::create_dir_all(&unpacked)?; @@ -388,7 +388,7 @@ fn prepare_self(rel: &Release, token: Option<&str>) -> Result> { )?; } let verified = (|| -> Result { - anyhow::ensure!(app.exists(), "the download holds no BroLink.app"); + anyhow::ensure!(app.exists(), "the download holds no Latch.app"); run( "/usr/bin/codesign", &["--verify", "--deep", "--strict", &app.display().to_string()], @@ -428,7 +428,7 @@ fn prepare_self(rel: &Release, token: Option<&str>) -> Result> { /// alive across the rename, so the swap is safe while we are still up. fn install_self(rel: &Release) -> Result<()> { let bundle = bundle_path().ok_or_else(|| anyhow!("not running from an app bundle"))?; - let new = updates_dir(rel)?.join("unpacked").join("BroLink.app"); + let new = updates_dir(rel)?.join("unpacked").join("Latch.app"); anyhow::ensure!(new.exists(), "nothing is downloaded"); replace_bundle(&bundle, &new, |new, staged| { run( @@ -456,9 +456,9 @@ fn replace_bundle( let parent = bundle .parent() .ok_or_else(|| anyhow!("app has no parent"))?; - let stage = parent.join(format!(".brolink-update-{:016x}", rand::random::())); + let stage = parent.join(format!(".latch-update-{:016x}", rand::random::())); std::fs::create_dir(&stage).context("create update staging directory")?; - let staged = stage.join("BroLink.app"); + let staged = stage.join("Latch.app"); let parked = stage.join("previous.app"); let outcome = (|| -> Result<()> { copy(new, &staged).context("stage the new app")?; @@ -499,16 +499,12 @@ fn relaunch_command(bundle: &std::path::Path) -> std::process::Command { let mut command = std::process::Command::new("/bin/sh"); // The bundle is an argument, never shell source (paths can contain $, ", // backticks and newlines). $0 is a diagnostic command name. - command.args([ - "-c", - "sleep 1; exec /usr/bin/open \"$1\"", - "brolink-relaunch", - ]); + command.args(["-c", "sleep 1; exec /usr/bin/open \"$1\"", "latch-relaunch"]); command.arg(bundle); command } -/// Extract `brolink-host.exe` from the verified Windows archive. An extracted +/// Extract `latch-host.exe` from the verified Windows archive. An extracted /// cache has no published digest, so never trust one from an earlier run. pub(crate) fn host_exe(rel: &Release, token: Option<&str>) -> Result> { let zip = fetch_asset(rel, WINDOWS_ASSET, token)?; @@ -529,7 +525,7 @@ fn should_push(running: &Version, rel: &Release) -> bool { pub fn old_host_message(name: &str, version: &Version) -> String { format!( - "{name} runs BroLink Host {version}, which cannot take an update over the network. Connect to it and choose PC → Update BroLink Host in the toolbar: this machine installs the new version through the stream. After that, updates are automatic." + "{name} runs Latch Host {version}, which cannot take an update over the network. Connect to it and choose PC → Update Latch Host in the toolbar: this machine installs the new version through the stream. After that, updates are automatic." ) } @@ -602,6 +598,10 @@ fn send_host(ip: Ipv4Addr, version: &str, sha: &str, exe: &[u8]) -> Result<()> { &[ (UPDATE_VERSION_HEADER, version), (UPDATE_SHA256_HEADER, sha), + // A host older than 4.1 knows only the headers' old names, and + // is the one this push is most likely for. + (legacy::UPDATE_VERSION_HEADER, version), + (legacy::UPDATE_SHA256_HEADER, sha), ("Content-Type", "application/octet-stream"), ], exe, @@ -650,9 +650,9 @@ mod tests { #[test] fn a_partial_update_copy_preserves_the_working_app() { - let dir = std::env::temp_dir().join(format!("brolink-swap-{:016x}", rand::random::())); + let dir = std::env::temp_dir().join(format!("latch-swap-{:016x}", rand::random::())); std::fs::create_dir(&dir).unwrap(); - let bundle = dir.join("BroLink.app"); + let bundle = dir.join("Latch.app"); std::fs::create_dir(&bundle).unwrap(); std::fs::write(bundle.join("version"), "original").unwrap(); let outcome = replace_bundle(&bundle, &dir.join("download"), |_, staged| { @@ -671,9 +671,9 @@ mod tests { #[test] fn a_staged_update_replaces_the_app_and_removes_the_backup() { - let dir = std::env::temp_dir().join(format!("brolink-swap-{:016x}", rand::random::())); + let dir = std::env::temp_dir().join(format!("latch-swap-{:016x}", rand::random::())); std::fs::create_dir(&dir).unwrap(); - let bundle = dir.join("BroLink.app"); + let bundle = dir.join("Latch.app"); std::fs::create_dir(&bundle).unwrap(); std::fs::write(bundle.join("version"), "original").unwrap(); replace_bundle(&bundle, &dir.join("download"), |_, staged| { @@ -731,7 +731,7 @@ mod tests { let msg = old_host_message("Gaming-PC", &Version::new(3, 0, 0)); assert!(msg.contains("Gaming-PC"), "{msg}"); assert!(msg.contains("3.0.0"), "{msg}"); - assert!(msg.contains("Update BroLink Host"), "{msg}"); + assert!(msg.contains("Update Latch Host"), "{msg}"); assert!(!msg.contains("Broken pipe"), "{msg}"); assert!(!msg.contains("os error"), "{msg}"); } @@ -745,7 +745,7 @@ mod tests { for mut s in listener.incoming().flatten() { let mut buf = [0u8; 1024]; let _ = s.read(&mut buf); - let body = format!(r#"{{"app":"brolink","version":"{version}"}}"#); + let body = format!(r#"{{"app":"latch","version":"{version}"}}"#); let _ = write!( s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", diff --git a/crates/client/src/video.rs b/crates/client/src/video.rs index 29e3b79..1f48c08 100644 --- a/crates/client/src/video.rs +++ b/crates/client/src/video.rs @@ -1,8 +1,8 @@ //! Drawing decoded frames with wgpu inside an egui rect: two textures (Y and //! interleaved UV) and a shader that does the BT.709 conversion. -use brolink_stream::FrameSlot; use egui_wgpu::wgpu; +use latch_stream::FrameSlot; use std::sync::Arc; const SHADER: &str = r#" @@ -104,7 +104,7 @@ pub struct Resources { impl Resources { pub fn new(device: &wgpu::Device, target: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("brolink video"), + label: Some("latch video"), source: wgpu::ShaderSource::Wgsl(SHADER.into()), }); let texture_entry = |binding| wgpu::BindGroupLayoutEntry { @@ -118,7 +118,7 @@ impl Resources { count: None, }; let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("brolink video"), + label: Some("latch video"), entries: &[ texture_entry(0), texture_entry(1), @@ -141,12 +141,12 @@ impl Resources { ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("brolink video"), + label: Some("latch video"), bind_group_layouts: &[&layout], push_constant_ranges: &[], }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("brolink video"), + label: Some("latch video"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, @@ -171,7 +171,7 @@ impl Resources { cache: None, }); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("brolink video"), + label: Some("latch video"), mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -179,7 +179,7 @@ impl Resources { ..Default::default() }); let uniforms = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("brolink video"), + label: Some("latch video"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, @@ -216,15 +216,15 @@ impl Resources { view_formats: &[], }) }; - let y = make(width, height, wgpu::TextureFormat::R8Unorm, "brolink y"); + let y = make(width, height, wgpu::TextureFormat::R8Unorm, "latch y"); let uv = make( width.div_ceil(2), height.div_ceil(2), wgpu::TextureFormat::Rg8Unorm, - "brolink uv", + "latch uv", ); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("brolink video"), + label: Some("latch video"), layout: &self.layout, entries: &[ wgpu::BindGroupEntry { @@ -377,7 +377,7 @@ mod tests { } } let frames = Arc::new(FrameSlot::default()); - frames.publish(brolink_stream::Frame { + frames.publish(latch_stream::Frame { width: 64, height: 64, y: (0..64) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index ced2cad..74e35c5 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "brolink-core" +name = "latch-core" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "Shared pieces of BroLink: the control API, Tailscale, wake packets" +description = "Shared pieces of Latch: the control API, Tailscale, wake packets" [dependencies] anyhow.workspace = true diff --git a/crates/core/assets/logo-1024.png b/crates/core/assets/logo-1024.png index 56f8445..5453259 100644 Binary files a/crates/core/assets/logo-1024.png and b/crates/core/assets/logo-1024.png differ diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index a071f37..2bd8420 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -1,6 +1,6 @@ //! The JSON the host serves and the client reads. Plain HTTP over Tailscale: //! WireGuard already encrypts it, and `tailscale whois` already says who is -//! asking, so BroLink adds neither a cipher nor a password of its own. +//! asking, so Latch adds neither a cipher nor a password of its own. //! //! Routes (all JSON, all on [`crate::CONTROL_PORT`]): //! @@ -10,7 +10,7 @@ //! | POST | /v1/pin | [`PinRequest`] | [`Ack`] | //! | POST | /v1/power | [`PowerRequest`]| [`Ack`] | //! | POST | /v1/quit | | [`Ack`] (loopback only) | -//! | POST | /v1/update | `brolink-host.exe` bytes | [`Ack`] | +//! | POST | /v1/update | `latch-host.exe` bytes | [`Ack`] | //! | GET | /v1/clipboard | | [`Clipboard`] | //! | POST | /v1/clipboard | [`Clipboard`] | [`Ack`] | //! | GET | /v1/display | | display report (JSON) | @@ -32,11 +32,11 @@ use serde::{Deserialize, Serialize}; pub const UPDATE_PATH: &str = "/v1/update"; -pub const UPDATE_VERSION_HEADER: &str = "x-brolink-version"; -pub const UPDATE_SHA256_HEADER: &str = "x-brolink-sha256"; +pub const UPDATE_VERSION_HEADER: &str = "x-latch-version"; +pub const UPDATE_SHA256_HEADER: &str = "x-latch-sha256"; /// The largest host executable accepted; the real one is a tenth of this. pub const UPDATE_MAX_BYTES: usize = 128 * 1024 * 1024; -/// Oldest BroLink Host that serves [`UPDATE_PATH`]. Older hosts cap the +/// Oldest Latch Host that serves [`UPDATE_PATH`]. Older hosts cap the /// body at 64 KiB and close; a Mac that POSTs the executable anyway sees /// a broken pipe (and on macOS, a socket timeout as EAGAIN). pub const FIRST_UPDATE_VERSION: &str = "3.1.0"; @@ -136,11 +136,11 @@ pub struct Status { pub log: Vec, } -/// The streaming engine BroLink runs on this machine. +/// The streaming engine Latch runs on this machine. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct Streamer { - /// "BroLink" when sharing is set up; empty when no engine is installed. + /// "Latch" when sharing is set up; empty when no engine is installed. pub kind: String, pub installed: bool, /// Its GameStream port answers. diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 3999223..d636eba 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -1,14 +1,14 @@ //! Where each app keeps its few settings: one TOML file in the per-user data -//! directory (`%LOCALAPPDATA%\BroLink` on Windows, `~/Library/Application -//! Support/dev.brolink.BroLink` on macOS, `~/.local/share/brolink` on Linux). +//! directory (`%LOCALAPPDATA%\Latch` on Windows, `~/Library/Application +//! Support/com.bardbro.Latch` on macOS, `~/.local/share/latch` on Linux). use anyhow::{Context, Result}; use serde::{de::DeserializeOwned, Serialize}; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{ atomic::{AtomicU64, Ordering}, - Mutex, + Mutex, Once, }; static CONFIG_LOCK: Mutex<()> = Mutex::new(()); @@ -16,22 +16,39 @@ static TEMP_ID: AtomicU64 = AtomicU64::new(0); /// The per-user data directory, created private to this user. /// -/// `BROLINK_DATA_DIR` replaces it when set. The workspace's +/// `LATCH_DATA_DIR` replaces it when set. The workspace's /// `.cargo/config.toml` sets it for `cargo test` and `cargo run`, so a test -/// run on a machine with BroLink installed leaves that install's settings, +/// run on a machine with Latch installed leaves that install's settings, /// pairing identity and logs alone. +/// +/// The first call by a copy that finds the folder 4.0 and older used, and +/// none of its own, takes that folder over: see [`adopt`]. pub fn data_dir() -> Result { - let dir = if let Some(dir) = std::env::var_os("BROLINK_DATA_DIR").filter(|d| !d.is_empty()) { - Some(PathBuf::from(dir)) + let explicit = crate::legacy::env("LATCH_DATA_DIR").map(PathBuf::from); + let adopting = explicit.is_none(); + let dir = if let Some(dir) = explicit { + Some(dir) } else if cfg!(windows) { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .map(|p| p.join(crate::APP_NAME)) } else { - directories::ProjectDirs::from("dev", "brolink", crate::APP_NAME) + directories::ProjectDirs::from("com", "bardbro", crate::APP_NAME) .map(|d| d.data_dir().to_path_buf()) } .context("no per-user data directory")?; + if adopting { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + if let Some(old) = crate::legacy::data_dir() { + match adopt(&old, &dir) { + Ok(true) => tracing::info!("took over {} as {}", old.display(), dir.display()), + Ok(false) => {} + Err(e) => tracing::warn!("could not take over {}: {e:#}", old.display()), + } + } + }); + } std::fs::create_dir_all(&dir).with_context(|| dir.display().to_string())?; #[cfg(unix)] { @@ -41,6 +58,63 @@ pub fn data_dir() -> Result { Ok(dir) } +/// Make the folder an older install left at `old` the one at `new`, when +/// `old` exists and `new` does not. Settings, the pairing identity, the +/// paired devices and the streaming engine all come along. Returns whether +/// it did. +/// +/// A folder is moved. On Windows the old one holds the running +/// `latch-host.exe` of an install that has not been set up again, and +/// Windows will not move a folder with a running program in it, so it is +/// copied without its executables and the old one is left for setup to +/// remove. +pub fn adopt(old: &Path, new: &Path) -> Result { + if new.exists() || !old.is_dir() { + return Ok(false); + } + if let Some(parent) = new.parent() { + std::fs::create_dir_all(parent)?; + } + if !cfg!(windows) && std::fs::rename(old, new).is_ok() { + return Ok(true); + } + // Staged beside the destination, so that a copy cut short never leaves a + // half-filled folder that the next start mistakes for the real one. + let stage = new.with_extension(format!("adopting-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&stage); + let copied = copy_tree(old, &stage) + .and_then(|()| std::fs::rename(&stage, new).with_context(|| new.display().to_string())); + if copied.is_err() { + let _ = std::fs::remove_dir_all(&stage); + } + copied.map(|()| true) +} + +/// Copy `from` to `to`, leaving out programs and the swap files an update +/// makes of them. A file that cannot be read (another process holds it +/// open) is skipped, not fatal: it is a log, not a setting. +fn copy_tree(from: &Path, to: &Path) -> Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let name = entry.file_name(); + let lower = name.to_string_lossy().to_ascii_lowercase(); + let src = entry.path(); + let dst = to.join(&name); + if entry.file_type()?.is_dir() { + copy_tree(&src, &dst)?; + } else if lower.ends_with(".exe") + || lower.ends_with(".exe.old") + || lower.ends_with(".exe.new") + { + continue; + } else if let Err(e) = std::fs::copy(&src, &dst) { + tracing::warn!("skipped {}: {e}", src.display()); + } + } + Ok(()) +} + /// 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. @@ -223,12 +297,12 @@ mod tests { std::fs::remove_file(path).unwrap(); } - /// `.cargo/config.toml` points `cargo test` at `target/brolink-data`. + /// `.cargo/config.toml` points `cargo test` at `target/latch-data`. /// Without it every test that loads or saves settings would read and /// rewrite the real install's files on the machine running the tests. #[test] fn tests_run_against_a_data_directory_under_target() { - let want = std::env::var_os("BROLINK_DATA_DIR").expect("cargo test sets BROLINK_DATA_DIR"); + let want = std::env::var_os("LATCH_DATA_DIR").expect("cargo test sets LATCH_DATA_DIR"); let dir = data_dir().unwrap(); assert_eq!(dir, PathBuf::from(want)); assert!( @@ -238,6 +312,66 @@ mod tests { ); } + #[test] + fn a_new_install_takes_over_the_folder_an_older_one_left() { + let root = std::env::temp_dir().join(format!("latch-adopt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let old = root.join("old"); + let new = root.join("nested/new"); + std::fs::create_dir_all(old.join("engine/config")).unwrap(); + std::fs::write(old.join("host.toml"), "sunshine_user = \"brolink\"\n").unwrap(); + std::fs::write(old.join("engine/config/state.json"), "{}").unwrap(); + std::fs::write(old.join("brolink-host.exe"), "program").unwrap(); + + assert!(adopt(&old, &new).unwrap()); + assert_eq!( + std::fs::read_to_string(new.join("host.toml")).unwrap(), + "sunshine_user = \"brolink\"\n" + ); + assert!(new.join("engine/config/state.json").exists()); + if cfg!(windows) { + // Copied: programs stay behind in the old folder. + assert!(!new.join("brolink-host.exe").exists()); + assert!(old.join("host.toml").exists()); + } else { + assert!(new.join("brolink-host.exe").exists()); + assert!(!old.exists()); + } + + // Once there is a folder of its own, nothing is taken again. + std::fs::create_dir_all(&old).unwrap(); + std::fs::write(old.join("host.toml"), "other").unwrap(); + assert!(!adopt(&old, &new).unwrap()); + assert_ne!( + std::fs::read_to_string(new.join("host.toml")).unwrap(), + "other" + ); + // No old folder, nothing to take. + assert!(!adopt(&root.join("missing"), &root.join("elsewhere")).unwrap()); + assert!(!root.join("elsewhere").exists()); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn copying_a_folder_leaves_programs_out_and_is_staged() { + let root = std::env::temp_dir().join(format!("latch-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let old = root.join("old"); + std::fs::create_dir_all(&old).unwrap(); + for f in ["a.toml", "x.exe", "x.exe.old", "x.exe.new", "X.EXE"] { + std::fs::write(old.join(f), f).unwrap(); + } + copy_tree(&old, &root.join("new")).unwrap(); + let mut got: Vec = std::fs::read_dir(root.join("new")) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + got.sort(); + assert_eq!(got, ["a.toml"]); + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] fn machine_name_is_never_empty() { assert!(!machine_name().is_empty()); diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 9b3e0c0..c84fb16 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -8,14 +8,14 @@ use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; use std::time::Duration; -/// Larger bodies are refused; nothing BroLink sends comes close, except the +/// Larger bodies are refused; nothing Latch sends comes close, except the /// host executable on [`crate::api::UPDATE_PATH`], which has its own limit. const MAX_BODY: usize = 64 * 1024; const MAX_HEADER: usize = 16 * 1024; const MAX_CONNECTIONS: usize = 16; /// Time to read headers and a small JSON body, and to write the reply. const IDLE_TIMEOUT: Duration = Duration::from_secs(15); -/// Time to read `brolink-host.exe` after the headers. Tailscale plus a +/// Time to read `latch-host.exe` after the headers. Tailscale plus a /// 10–30 MB body does not fit in [`IDLE_TIMEOUT`]. const UPDATE_BODY_TIMEOUT: Duration = Duration::from_secs(120); @@ -252,14 +252,19 @@ struct ConnectionSlot(std::sync::Arc); impl ConnectionSlot { fn acquire(active: &std::sync::Arc) -> Option { - active - .fetch_update( - std::sync::atomic::Ordering::Relaxed, - std::sync::atomic::Ordering::Relaxed, - |n| (n < MAX_CONNECTIONS).then_some(n + 1), - ) - .ok() - .map(|_| Self(active.clone())) + use std::sync::atomic::Ordering::Relaxed; + // A compare-and-swap loop rather than `fetch_update`, which Rust 1.99 + // renamed `try_update`: this builds on both. + let mut n = active.load(Relaxed); + loop { + if n >= MAX_CONNECTIONS { + return None; + } + match active.compare_exchange_weak(n, n + 1, Relaxed, Relaxed) { + Ok(_) => return Some(Self(active.clone())), + Err(seen) => n = seen, + } + } } } @@ -640,7 +645,7 @@ mod tests { 200, &serde_json::json!({ "len": req.body.len(), - "ver": req.header("X-BroLink-Version"), + "ver": req.header("X-Latch-Version"), "first": req.body.first(), }), ) @@ -652,7 +657,7 @@ mod tests { addr, "POST", crate::api::UPDATE_PATH, - &[("X-BroLink-Version", "3.1.0")], + &[("X-Latch-Version", "3.1.0")], &body, t, ) diff --git a/crates/core/src/icon.rs b/crates/core/src/icon.rs index ceefe7b..dd79ceb 100644 --- a/crates/core/src/icon.rs +++ b/crates/core/src/icon.rs @@ -1,5 +1,5 @@ //! The application icon: the checked-in logo, decoded once and resized on -//! demand. Shared by the windows (via `brolink_core::icon`) and by the host +//! demand. Shared by the windows (via `latch_core::icon`) and by the host //! build script, which includes this file by path, so it must not refer to //! anything else in this crate. @@ -8,10 +8,11 @@ use std::sync::{Mutex, OnceLock}; use image::{Rgba, RgbaImage}; -/// The 1024x1024 logo, a rounded tile on a near-black pad. +/// The 1024x1024 icon: a full-bleed ink tile with the logo knocked out in +/// white and its blue square. `scripts/make-icons.py` draws it. const LOGO_PNG: &[u8] = include_bytes!("../assets/logo-1024.png"); -/// The decoded logo. PNG inflate is the expensive part; a resize is cheap. +/// The decoded icon. PNG inflate is the expensive part; a resize is cheap. fn logo() -> &'static RgbaImage { static LOGO: OnceLock = OnceLock::new(); LOGO.get_or_init(|| { @@ -21,49 +22,11 @@ fn logo() -> &'static RgbaImage { }) } -/// Bounding box of the rounded tile: where the pad colour ends on the -/// middle row and column. -fn tile_bounds(im: &RgbaImage) -> (u32, u32, u32, u32) { - let pad = *im.get_pixel(0, 0); - let mid_x = im.width() / 2; - let mid_y = im.height() / 2; - let mut x0 = 0; - let mut x1 = im.width(); - for x in 0..im.width() { - if *im.get_pixel(x, mid_y) != pad { - x0 = x; - break; - } - } - for x in (0..im.width()).rev() { - if *im.get_pixel(x, mid_y) != pad { - x1 = x + 1; - break; - } - } - let mut y0 = 0; - let mut y1 = im.height(); - for y in 0..im.height() { - if *im.get_pixel(mid_x, y) != pad { - y0 = y; - break; - } - } - for y in (0..im.height()).rev() { - if *im.get_pixel(mid_x, y) != pad { - y1 = y + 1; - break; - } - } - (x0, y0, x1, y1) -} - /// Render the icon at `size` pixels square as straight (unpremultiplied) RGBA. /// /// The tile fills the canvas. Windows (taskbar, Explorer, the window) and /// the in-app header all draw this bitmap; macOS 26 also wants a filled -/// square and applies the rounded app-icon shape itself. Pre-padding left -/// a square plate around the mark. +/// square and applies the rounded app-icon shape itself. pub fn render(size: u32) -> Vec { // Each window's header and the window icon ask for the same size at // startup, before the first frame; the resize is the slow part. @@ -76,11 +39,8 @@ fn draw(size: u32) -> Vec { if size == 0 { return Vec::new(); } - let logo = logo(); - let (x0, y0, x1, y1) = tile_bounds(logo); - let cropped = image::imageops::crop_imm(logo, x0, y0, x1 - x0, y1 - y0).to_image(); let mut out = - image::imageops::resize(&cropped, size, size, image::imageops::FilterType::Lanczos3); + image::imageops::resize(logo(), size, size, image::imageops::FilterType::Lanczos3); // Opaque fill so the OS, not our alpha, defines the shape. for p in out.pixels_mut() { let Rgba([r, g, b, a]) = *p; @@ -106,12 +66,16 @@ mod tests { [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] } - fn is_brand_violet(p: [u8; 4]) -> bool { - p[3] == 255 && p[2] > 140 && p[0] > 80 && p[1] + 40 < p[2] + fn is_brand_blue(p: [u8; 4]) -> bool { + p[3] == 255 && p[2] > 180 && p[0] < 110 && p[1] > 100 && p[1] < 180 + } + + fn is_white(p: [u8; 4]) -> bool { + p[3] == 255 && p[0] > 240 && p[1] > 240 && p[2] > 240 } #[test] - fn renders_the_requested_size_with_opaque_corners_and_the_violet_mark() { + fn renders_the_requested_size_with_an_ink_tile_and_the_white_and_blue_mark() { for size in [16, 256] { let rgba = render(size); assert_eq!(rgba.len(), (size * size * 4) as usize); @@ -119,17 +83,15 @@ mod tests { // Windows (and the in-app header) are not a transparent hole. let [r, g, b, a] = pixel(&rgba, size, 0, 0); assert!( - r < 16 && g < 16 && b < 20 && a == 255, + r < 40 && g < 40 && b < 40 && a == 255, "corner {r},{g},{b},{a}" ); - assert!( - rgba.chunks(4) - .any(|p| is_brand_violet([p[0], p[1], p[2], p[3]])), - "no violet at {size}" - ); + let any = |f: fn([u8; 4]) -> bool| rgba.chunks(4).any(|p| f([p[0], p[1], p[2], p[3]])); + assert!(any(is_brand_blue), "no blue square at {size}"); + assert!(any(is_white), "no white mark at {size}"); } - // Cropping the pad makes the mark fill the tile: at 256, violet - // reaches outside the inner half. The old padded render did not. + // The mark fills the tile: at 256 the white square's corner lies + // well outside the inner half of the canvas. let size = 256u32; let rgba = render(size); let outside = (0..size) @@ -139,8 +101,8 @@ mod tests { let dy = y as i32 - 128; dx * dx + dy * dy > 100 * 100 }) - .any(|(x, y)| is_brand_violet(pixel(&rgba, size, x, y))); - assert!(outside, "mark still sits in a padded hole"); + .any(|(x, y)| is_white(pixel(&rgba, size, x, y))); + assert!(outside, "the mark sits in a padded hole"); } #[test] diff --git a/crates/core/src/legacy.rs b/crates/core/src/legacy.rs new file mode 100644 index 0000000..811336d --- /dev/null +++ b/crates/core/src/legacy.rs @@ -0,0 +1,93 @@ +//! What Latch answered to before 4.1.0, when it was called BroLink. +//! +//! Installed copies, other machines on the tailnet and old settings +//! folders keep the old names for a while after an upgrade, so the code +//! that recognises or retires them is all here and nowhere else. Nothing +//! in this file is shown to a person except as part of a migration. + +use std::ffi::OsString; +use std::path::PathBuf; + +/// `Status::app` from a 4.0 host or older. +pub const APP_ID: &str = "brolink"; +/// `Streamer::kind` of an engine that an old Latch set up. +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"; + +/// Whether `app` is what a Latch node (this version or an older one) puts in +/// `Status::app`. +pub fn is_node_app(app: &str) -> bool { + app == crate::APP_ID || app == APP_ID +} + +/// Whether `kind` names a streaming engine that Latch set up, under either name. +pub fn is_own_streamer_kind(kind: &str) -> bool { + kind == crate::STREAMER_KIND || kind == STREAMER_KIND +} + +/// A setting from the environment under its current name, else under the +/// old one: `LATCH_DATA_DIR`, then `BROLINK_DATA_DIR`. Unset and empty are +/// the same. +pub fn env(name: &str) -> Option { + let current = std::env::var_os(name).filter(|v| !v.is_empty()); + current.or_else(|| { + let rest = name.strip_prefix("LATCH_")?; + std::env::var_os(format!("BROLINK_{rest}")).filter(|v| !v.is_empty()) + }) +} + +/// Where 4.0 and older kept their settings, pairing identity and logs. +/// `None` when this platform has no per-user data directory. +pub fn data_dir() -> Option { + if cfg!(windows) { + std::env::var_os("LOCALAPPDATA").map(|p| PathBuf::from(p).join("BroLink")) + } else { + directories::ProjectDirs::from("dev", "brolink", "BroLink") + .map(|d| d.data_dir().to_path_buf()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_names_identify_a_node() { + assert!(is_node_app("latch")); + assert!(is_node_app("brolink")); + assert!(!is_node_app("sunshine")); + assert!(!is_node_app("")); + } + + #[test] + fn both_names_identify_our_own_engine() { + assert!(is_own_streamer_kind("Latch")); + assert!(is_own_streamer_kind("BroLink")); + assert!(!is_own_streamer_kind("Sunshine")); + assert!(!is_own_streamer_kind("Apollo")); + } + + #[test] + fn the_old_environment_name_is_a_fallback_and_the_new_one_wins() { + std::env::remove_var("LATCH_LEGACY_TEST_A"); + std::env::remove_var("BROLINK_LEGACY_TEST_A"); + assert_eq!(env("LATCH_LEGACY_TEST_A"), None); + std::env::set_var("BROLINK_LEGACY_TEST_A", "old"); + assert_eq!(env("LATCH_LEGACY_TEST_A"), Some("old".into())); + std::env::set_var("LATCH_LEGACY_TEST_A", "new"); + assert_eq!(env("LATCH_LEGACY_TEST_A"), Some("new".into())); + std::env::set_var("LATCH_LEGACY_TEST_A", ""); + assert_eq!(env("LATCH_LEGACY_TEST_A"), Some("old".into())); + std::env::remove_var("LATCH_LEGACY_TEST_A"); + std::env::remove_var("BROLINK_LEGACY_TEST_A"); + } + + #[test] + fn the_old_data_directory_is_where_4_0_kept_it() { + let dir = data_dir().expect("a per-user data directory"); + let name = dir.file_name().unwrap().to_string_lossy().to_lowercase(); + assert!(name.contains("brolink"), "{}", dir.display()); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0f0facf..ca9a6d4 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,7 +1,7 @@ -//! What BroLink nodes share: the control API they speak, the Tailscale CLI, +//! What Latch nodes share: the control API they speak, the Tailscale CLI, //! wake packets, config files, the icon. //! -//! The picture never passes through here. `brolink-stream` speaks the +//! The picture never passes through here. `latch-stream` speaks the //! GameStream protocol to the streaming engine and Tailscale carries it; //! this crate holds the rest: finding machines, waking them, pairing //! without touching them, turning them off again, and fetching new releases. @@ -11,15 +11,22 @@ pub mod config; pub mod dates; pub mod http; pub mod icon; +pub mod legacy; pub mod screens; pub mod tailscale; pub mod update; pub mod wake; -pub const APP_NAME: &str = "BroLink"; +pub const APP_NAME: &str = "Latch"; +/// `Status::app` of a Latch node, so a scan can tell one from any other +/// service that answers on the control port. +pub const APP_ID: &str = "latch"; +/// `Streamer::kind` of the streaming engine Latch itself set up, as opposed +/// to a Sunshine or Apollo it found installed. +pub const STREAMER_KIND: &str = "Latch"; /// TCP port of the host's control API, on loopback and on its Tailscale IP. pub const CONTROL_PORT: u16 = 47850; -/// TCP port a Mac serves a BroLink Host install from, on its Tailscale IP, +/// TCP port a Mac serves a Latch Host install from, on its Tailscale IP, /// for a PC whose host is too old to take `/v1/update`. pub const HANDOVER_PORT: u16 = 47851; /// Sunshine's HTTP port (pairing and launch); its web UI is one up. diff --git a/crates/core/src/tailscale.rs b/crates/core/src/tailscale.rs index 843ec78..02350f8 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -1,4 +1,4 @@ -//! Reading Tailscale through its own CLI. The tailnet is BroLink's network +//! Reading Tailscale through its own CLI. The tailnet is Latch's network //! *and* its list of trusted machines, so both apps lean on it entirely. use anyhow::{bail, Context, Result}; @@ -11,7 +11,7 @@ use std::process::{Command, Stdio}; use std::sync::OnceLock; use std::time::{Duration, Instant}; -/// The ACL tag BroLink uses for a tailnet peer-relay node. +/// The ACL tag Latch uses for a tailnet peer-relay node. pub const RELAY_TAG: &str = "tag:relay"; /// Short deadline for the optional relay probe. Other CLI calls are bounded too. @@ -59,7 +59,7 @@ fn find_cli() -> Option { }) } -/// `tailscale status --json`, the parts BroLink reads. +/// `tailscale status --json`, the parts Latch reads. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "PascalCase")] pub struct Status { diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs index 724cfe9..733fd45 100644 --- a/crates/core/src/update.rs +++ b/crates/core/src/update.rs @@ -1,12 +1,12 @@ -//! Finding and fetching BroLink releases on GitHub, for both apps' updaters. +//! Finding and fetching Latch releases on GitHub, for both apps' updaters. //! //! A public repository needs no login. For a private one the calls carry a -//! token: the caller's setting, `BROLINK_GITHUB_TOKEN`, or on macOS whatever +//! token: the caller's setting, `LATCH_GITHUB_TOKEN`, or on macOS whatever //! git has stored for github.com, which is the login the install script //! uses too. Without one the requests still go out. //! //! Only the Mac talks to GitHub. It replaces its own bundle and sends each -//! PC the new `brolink-host.exe` over the control API; see +//! PC the new `latch-host.exe` over the control API; see //! [`crate::api::UPDATE_PATH`]. use anyhow::{anyhow, bail, Context, Result}; @@ -19,15 +19,15 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; -pub const REPO: &str = "MrBeldum/brolink"; -pub const MAC_ASSET: &str = "brolink-macos-arm64.tar.gz"; -pub const WINDOWS_ASSET: &str = "brolink-windows-x64.zip"; +pub const REPO: &str = "MrBeldum/latch"; +pub const MAC_ASSET: &str = "latch-macos-arm64.tar.gz"; +pub const WINDOWS_ASSET: &str = "latch-windows-x64.zip"; /// The host executable inside [`WINDOWS_ASSET`]. -pub const HOST_EXE: &str = "brolink-host.exe"; +pub const HOST_EXE: &str = "latch-host.exe"; const TIMEOUT: Duration = Duration::from_secs(30); const MAX_HOPS: usize = 5; -/// Nothing BroLink ships comes near this. +/// Nothing Latch ships comes near this. const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] @@ -70,12 +70,12 @@ pub fn first_update() -> Version { Version::parse(crate::api::FIRST_UPDATE_VERSION).expect("FIRST_UPDATE_VERSION is semver") } -/// Whether a running host can take `brolink-host.exe` from the Mac. +/// Whether a running host can take `latch-host.exe` from the Mac. pub fn host_can_receive_update(running: &Version) -> bool { *running >= first_update() } -/// Whether a peer takes a pushed `brolink-host.exe` over `/v1/update`: +/// Whether a peer takes a pushed `latch-host.exe` over `/v1/update`: /// Windows only. `os` is the peer's `Status.os` (Tailscale's string), or /// empty from a host older than 4.0, which was always Windows. pub fn takes_pushed_host(os: &str) -> bool { @@ -90,8 +90,8 @@ pub fn token(configured: Option<&str>) -> Option { .filter(|t| !t.is_empty()) .map(str::to_string) .or_else(|| { - std::env::var("BROLINK_GITHUB_TOKEN") - .ok() + crate::legacy::env("LATCH_GITHUB_TOKEN") + .and_then(|t| t.into_string().ok()) .filter(|t| !t.trim().is_empty()) }) .or_else(git_token) @@ -320,7 +320,7 @@ pub fn fetch(url: &str, token: Option<&str>, accept: &str, sink: &mut dyn Write) let auth = token.filter(|_| host == first_host); let mut tls = connect(&host)?; let mut head = format!( - "GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: brolink/{}\r\nAccept: {accept}\r\nConnection: close\r\n", + "GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: latch/{}\r\nAccept: {accept}\r\nConnection: close\r\n", env!("CARGO_PKG_VERSION") ); if let Some(t) = auth { @@ -406,9 +406,9 @@ mod tests { const RELEASE: &str = r#"{ "tag_name": "v3.1.0", "prerelease": false, "assets": [ - {"name": "brolink-macos-arm64.tar.gz", "url": "https://api.github.com/repos/x/y/releases/assets/1", + {"name": "latch-macos-arm64.tar.gz", "url": "https://api.github.com/repos/x/y/releases/assets/1", "size": 5243666, "digest": "sha256:226CE7321E5185D10F6F83CD38916D25485D1734794119B279A72A9AF3CC4AB7"}, - {"name": "brolink-windows-x64.zip", "url": "https://api.github.com/repos/x/y/releases/assets/2", "size": 28514670} + {"name": "latch-windows-x64.zip", "url": "https://api.github.com/repos/x/y/releases/assets/2", "size": 28514670} ] }"#; @@ -547,7 +547,7 @@ mod tests { #[test] fn a_cached_asset_is_rechecked_before_use() { - let dir = std::env::temp_dir().join(format!("brolink-verify-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("latch-verify-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("x.bin"); std::fs::write(&path, b"abc").unwrap(); @@ -570,7 +570,7 @@ mod tests { #[test] fn a_bad_download_leaves_nothing_behind() { - let dir = std::env::temp_dir().join(format!("brolink-dl-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("latch-dl-{}", std::process::id())); let dest = dir.join("x.bin"); let asset = Asset { name: "x.bin".into(), diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 40e3c53..e9174a9 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -1,24 +1,24 @@ [package] -name = "brolink-host" +name = "latch-host" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "BroLink: share this machine and connect to the others" +description = "Latch: share this machine and connect to the others" build = "build.rs" [lib] -name = "brolink_host" +name = "latch_host" path = "src/lib.rs" [[bin]] -name = "brolink-host" +name = "latch-host" path = "src/main.rs" [dependencies] -brolink-core.workspace = true -brolink-client.workspace = true -brolink-ui.workspace = true +latch-core.workspace = true +latch-client.workspace = true +latch-ui.workspace = true anyhow.workspace = true serde = { workspace = true } serde_json.workspace = true diff --git a/crates/host/build.rs b/crates/host/build.rs index 4ccbd10..15e97e7 100644 --- a/crates/host/build.rs +++ b/crates/host/build.rs @@ -14,18 +14,18 @@ fn main() { println!("cargo:rerun-if-changed=../core/assets/logo-1024.png"); #[cfg(windows)] if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { - let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("brolink.ico"); + let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("latch.ico"); std::fs::write(&out, ico(&[16, 24, 32, 48, 64, 256])).expect("write icon"); let mut res = winresource::WindowsResource::new(); res.set_icon(out.to_str().unwrap()) - .set("ProductName", "BroLink") - .set("FileDescription", "BroLink Host") - .set("CompanyName", "BroLink Contributors") + .set("ProductName", "Latch") + .set("FileDescription", "Latch Host") + .set("CompanyName", "Latch Contributors") .set( "LegalCopyright", - "Copyright (c) 2026 BroLink Contributors. GPL-3.0-or-later", + "Copyright (c) 2026 Latch Contributors. GPL-3.0-or-later", ) - .set("OriginalFilename", "brolink-host.exe"); + .set("OriginalFilename", "latch-host.exe"); if let Err(e) = res.compile() { println!("cargo:warning=no Windows resources embedded: {e}"); } diff --git a/crates/host/src/app.rs b/crates/host/src/app.rs index 7190ad7..7b1d24c 100644 --- a/crates/host/src/app.rs +++ b/crates/host/src/app.rs @@ -6,11 +6,11 @@ use crate::config::{self, HostConfig}; use crate::setup; use crate::streamer::Api; -use brolink_core::api::Status; -use brolink_core::http; -use brolink_core::CONTROL_PORT; -use brolink_ui::{self as ui, space, Kv, Tone}; use eframe::egui; +use latch_core::api::Status; +use latch_core::http; +use latch_core::CONTROL_PORT; +use latch_ui::{self as ui, space, Kv, Tone}; use parking_lot::Mutex; use semver::Version; use std::cmp::Ordering; @@ -205,7 +205,7 @@ impl HostApp { return; } if !self.cfg.has_creds() && !migrate { - self.cfg.sunshine_user = "brolink".into(); + self.cfg.sunshine_user = "latch".into(); self.cfg.sunshine_pass = config::random_password(); if let Err(e) = self.cfg.save() { tracing::warn!("could not save engine login: {e:#}"); @@ -265,7 +265,7 @@ impl HostApp { tracing::warn!("autostart: {e:#}"); self.note = Some(( Tone::Danger, - format!("Couldn't change whether BroLink starts at login: {e}."), + format!("Couldn't change whether Latch starts at login: {e}."), Instant::now(), )); } @@ -306,7 +306,7 @@ impl HostApp { if !self.live { return; } - let Ok(dir) = brolink_core::config::data_dir() else { + let Ok(dir) = latch_core::config::data_dir() else { return; }; let opener = match self.os { @@ -372,7 +372,7 @@ impl HostApp { ui, Tone::Neutral, &format!( - "The background service runs BroLink {} and this window {}; BroLink is restarting the older one.", + "The background service runs Latch {} and this window {}; Latch is restarting the older one.", s.version, env!("CARGO_PKG_VERSION") ), @@ -482,11 +482,11 @@ impl HostApp { ), Os::Mac => ( "Share this Mac", - "BroLink installs the streaming engine for you. macOS then asks once to let it record the screen.", + "Latch installs the streaming engine for you. macOS then asks once to let it record the screen.", ), Os::Linux => ( "Share this machine", - "BroLink installs the streaming engine for this user and keeps it running.", + "Latch installs the streaming engine for this user and keeps it running.", ), }; let title = if done { "Sharing is set up" } else { title }; @@ -552,7 +552,7 @@ impl HostApp { Some("Keeps this PC's pairings and web login.") } else if !s.streamer.installed { Some(match (self.os, self.bundled_engine) { - (Os::Windows, true) => "Installs the engine that ships with BroLink.", + (Os::Windows, true) => "Installs the engine that ships with Latch.", _ => "Downloads the streaming engine and installs it.", }) } else { @@ -622,7 +622,7 @@ impl HostApp { } else if !s.streamer.api_ok { ( Tone::Warning, - "Running, but BroLink can't sign in to it".to_string(), + "Running, but Latch can't sign in to it".to_string(), ) } else { let encoder = match s.streamer.encoder.as_str() { @@ -832,7 +832,7 @@ impl HostApp { if ui::toggle_row( ui, &mut auto, - "Start BroLink when you log in", + "Start Latch when you log in", Some(&format!( "So others can connect after this {noun} restarts, with nobody at the keyboard." )), @@ -899,8 +899,8 @@ impl HostApp { /// What this machine's NAT means for the others, in a sentence that names /// the right kind of machine (the service log's version says "a Mac"). -fn network_sentence(n: &brolink_core::api::NatReport, noun: &str) -> String { - let city = brolink_core::tailscale::derp_city(&n.derp); +fn network_sentence(n: &latch_core::api::NatReport, noun: &str) -> String { + let city = latch_core::tailscale::derp_city(&n.derp); if !n.udp { format!("UDP is blocked here, so other machines reach this {noun} only through a Tailscale relay.") } else if n.hard == Some(true) && !n.portmap { @@ -1177,7 +1177,7 @@ mod tests { /// The Sharing page inside the unified window, rendered without a PC: /// /// ```text -/// cargo test -p brolink-host snapshots -- --ignored +/// cargo test -p latch-host snapshots -- --ignored /// ``` /// /// Nothing here reads or writes real settings or reaches the service: the @@ -1186,9 +1186,9 @@ mod tests { mod snapshots { use super::*; use crate::product::NodeApp; - use brolink_client::app::Page; - use brolink_core::api::Streamer; use egui_kittest::kittest::Queryable; + use latch_client::app::Page; + use latch_core::api::Streamer; const MIN: egui::Vec2 = egui::vec2(640.0, 420.0); const TYPICAL: egui::Vec2 = egui::vec2(1280.0, 800.0); @@ -1209,7 +1209,7 @@ mod snapshots { fn ready_status(os: &str) -> Status { Status { - app: "brolink".into(), + app: latch_core::APP_ID.into(), version: env!("CARGO_PKG_VERSION").into(), name: if os == "windows" { "GAMING-PC".into() @@ -1227,7 +1227,7 @@ mod snapshots { wake_packet_age_secs: Some(42), fast_startup: Some(false), streamer: Streamer { - kind: "BroLink".into(), + kind: latch_core::STREAMER_KIND.into(), installed: true, running: true, api_ok: true, @@ -1239,7 +1239,7 @@ mod snapshots { audio_problem: String::new(), }, power_allowed: true, - nat: Some(brolink_core::api::NatReport { + nat: Some(latch_core::api::NatReport { udp: true, ipv4: true, ipv6: false, @@ -1249,10 +1249,10 @@ mod snapshots { }), setup: vec![], log: vec![ - "BroLink 4.0.2 listening on TCP 47850".into(), + "Latch 4.1.0 listening on TCP 47850".into(), "listening for wake packets on UDP 9".into(), "Tailscale up as user@example.com (100.64.0.10)".into(), - "the streaming engine is running and BroLink is signed in".into(), + "the streaming engine is running and Latch is signed in".into(), "Wake-on-LAN ready on Ethernet (02:00:00:00:00:01)".into(), "paired \"MacBook-Pro\"".into(), ], @@ -1299,9 +1299,9 @@ mod snapshots { ]; Shared { status: Some(st), - setup_result: Some(Err("the administrator prompt was declined or setup failed (see C:\\Users\\Example User\\AppData\\Local\\BroLink\\setup.log)".into())), + setup_result: Some(Err("the administrator prompt was declined or setup failed (see C:\\Users\\Example User\\AppData\\Local\\Latch\\setup.log)".into())), setup_log: vec![ - "[14:02:11] BroLink setup started".into(), + "[14:02:11] Latch setup started".into(), "[14:02:11] Downloading the streaming engine".into(), " engine: curl: (6) Could not resolve host: api.github.com".into(), ], diff --git a/crates/host/src/audio.rs b/crates/host/src/audio.rs index 9264a4d..e7585f9 100644 --- a/crates/host/src/audio.rs +++ b/crates/host/src/audio.rs @@ -11,7 +11,7 @@ mod win { use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; - const TASK: &str = "BroLinkEngineUser"; + const TASK: &str = "LatchEngineUser"; const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// The helper scripts, written into the data directory when they are @@ -21,7 +21,7 @@ mod win { /// three files and re-register the task each time. pub fn install_helpers() -> Result { static TASK_REGISTERED: AtomicBool = AtomicBool::new(false); - let dir = brolink_core::config::data_dir()?; + let dir = latch_core::config::data_dir()?; let script = dir.join("take-over-engine.ps1"); write_if_changed(&script, include_str!("../windows/take-over-engine.ps1"))?; let launcher = dir.join("take-over-engine.vbs"); @@ -38,6 +38,11 @@ mod win { let tr = format!("wscript.exe //B //Nologo \"{}\"", launcher.display()); if create_logon_task(&tr) { TASK_REGISTERED.store(true, Ordering::Relaxed); + // An earlier version's task runs its own copy of the helper + // from the old folder, and two would race to start the + // engine. One an administrator registered survives this; + // setup removes it. + delete_logon_task(crate::legacy::WINDOWS_ENGINE_TASK); } else { tracing::info!("could not register {TASK}; the host will start the engine itself"); } @@ -52,6 +57,15 @@ mod win { std::fs::write(path, text).with_context(|| path.display().to_string()) } + fn delete_logon_task(name: &str) { + let _ = Command::new("schtasks") + .args(["/Delete", "/TN", name, "/F"]) + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + fn create_logon_task(tr: &str) -> bool { // These helpers live in the user's profile and must never gain an // elevated token through a scheduled task, including on upgrades. diff --git a/crates/host/src/brand.rs b/crates/host/src/brand.rs index 5126809..1e80955 100644 --- a/crates/host/src/brand.rs +++ b/crates/host/src/brand.rs @@ -1,11 +1,11 @@ //! The engine's executables carry the name and icon they were built with, //! and Task Manager, the volume mixer and a firewall prompt show exactly -//! that. Setup runs `brolink-host --brand-engine ` elevated, with the +//! that. Setup runs `latch-host --brand-engine ` elevated, with the //! engine stopped, and this module rewrites two resources in each file in -//! place. The version block gets BroLink's description and product name, +//! place. The version block gets Latch's description and product name, //! and every other string it carried (copyright, licence, version numbers) //! is read out first and written back unchanged. The first icon group is -//! replaced by BroLink's own, copied out of this executable. Nothing else +//! replaced by Latch's own, copied out of this executable. Nothing else //! in the files changes, and the archive they came from is never touched. use anyhow::Result; @@ -14,8 +14,8 @@ use std::path::Path; use std::time::{Duration, Instant}; /// What the engine process is called once branded. -pub const DESCRIPTION: &str = "BroLink Streaming"; -pub const SERVICE_DESCRIPTION: &str = "BroLink Streaming Service"; +pub const DESCRIPTION: &str = "Latch Streaming"; +pub const SERVICE_DESCRIPTION: &str = "Latch Streaming Service"; /// Files under the engine directory and the description each gets. pub const TARGETS: [(&str, &str); 2] = [ @@ -23,13 +23,15 @@ pub const TARGETS: [(&str, &str); 2] = [ (r"tools\sunshinesvc.exe", SERVICE_DESCRIPTION), ]; -/// True when the engine in `dir` already presents itself as BroLink. +/// True when the engine in `dir` already presents itself as Latch. pub fn is_branded(dir: &Path) -> bool { description(&dir.join(TARGETS[0].0)).as_deref() == Some(DESCRIPTION) } /// [`is_branded`], re-read at most every ten seconds: the status is polled -/// often and the answer changes only when setup runs. +/// often and the answer changes only when setup runs. An engine that an +/// earlier version branded counts: it shows Latch's name under the old +/// spelling, and setup rewrites it the next time it runs. pub fn is_branded_cached(dir: &Path) -> bool { static CACHE: Mutex> = Mutex::new(None); let mut c = CACHE.lock(); @@ -38,7 +40,9 @@ pub fn is_branded_cached(dir: &Path) -> bool { return v; } } - let v = is_branded(dir); + let v = is_branded(dir) + || description(&dir.join(TARGETS[0].0)).as_deref() + == Some(crate::legacy::ENGINE_DESCRIPTION); *c = Some((Instant::now(), v)); v } @@ -220,7 +224,7 @@ mod win { } } - /// BroLink's icon out of this executable: each group entry's header + /// Latch's icon out of this executable: each group entry's header /// with its image. fn own_icon() -> Result)>> { let me = unsafe { GetModuleHandleW(PCWSTR::null()) }.context("this executable's module")?; diff --git a/crates/host/src/clipboard.rs b/crates/host/src/clipboard.rs index 8e1cb1a..5caeb79 100644 --- a/crates/host/src/clipboard.rs +++ b/crates/host/src/clipboard.rs @@ -6,7 +6,7 @@ //! handled; an image or a file on the clipboard reads as empty text. use anyhow::Result; -use brolink_core::api::Clipboard; +use latch_core::api::Clipboard; /// Windows ends lines with CRLF; the Mac with LF. Both directions convert, /// so a paste never carries stray carriage returns. diff --git a/crates/host/src/config.rs b/crates/host/src/config.rs index 9f62130..897b5c3 100644 --- a/crates/host/src/config.rs +++ b/crates/host/src/config.rs @@ -1,4 +1,4 @@ -//! The host's few settings, in `%LOCALAPPDATA%\BroLink\host.toml`. +//! The host's few settings, in `%LOCALAPPDATA%\Latch\host.toml`. use serde::{Deserialize, Serialize}; @@ -37,14 +37,14 @@ impl Default for HostConfig { impl HostConfig { pub fn load() -> Self { - brolink_core::config::load(FILE) + latch_core::config::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 /// erases one on disk. pub fn save(&self) -> anyhow::Result<()> { - brolink_core::config::update(FILE, |disk: &mut Self| { + latch_core::config::update(FILE, |disk: &mut Self| { disk.power_allowed = self.power_allowed; disk.start_with_windows = self.start_with_windows; disk.stay_awake = self.stay_awake; diff --git a/crates/host/src/legacy.rs b/crates/host/src/legacy.rs new file mode 100644 index 0000000..e066902 --- /dev/null +++ b/crates/host/src/legacy.rs @@ -0,0 +1,119 @@ +//! What this host registered with the operating system before 4.1.0, when +//! Latch was called BroLink, and how an upgraded copy retires it. +//! +//! An upgrade by the updater swaps the program and nothing else, so the +//! launch agent, the user unit, the Windows service, the scheduled task, +//! the firewall rules and the engine's folder all still carry the old +//! name. Every one is recognised here and replaced by its new name when +//! the new copy first runs or when setup next does. Until then the old +//! registrations keep pointing at things that still exist, so a host that +//! was updated and never set up again keeps working. + +/// What an engine that an old setup branded calls itself. +pub const ENGINE_DESCRIPTION: &str = "BroLink Streaming"; +/// The engine's renamed program as an old setup left it on disk: the Mac's +/// app binary, and the Linux package's. +pub const ENGINE_EXE_NAMES: [&str; 2] = ["BroLinkStreaming", "brolink-engine"]; + +/// The folder a 4.0 Windows setup unpacked the engine into. +pub const WINDOWS_ENGINE_DIR: &str = r"C:\Program Files\BroLink\engine"; +pub const WINDOWS_SERVICE: &str = "BroLinkStream"; +/// The logon task that runs the engine as the signed-in user. +pub const WINDOWS_ENGINE_TASK: &str = "BroLinkEngineUser"; +/// The value under `HKCU\...\Run` that starts the host at logon. +pub const WINDOWS_RUN_VALUE: &str = "BroLinkHost"; +/// Firewall rules a 4.0 setup opened, by name. +pub const WINDOWS_RULES: [&str; 6] = [ + "BroLink Host", + "BroLink wake", + "BroLink Streaming TCP", + "BroLink Streaming UDP", + "BroLink Sunshine TCP", + "BroLink Sunshine UDP", +]; + +/// The Mac app's folder and its launch agent's label. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub const MAC_BUNDLE: &str = "BroLink.app"; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub const MAC_LAUNCH_AGENT: &str = "dev.brolink.node"; +/// The Linux user unit. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub const LINUX_USER_UNIT: &str = "brolink.service"; + +/// 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 + .iter() + .map(|r| format!("'{r}'")) + .collect::>() + .join(", "); + format!( + r#"Step "Retiring what an earlier version registered" +foreach ($t in '{task}') {{ + if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {{ + Stop-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction SilentlyContinue + }} +}} +foreach ($r in {rules}) {{ netsh advfirewall firewall delete rule name="$r" | Out-Null }} +$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 }} + 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: $_" }} + }} + $oldRoot = Split-Path $oldEngine + if (-not (Get-ChildItem -LiteralPath $oldRoot -Force -ErrorAction SilentlyContinue)) {{ + Remove-Item -LiteralPath $oldRoot -Force -ErrorAction SilentlyContinue + }} +}} +"#, + task = WINDOWS_ENGINE_TASK, + rules = rules, + old = WINDOWS_ENGINE_DIR, + service = WINDOWS_SERVICE, + engine = engine_dir, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_windows_fragment_removes_every_old_registration_and_moves_the_engine() { + let s = retire_windows_ps(r"C:\Program Files\Latch\engine"); + for rule in WINDOWS_RULES { + assert!(s.contains(&format!("'{rule}'")), "{rule}: {s}"); + } + 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}" + ); + // The old folder is only emptied after it has been moved from, and + // an engine already in the new place is never overwritten. + assert!(s.contains(r"-not (Test-Path -LiteralPath (Join-Path 'C:\Program Files\Latch\engine' 'sunshine.exe'))")); + assert!(!s.contains("Remove-Item -Recurse"), "{s}"); + } + + #[test] + fn an_old_engine_program_is_still_recognised() { + assert!(ENGINE_EXE_NAMES.contains(&"BroLinkStreaming")); + assert!(ENGINE_EXE_NAMES.contains(&"brolink-engine")); + } +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index cc83cc2..742e6fd 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -1,4 +1,4 @@ -//! BroLink node: control service, streaming engine setup, and the unified +//! Latch node: control service, streaming engine setup, and the unified //! window that both views other machines and shares this one. pub mod app; @@ -7,6 +7,7 @@ pub mod brand; pub mod clipboard; pub mod config; pub mod display; +pub mod legacy; pub mod logfile; pub mod migrate; pub mod power; diff --git a/crates/host/src/logfile.rs b/crates/host/src/logfile.rs index cab521c..4684bf5 100644 --- a/crates/host/src/logfile.rs +++ b/crates/host/src/logfile.rs @@ -121,7 +121,7 @@ mod tests { use super::*; fn tmpdir(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("brolink-logfile-{name}-{}", std::process::id())); + let d = std::env::temp_dir().join(format!("latch-logfile-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d @@ -175,8 +175,8 @@ mod tests { #[test] fn the_previous_generation_sits_beside_the_log() { assert_eq!( - rotated_path(Path::new("/var/brolink/panel.log")), - PathBuf::from("/var/brolink/panel.log.1") + rotated_path(Path::new("/var/latch/panel.log")), + PathBuf::from("/var/latch/panel.log.1") ); } } diff --git a/crates/host/src/main.rs b/crates/host/src/main.rs index f25e85c..3b27f1b 100644 --- a/crates/host/src/main.rs +++ b/crates/host/src/main.rs @@ -1,4 +1,4 @@ -//! BroLink. One executable, two jobs: +//! Latch. One executable, two jobs: //! //! * `--background`: the control service other machines talk to. //! * no flags: the window — a list of machines to connect to, and setup @@ -9,18 +9,18 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use anyhow::Result; -use brolink_core::config::data_dir; -use brolink_host::brand; -use brolink_host::logfile::RotatingLog; -use brolink_host::product::NodeApp; -use brolink_host::service::{self, Service}; use clap::Parser; +use latch_core::config::data_dir; +use latch_host::brand; +use latch_host::logfile::RotatingLog; +use latch_host::product::NodeApp; +use latch_host::service::{self, Service}; #[derive(Parser, Debug)] #[command( - name = "brolink-host", + name = "latch-host", version, - about = "BroLink: your machines, on any of your machines" + about = "Latch: your machines, on any of your machines" )] struct Args { /// Run the control service with no window. @@ -29,7 +29,7 @@ struct Args { /// Started by an update: wait for this process to give up the port. #[arg(long, requires = "background")] replaces: Option, - /// Give the streaming engine in DIR BroLink's name and icon. Run by + /// 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. #[arg(long, value_name = "DIR", conflicts_with = "background")] @@ -45,7 +45,7 @@ struct Args { setup_elevated: bool, /// The launching user's %LOCALAPPDATA%, so an elevated setup approved /// with another administrator's password still uses that user's - /// BroLink folder (host.toml, setup.log). + /// Latch folder (host.toml, setup.log). #[arg(long, value_name = "DIR", requires = "setup_elevated")] local_app_data: Option, } @@ -63,6 +63,16 @@ fn main() -> Result<()> { } else { "panel.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))] + latch_host::unix_setup::repair_adopted_engine_config(); + #[cfg(target_os = "macos")] + if let Err(e) = latch_host::unix_setup::adopt_legacy_bundle(args.background) { + // Only returns when there was nothing to move or the move failed; + // either way this copy runs from where it is. + tracing::warn!("could not move into Latch.app: {e:#}"); + } if args.background { return Service::new().run_arc(args.replaces.is_some()); } @@ -70,7 +80,7 @@ fn main() -> Result<()> { return run_setup(); } if args.setup_elevated { - return brolink_host::setup::run_as_admin(); + return latch_host::setup::run_as_admin(); } if let Some(dir) = &args.brand_engine { return brand::brand(dir).map_err(|e| { @@ -80,22 +90,22 @@ fn main() -> Result<()> { } service::ensure_service_running(); eframe::run_native( - "BroLink", - brolink_client::native_options(), + "Latch", + latch_client::native_options(), Box::new(|cc| Ok(Box::new(NodeApp::new(cc)))), ) .map_err(|e| anyhow::anyhow!("{e}")) } fn run_setup() -> Result<()> { - let mut cfg = brolink_host::config::HostConfig::load(); + let mut cfg = latch_host::config::HostConfig::load(); if !cfg.has_creds() { - cfg.sunshine_user = "brolink".into(); - cfg.sunshine_pass = brolink_host::config::random_password(); + cfg.sunshine_user = "latch".into(); + cfg.sunshine_pass = latch_host::config::random_password(); cfg.save()?; } let exe = std::env::current_exe()?; - brolink_host::setup::run(&brolink_host::setup::Plan { + latch_host::setup::run(&latch_host::setup::Plan { exe: &exe, install_engine: true, migrate: false, @@ -113,7 +123,7 @@ fn run_setup() -> Result<()> { /// swapchain reports suboptimal (`wgpu-hal`, `vulkan/mod.rs`). On Hermes /// that is every frame the window draws, and it was **every line** of a /// 32 MB `panel.log` — the condition is normal and wgpu recreates the -/// swapchain itself, so there is nothing to act on. BroLink stays at info +/// swapchain itself, so there is nothing to act on. Latch stays at info /// and that one target is heard from only when it is an error. const DEFAULT_LOG: &str = "info,wgpu_hal=error"; diff --git a/crates/host/src/migrate.rs b/crates/host/src/migrate.rs index 8ee5047..3ec39a7 100644 --- a/crates/host/src/migrate.rs +++ b/crates/host/src/migrate.rs @@ -22,10 +22,10 @@ pub const OLD_CONFIG: &[&str] = &[ r"C:\Program Files\Apollo\config", ]; -/// Restart-Service match after a creds write; includes the BroLink service. -pub const SERVICE_MATCH: &str = "Sunshine|Apollo|BroLinkStream"; +/// Restart-Service match after a creds write; includes the Latch service. +pub const SERVICE_MATCH: &str = "Sunshine|Apollo|LatchStream"; -/// Stop+disable only the upstream engines, never BroLinkStream. +/// Stop+disable only the upstream engines, never LatchStream. pub const OLD_SERVICE_MATCH: &str = "Sunshine|Apollo"; pub fn uses_old_engine(kind: &str) -> bool { @@ -38,7 +38,7 @@ fn ps_rel(parts: &[&str]) -> String { pub fn stop_old_ps() -> String { format!( - r#"Step "Stopping the old streaming service so only BroLink listens" + r#"Step "Stopping the old streaming service so only Latch listens" Get-Service | Where-Object {{ $_.Name -match '{OLD_SERVICE_MATCH}' }} | ForEach-Object {{ Stop-Service -Name $_.Name -Force -ErrorAction SilentlyContinue Set-Service -Name $_.Name -StartupType Disabled -ErrorAction SilentlyContinue @@ -64,7 +64,7 @@ pub fn copy_ps(engine_dir: &str) -> String { .collect::>() .join(", "); format!( - r#"Step "Copying streaming state into BroLink" + r#"Step "Copying streaming state into Latch" $src = @({sources}) | Where-Object {{ Test-Path (Join-Path $_ 'sunshine_state.json') }} | Select-Object -First 1 if (-not $src) {{ throw "no previous engine state to migrate" }} $dest = Join-Path '{engine}' 'config' @@ -134,7 +134,7 @@ if (-not (Test-Path -LiteralPath (Join-Path $oldRoot 'sunshine.exe'))) {{ }} }} "#, - port = brolink_core::SUNSHINE_PORT, + port = latch_core::SUNSHINE_PORT, code = PRODUCT_CODE, ) } @@ -147,7 +147,7 @@ mod tests { fn only_upstream_engines_need_migration() { assert!(uses_old_engine("Sunshine")); assert!(uses_old_engine("Apollo")); - assert!(!uses_old_engine("BroLink")); + assert!(!uses_old_engine("Latch")); assert!(!uses_old_engine("")); } @@ -180,13 +180,13 @@ mod tests { "{line}" ); } - assert!(!s.contains("BroLink\\engine"), "{s}"); + assert!(!s.contains("Latch\\engine"), "{s}"); assert!(after_start_ps(true).is_empty()); } #[test] fn copy_ps_names_the_live_inventory() { - let s = copy_ps(r"C:\Program Files\BroLink\engine"); + let s = copy_ps(r"C:\Program Files\Latch\engine"); assert!(s.contains("sunshine_state.json"), "{s}"); assert!(s.contains(r"credentials\cacert.pem"), "{s}"); assert!(s.contains(r"credentials\cakey.pem"), "{s}"); diff --git a/crates/host/src/power.rs b/crates/host/src/power.rs index ca89c88..f2d830a 100644 --- a/crates/host/src/power.rs +++ b/crates/host/src/power.rs @@ -1,9 +1,9 @@ //! Remote power actions, and keeping the PC from idle-sleeping. use anyhow::Result; -use brolink_core::api::PowerAction; +use latch_core::api::PowerAction; -/// Prevent Windows from idle-sleeping while BroLink Host is running, so +/// 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. diff --git a/crates/host/src/product.rs b/crates/host/src/product.rs index f1e6163..1308382 100644 --- a/crates/host/src/product.rs +++ b/crates/host/src/product.rs @@ -1,12 +1,12 @@ -//! The one BroLink window: the machine list to connect from, this -//! machine's Sharing page, and settings. The viewer (`brolink-client`) +//! The one Latch window: the machine list to connect from, this +//! machine's Sharing page, and settings. The viewer (`latch-client`) //! draws the window; the Sharing page is drawn by [`HostApp`] through //! [`SharePage`], and a summary of it is handed over for the machine list. use crate::app::{HostApp, Os, Shared}; -use brolink_client::app::ClientApp; -use brolink_client::share::{LocalShare, SharePage, Slot}; use eframe::egui; +use latch_client::app::ClientApp; +use latch_client::share::{LocalShare, SharePage, Slot}; use parking_lot::Mutex; use std::cell::RefCell; use std::rc::Rc; @@ -54,7 +54,7 @@ impl NodeApp { #[doc(hidden)] pub fn headless( cc: &eframe::CreationContext<'_>, - discovery: brolink_client::session::Discovery, + discovery: latch_client::session::Discovery, shared: Shared, os: Os, ) -> Self { @@ -62,7 +62,7 @@ impl NodeApp { cc, discovery, Default::default(), - brolink_client::config::ClientConfig::default(), + latch_client::config::ClientConfig::default(), ); let host = HostApp::headless(Arc::new(Mutex::new(shared)), os); Self::assemble(client, host) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 00e5f08..1f719e2 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -15,13 +15,13 @@ use crate::streamer::{self, Api, Install}; use crate::update; use crate::wake::{self, WakeInfo}; use anyhow::{Context, Result}; -use brolink_core::api::{ +use latch_core::api::{ Ack, Clipboard, DisplayRequest, NatReport, PinRequest, PowerRequest, Status, Streamer, CLIPBOARD_PATH, UPDATE_PATH, }; -use brolink_core::http::{self, Request, Response}; -use brolink_core::tailscale::{self, is_tailnet_ip}; -use brolink_core::CONTROL_PORT; +use latch_core::http::{self, Request, Response}; +use latch_core::tailscale::{self, is_tailnet_ip}; +use latch_core::CONTROL_PORT; use parking_lot::Mutex; use std::collections::{HashMap, VecDeque}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; @@ -68,7 +68,7 @@ impl Service { pub fn new() -> Self { Self { cfg: Mutex::new(HostConfig::load()), - name: Mutex::new(brolink_core::config::machine_name()), + name: Mutex::new(latch_core::config::machine_name()), tailscale: Mutex::new(Err("not checked yet".into())), wake: Mutex::new(WakeInfo::default()), wake_seen: Mutex::new(None), @@ -129,7 +129,7 @@ impl Service { // restarts on failure then leaves the running one alone // instead of respawning this one every few seconds. self.log(format!( - "another BroLink service already answers on TCP {CONTROL_PORT}; this copy exits ({e})" + "another Latch service already answers on TCP {CONTROL_PORT}; this copy exits ({e})" )); return Ok(()); } @@ -137,7 +137,7 @@ impl Service { } }; self.log(format!( - "BroLink Host {} listening on TCP {CONTROL_PORT}", + "Latch Host {} listening on TCP {CONTROL_PORT}", env!("CARGO_PKG_VERSION") )); if let Ok(exe) = std::env::current_exe() { @@ -235,10 +235,10 @@ impl Service { } else { running && self.streamer.lock().api_ok }; - // Apply the streaming profile on any engine BroLink can log into, + // Apply the streaming profile on any engine Latch can log into, // after any old app has ended. The marker survives panel/config saves. if tick.is_multiple_of(6) && api_ok && install.is_some() { - if let Ok(dir) = brolink_core::config::data_dir() { + if let Ok(dir) = latch_core::config::data_dir() { let marker = dir.join("stream-profile-v3"); if !marker.exists() { let api = Api { @@ -292,11 +292,11 @@ impl Service { if (cur.installed, cur.running, cur.api_ok) != (st.installed, st.running, st.api_ok) { self.log(match (&st.installed, &st.running, &st.api_ok) { (false, _, _) => "the streaming engine is not installed".to_string(), - (true, false, _) => "BroLink is installed but not sharing yet".to_string(), + (true, false, _) => "Latch is installed but not sharing yet".to_string(), (true, true, false) => { - "BroLink is running; the streaming engine has no working login yet".into() + "Latch is running; the streaming engine has no working login yet".into() } - (true, true, true) => "BroLink is sharing this machine".to_string(), + (true, true, true) => "Latch is sharing this machine".to_string(), }); } if cur.encoder != st.encoder && !st.encoder.is_empty() { @@ -334,7 +334,7 @@ impl Service { } if tick.is_multiple_of(12) { - *self.name.lock() = brolink_core::config::machine_name(); + *self.name.lock() = latch_core::config::machine_name(); let w = wake::probe(); let mut cur = self.wake.lock(); if *cur != w { @@ -385,7 +385,7 @@ impl Service { } /// A magic packet arrived while awake: remember when, for the status. - fn wake_packet(&self, mac: brolink_core::wake::MacAddr, from: SocketAddr) { + fn wake_packet(&self, mac: latch_core::wake::MacAddr, from: SocketAddr) { let own = self.wake.lock().mac.clone(); if own.as_deref() != Some(mac.to_string().as_str()) { return; @@ -411,10 +411,11 @@ impl Service { } else if !streamer.running { setup.push("The streaming engine is installed but not running.".into()); } else if !streamer.api_ok { - setup.push("BroLink has no working login for the streaming engine.".into()); + setup.push("Latch has no working login for the streaming engine.".into()); } else if cfg!(windows) - && streamer.kind == "BroLink" - && !crate::brand::is_branded_cached(std::path::Path::new(crate::streamer::ENGINE_DIR)) + && latch_core::legacy::is_own_streamer_kind(&streamer.kind) + && !crate::streamer::find() + .is_some_and(|install| crate::brand::is_branded_cached(&install.dir)) { setup.push( "The streaming engine still shows its upstream name and icon in Task Manager." @@ -433,7 +434,7 @@ impl Service { ); } Status { - app: "brolink".into(), + app: latch_core::APP_ID.into(), version: env!("CARGO_PKG_VERSION").into(), name: self.name.lock().clone(), os: ts @@ -570,14 +571,14 @@ impl Service { // A web page open in a browser on an authorised machine (this PC, // or the Mac) can POST here without asking: a cross-origin form // post needs no preflight, and sleep, quit or a clipboard write - // happen whether or not the page can read the reply. BroLink's own + // happen whether or not the page can read the reply. Latch's own // callers send JSON or an executable, neither of which a browser // can send cross-origin without a preflight that nothing here // answers. - if req.method == "POST" && !sent_by_brolink(req) { + if req.method == "POST" && !sent_by_latch(req) { return Response::json( 403, - &Ack::err("a BroLink request carries JSON or an executable"), + &Ack::err("a Latch request carries JSON or an executable"), ); } let local = peer.ip().is_loopback(); @@ -611,7 +612,7 @@ impl Service { } } - /// A newer `brolink-host.exe` from the Mac: stage it, answer, then swap + /// A newer `latch-host.exe` from the Mac: stage it, answer, then swap /// it in and hand over. See [`crate::update`]. fn update(&self, from: IpAddr, req: &Request) -> Response { if !cfg!(windows) { @@ -620,7 +621,9 @@ impl Service { // fail to start and be retried. return Response::json( 400, - &Ack::err("only a Windows PC takes a pushed brolink-host.exe; this machine updates itself"), + &Ack::err( + "only a Windows PC takes a pushed latch-host.exe; this machine updates itself", + ), ); } if self.update_running.swap(true, Ordering::AcqRel) { @@ -645,7 +648,7 @@ impl Service { match update::stage(req, &exe) { Ok(version) => { self.log(format!( - "updating to {version}: {from} sent the new BroLink Host" + "updating to {version}: {from} sent the new Latch Host" )); let running = self.update_running.clone(); let log = self.log.clone(); @@ -684,7 +687,7 @@ impl Service { return Response::json( 502, &Ack::err( - "BroLink cannot log in to the streaming engine on this PC; run setup there", + "Latch cannot log in to the streaming engine on this PC; run setup there", ), ); } @@ -909,10 +912,10 @@ pub fn ensure_service_running() { } } -/// The `Content-Type` BroLink's own callers send: JSON, or the executable +/// 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. -fn sent_by_brolink(req: &Request) -> bool { +fn sent_by_latch(req: &Request) -> bool { let ct = req .header("content-type") .unwrap_or("") @@ -1050,7 +1053,7 @@ mod tests { headers: vec![("content-type".into(), "application/json-not-really".into())], ..Default::default() }; - assert!(!sent_by_brolink(&req)); + assert!(!sent_by_latch(&req)); } #[test] @@ -1074,7 +1077,7 @@ mod tests { #[test] fn wake_evidence_requires_a_known_matching_adapter() { let svc = Service::new(); - let mac = brolink_core::wake::MacAddr::parse("02:00:00:00:00:01").unwrap(); + let mac = latch_core::wake::MacAddr::parse("02:00:00:00:00:01").unwrap(); let from = "192.168.1.2:9".parse().unwrap(); svc.wake_packet(mac, from); assert!(svc.wake_seen.lock().is_none()); @@ -1099,7 +1102,7 @@ mod tests { let r = svc.handle("127.0.0.1:5".parse().unwrap(), &req); assert_eq!(r.status, 200); let st: Status = r.parse().unwrap(); - assert_eq!(st.app, "brolink"); + assert_eq!(st.app, "latch"); assert!(!st.name.is_empty()); } @@ -1140,7 +1143,7 @@ mod tests { method: "POST".into(), path: UPDATE_PATH.into(), headers: vec![ - ("x-brolink-version".into(), "0.0.1".into()), + ("x-latch-version".into(), "0.0.1".into()), ("content-type".into(), "application/octet-stream".into()), ], body: b"MZ".to_vec(), @@ -1191,7 +1194,7 @@ mod tests { assert_eq!(r.status, 403, "{path} with {ct:?}: {}", r.body); } } - // BroLink's own callers are unaffected, whatever the case of the header. + // Latch's own callers are unaffected, whatever the case of the header. let r = svc.handle( local, &Request { diff --git a/crates/host/src/setup.rs b/crates/host/src/setup.rs index 0eaa73a..d75adb4 100644 --- a/crates/host/src/setup.rs +++ b/crates/host/src/setup.rs @@ -2,7 +2,7 @@ //! //! Everything that needs elevation is done by a single PowerShell script //! behind a single UAC prompt: install the bundled Sunshine if none is -//! present, give it the login BroLink will use, open the control port to the +//! present, give it the login Latch will use, open the control port to the //! tailnet, list the Mac screen sizes on the virtual display, turn Fast //! Startup off and arm the network card for Wake-on-LAN. //! Each step logs and carries on, so one failure does not undo the others; @@ -12,8 +12,8 @@ #[cfg(windows)] use anyhow::Context; use anyhow::Result; -use brolink_core::config::data_dir; -use brolink_core::CONTROL_PORT; +use latch_core::config::data_dir; +use latch_core::CONTROL_PORT; use std::path::{Path, PathBuf}; pub struct Plan<'a> { @@ -36,9 +36,9 @@ pub fn log_path() -> Option { data_dir().ok().map(|d| d.join("setup.log")) } -/// The engine archive shipped beside `brolink-host.exe`. The lite archive +/// The engine archive shipped beside `latch-host.exe`. The lite archive /// is a plain zip: no Add/Remove Programs entry, no Start Menu shortcut and -/// no service or firewall rule of its own, so BroLink names all three. +/// no service or firewall rule of its own, so Latch names all three. pub const ENGINE_ZIP: &str = "Sunshine-Windows-AMD64-lite.zip"; /// The upstream release the engine is pinned to, and the SHA-256 GitHub @@ -55,18 +55,18 @@ pub const ENGINE_ZIP_ROOT: &str = "Sunshine"; /// the copy in `ENGINE_DIR`, because the copy is what the service points at. pub const ENGINE_REQUIRED: [&str; 2] = ["sunshine.exe", r"tools\sunshinesvc.exe"]; -/// The service BroLink registers, and the name it shows under. The engine's +/// The service Latch registers, and the name it shows under. The engine's /// service wrapper is `SERVICE_WIN32_OWN_PROCESS`, for which Windows ignores /// the name the process passes to `StartServiceCtrlDispatcher`, so the SCM -/// name is BroLink's to pick. `UPSTREAM_SERVICE` is the name that wrapper +/// name is Latch's to pick. `UPSTREAM_SERVICE` is the name that wrapper /// was compiled with, kept only as the name the script falls back *to* /// after proving `SERVICE` will not start. -pub const SERVICE: &str = "BroLinkStream"; -pub const SERVICE_DISPLAY: &str = "BroLink Streaming"; +pub const SERVICE: &str = "LatchStream"; +pub const SERVICE_DISPLAY: &str = "Latch Streaming"; pub const UPSTREAM_SERVICE: &str = "SunshineService"; -pub const TCP_RULE: &str = "BroLink Streaming TCP"; -pub const UDP_RULE: &str = "BroLink Streaming UDP"; +pub const TCP_RULE: &str = "Latch Streaming TCP"; +pub const UDP_RULE: &str = "Latch Streaming UDP"; /// Keys written into `config\sunshine.conf` before the engine first starts. /// From Sunshine v2026.906.222525 (`cb72dff`): `system_tray` is the tray @@ -105,7 +105,7 @@ pub const ENGINE_CONF: &[(&str, &str)] = &[ ("vt_realtime", "enabled"), ]; -/// The only streamable app BroLink launches. Upstream ships extra entries +/// 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#"{ "apps": [ @@ -229,8 +229,12 @@ fn win_dir(path: &Path) -> String { pub fn script(p: &Plan<'_>) -> String { let q = |s: &str| s.replace('\'', "''"); let exe_dir = win_dir(p.exe); + // The folder an earlier version used is left out: the retire step has + // moved it to ENGINE_DIR by now, and an engine that could not be moved + // must not be mistaken for one to keep, with its pairing state missing. let dirs = crate::streamer::INSTALL_DIRS .iter() + .filter(|(_, d)| *d != crate::legacy::WINDOWS_ENGINE_DIR) .map(|(_, d)| format!("'{d}'")) .collect::>() .join(", "); @@ -271,19 +275,19 @@ if ($needFiles -or -not $svcUp) {{ # (LocalSystem's TEMP is C:\Windows\Temp) would let one run delete another # run's staging, and -Recurse -Force on a guessable shared path is a # footgun even when nothing else is running. - $staging = Join-Path $env:TEMP ('brolink-engine-' + [guid]::NewGuid().ToString('N')) + $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 BroLink" + Step "Installing the streaming engine that ships with Latch" }} else {{ Step "Downloading the streaming engine {tag}" - $json = curl.exe -sSL -A brolink https://api.github.com/repos/{repo}/releases/tags/{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)" }} $asset = ($json | ConvertFrom-Json).assets | Where-Object {{ $_.name -like '*lite.zip' }} | Select-Object -First 1 if (-not $asset) {{ throw "no lite archive in engine release {tag}" }} $zip = Join-Path $staging 'engine.zip' - curl.exe -sSL -A brolink -o $zip $asset.browser_download_url + curl.exe -sSL -A latch -o $zip $asset.browser_download_url if ($LASTEXITCODE -ne 0) {{ throw "downloading the engine archive failed (curl exit $LASTEXITCODE)" }} }} if (-not (Test-Path $zip)) {{ throw "the engine archive is missing: $zip" }} @@ -318,7 +322,7 @@ if ($needFiles -or -not $svcUp) {{ # should dispatch fine. That is proven here rather than assumed: if the # service will not reach Running it is removed and re-registered under # '{upstream}', the name the wrapper was built with, still displayed as - # "{display}". An existing '{upstream}' belongs to an engine BroLink did + # "{display}". An existing '{upstream}' belongs to an engine Latch did # not install and is never touched - migrating that one is a later step. $svcBin = '"' + (Join-Path $dir 'tools\sunshinesvc.exe') + '"' $svc = '{service}' @@ -329,7 +333,7 @@ if ($needFiles -or -not $svcUp) {{ $deadline = (Get-Date).AddSeconds(15) while ((Get-Service -Name $svc -ErrorAction SilentlyContinue) -and ((Get-Date) -lt $deadline)) {{ Start-Sleep -Milliseconds 400 }} }} - New-Service -Name $svc -BinaryPathName $svcBin -DisplayName '{display}' -StartupType Automatic -Description 'Streams this PC to BroLink.' | Out-Null + New-Service -Name $svc -BinaryPathName $svcBin -DisplayName '{display}' -StartupType Automatic -Description 'Streams this PC to Latch.' | Out-Null Start-Service -Name $svc -ErrorAction SilentlyContinue if ((Get-Service -Name $svc -ErrorAction SilentlyContinue).Status -ne 'Running') {{ Step " '{service}' would not start; re-registering under the engine's own service name" @@ -339,7 +343,7 @@ if ($needFiles -or -not $svcUp) {{ while ((Get-Service -Name $svc -ErrorAction SilentlyContinue) -and ((Get-Date) -lt $deadline)) {{ Start-Sleep -Milliseconds 400 }} if (Get-Service -Name '{upstream}' -ErrorAction SilentlyContinue) {{ throw "'{service}' would not start and '{upstream}' already exists; leaving that engine alone" }} $svc = '{upstream}' - New-Service -Name $svc -BinaryPathName $svcBin -DisplayName '{display}' -StartupType Automatic -Description 'Streams this PC to BroLink.' | Out-Null + New-Service -Name $svc -BinaryPathName $svcBin -DisplayName '{display}' -StartupType Automatic -Description 'Streams this PC to Latch.' | Out-Null Start-Service -Name $svc -ErrorAction SilentlyContinue }} if ((Get-Service -Name $svc -ErrorAction SilentlyContinue).Status -ne 'Running') {{ throw "the {display} service did not start" }} @@ -368,7 +372,7 @@ if ($needFiles -or -not $svcUp) {{ } else { String::new() }; - // Only the engine BroLink installed is ever branded: an engine someone + // Only the engine Latch installed is ever branded: an engine someone // else put in Program Files is theirs. A dry run changes no files. let brand_existing = if p.dry_run { String::new() @@ -419,7 +423,7 @@ try {{ powercfg /deviceenablewake $wakeDevice | Out-Null }} catch {{ Write-Outpu " Step \"Keeping the migrated web login\"\n".to_string() } else { format!( - r#" Step "Setting the engine login BroLink uses" + r#" Step "Setting the engine login Latch uses" Push-Location $dir & (Join-Path $dir 'sunshine.exe') --creds '{user}' '{pass}' 2>&1 | Out-Null Pop-Location @@ -432,12 +436,12 @@ try {{ powercfg /deviceenablewake $wakeDevice | Out-Null }} catch {{ Write-Outpu ) }; format!( - r#"# BroLink setup. Generated; re-run "Set up this PC" in BroLink Host rather than editing. + r#"# Latch setup. Generated; re-run "Set up this PC" in Latch Host rather than editing. $ErrorActionPreference = 'Continue' function Step($m) {{ Write-Output "[$(Get-Date -Format HH:mm:ss)] $m" }} function Brand-Engine($d) {{ # Task Manager, the volume mixer and a firewall prompt show a program's - # version block and icon. The engine's executables get BroLink's, in + # version block and icon. The engine's executables get Latch's, in # place, with their copyright and licence strings kept; the archive they # were unpacked from is untouched. The engine has to be stopped for the # rewrite, so only services whose binary is inside this directory are @@ -445,12 +449,12 @@ function Brand-Engine($d) {{ # cosmetic: streaming works either way, so it is reported, not thrown. $exe = Join-Path $d 'sunshine.exe' if ((Get-Item -LiteralPath $exe -ErrorAction SilentlyContinue).VersionInfo.FileDescription -eq '{description}') {{ return }} - Step "Giving the engine BroLink's name and icon" + Step "Giving the engine Latch's name and icon" $held = @() try {{ $held = @(Get-CimInstance Win32_Service | Where-Object {{ $_.State -eq 'Running' -and $_.PathName -like ('*' + $d + '*') }} | ForEach-Object {{ $_.Name }}) foreach ($s in $held) {{ Stop-Service -Name $s -Force -ErrorAction SilentlyContinue }} - $errFile = Join-Path $env:TEMP ('brolink-brand-' + [guid]::NewGuid().ToString('N') + '.txt') + $errFile = Join-Path $env:TEMP ('latch-brand-' + [guid]::NewGuid().ToString('N') + '.txt') $p = Start-Process -FilePath '{host_exe}' -ArgumentList @('--brand-engine', ('"' + $d + '"')) -Wait -PassThru -WindowStyle Hidden -RedirectStandardError $errFile if ($p.ExitCode -ne 0) {{ $why = Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue @@ -464,9 +468,9 @@ function Brand-Engine($d) {{ }} }} {conceal} -Step "BroLink setup started" +Step "Latch setup started" $migrate = {migrate_flag} -$dir = @({dirs}) | Where-Object {{ Test-Path (Join-Path $_ 'sunshine.exe') }} | Select-Object -First 1 +{retire}$dir = @({dirs}) | Where-Object {{ Test-Path (Join-Path $_ 'sunshine.exe') }} | Select-Object -First 1 # Set when the engine step fails. The wake and host-firewall steps below are # independent and still run, but the script exits non-zero at the end so the # caller does not report a successful setup over a broken engine. @@ -490,19 +494,19 @@ try {{ $ErrorActionPreference = $keepEAP if ($staging -and (Test-Path $staging)) {{ Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue }} }} -Step "Opening TCP {port} to the tailnet for BroLink Host" -netsh advfirewall firewall delete rule name="BroLink Host" | Out-Null -netsh advfirewall firewall add rule name="BroLink Host" dir=in action=allow protocol=TCP localport={port} remoteip=100.64.0.0/10 program='{exe}' | Out-Null +Step "Opening TCP {port} to the tailnet for Latch Host" +netsh advfirewall firewall delete rule name="Latch Host" | Out-Null +netsh advfirewall firewall add rule name="Latch Host" dir=in action=allow protocol=TCP localport={port} remoteip=100.64.0.0/10 program='{exe}' | Out-Null Step "Opening UDP 9 so a Mac can check its wake path while this PC is awake" -netsh advfirewall firewall delete rule name="BroLink wake" | Out-Null -netsh advfirewall firewall add rule name="BroLink wake" dir=in action=allow protocol=UDP localport=9 program='{exe}' | Out-Null +netsh advfirewall firewall delete rule name="Latch wake" | Out-Null +netsh advfirewall firewall add rule name="Latch wake" dir=in action=allow protocol=UDP localport=9 program='{exe}' | Out-Null if ($dir) {{ - # BroLink names and scopes these itself rather than running the engine's + # Latch names and scopes these itself rather than running the engine's # own add-firewall-rule script, which opens every TCP and UDP port under # the name "Sunshine". The engine web UI port is deliberately absent: it # is reachable on loopback only. Step "Opening the streaming ports to the tailnet" - foreach ($old in 'BroLink Sunshine TCP', 'BroLink Sunshine UDP', '{tcp_rule}', '{udp_rule}') {{ + foreach ($old in 'Latch Sunshine TCP', 'Latch Sunshine UDP', '{tcp_rule}', '{udp_rule}') {{ netsh advfirewall firewall delete rule name="$old" | Out-Null }} $engineExe = Join-Path $dir 'sunshine.exe' @@ -515,13 +519,14 @@ Step "Never idle-sleep when plugged in, so Tailscale stays up from anywhere" powercfg /change standby-timeout-ac 0 powercfg /change hibernate-timeout-ac 0 {adapter}if ($engineError) {{ - Step "BroLink setup FAILED: $engineError" + Step "Latch setup FAILED: $engineError" exit 1 }} -Step "BroLink setup finished" +Step "Latch setup finished" exit 0 "#, dirs = dirs, + retire = crate::legacy::retire_windows_ps(crate::streamer::ENGINE_DIR), conceal = conceal_ps(), description = crate::brand::DESCRIPTION, host_exe = q(&p.exe.display().to_string()), @@ -569,7 +574,7 @@ pub fn run(p: &Plan<'_>) -> Result<()> { // user, so the unelevated panel installs it. let _ = crate::audio::install_helpers(); let exe = q(&p.exe.display().to_string()); - let log = log_path().unwrap_or_else(|| std::env::temp_dir().join("brolink-setup.log")); + let log = log_path().unwrap_or_else(|| std::env::temp_dir().join("latch-setup.log")); // UAC may run the helper as another administrator, whose profile // holds no host.toml: pass this user's folder so the engine login // and setup.log stay with the account that shares the machine. @@ -586,7 +591,7 @@ pub fn run(p: &Plan<'_>) -> Result<()> { .args(["-NoProfile", "-NonInteractive", "-Command", &launch]) .creation_flags(0x0800_0000) .status() - .context("launch elevated BroLink Host")?; + .context("launch elevated Latch Host")?; anyhow::ensure!( status.success(), "the administrator prompt was declined or setup failed (see {})", @@ -704,14 +709,14 @@ pub fn run_as_admin() -> Result<()> { .map(|root| PathBuf::from(root).join("Temp")) .filter(|dir| dir.is_dir()) .unwrap_or_else(std::env::temp_dir); - let tmp = tmp_dir.join(format!("brolink-setup-{}.ps1", std::process::id())); + let tmp = tmp_dir.join(format!("latch-setup-{}.ps1", std::process::id())); // PowerShell 5.1 reads a BOM-less file as the system ANSI code page, so // a Korean/Japanese username or adapter name ("이더넷") would be mangled // and the firewall rule would point at a path that does not exist. let mut bytes = b"\xEF\xBB\xBF".to_vec(); bytes.extend(script(&plan).as_bytes()); std::fs::write(&tmp, &bytes)?; - let log = log_path().unwrap_or_else(|| std::env::temp_dir().join("brolink-setup.log")); + let log = log_path().unwrap_or_else(|| std::env::temp_dir().join("latch-setup.log")); let out = std::fs::File::create(&log)?; let err = out.try_clone()?; let status = std::process::Command::new("powershell") @@ -736,34 +741,39 @@ pub fn run_as_admin() -> Result<()> { anyhow::bail!("setup runs on Windows only") } -/// Register or remove `brolink-host.exe --background` under the current +/// Register or remove `latch-host.exe --background` under the current /// user's Run key. pub fn set_start_with_windows(enable: bool, exe: &Path) -> Result<()> { #[cfg(windows)] { use std::os::windows::process::CommandExt; const KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; - let mut c = std::process::Command::new("reg"); - if enable { - c.args([ + let reg = |args: &[&str]| { + std::process::Command::new("reg") + .args(args) + .creation_flags(0x0800_0000) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + }; + // An earlier version's value goes either way: on, it is replaced; + // off, it would still start the host. + let _ = reg(&["delete", KEY, "/v", crate::legacy::WINDOWS_RUN_VALUE, "/f"]); + let status = if enable { + reg(&[ "add", KEY, "/v", - "BroLinkHost", + "LatchHost", "/t", "REG_SZ", "/d", &run_value(exe), "/f", - ]); + ])? } else { - c.args(["delete", KEY, "/v", "BroLinkHost", "/f"]); - } - let status = c - .creation_flags(0x0800_0000) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status()?; + reg(&["delete", KEY, "/v", "LatchHost", "/f"])? + }; anyhow::ensure!( status.success() || (!enable && !starts_with_windows()), "could not update the Run key" @@ -790,17 +800,21 @@ pub fn starts_with_windows() -> bool { #[cfg(windows)] { use std::os::windows::process::CommandExt; - std::process::Command::new("reg") - .args([ - "query", - r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", - "/v", - "BroLinkHost", - ]) - .creation_flags(0x0800_0000) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + ["LatchHost", crate::legacy::WINDOWS_RUN_VALUE] + .iter() + .any(|value| { + std::process::Command::new("reg") + .args([ + "query", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", + "/v", + value, + ]) + .creation_flags(0x0800_0000) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }) } #[cfg(not(windows))] crate::unix_setup::autostart_enabled() @@ -857,17 +871,17 @@ mod tests { #[test] fn run_value_quotes_the_path_and_asks_for_background() { - let p = PathBuf::from(r"C:\Users\Ada\AppData\Local\BroLink\brolink-host.exe"); + let p = PathBuf::from(r"C:\Users\Ada\AppData\Local\Latch\latch-host.exe"); assert_eq!( run_value(&p), - r#""C:\Users\Ada\AppData\Local\BroLink\brolink-host.exe" --background"# + r#""C:\Users\Ada\AppData\Local\Latch\latch-host.exe" --background"# ); } /// Position of `needle` after the preamble's function definitions, so /// an ordering test reads the steps as they run, not the helpers. fn find_in_body(s: &str, needle: &str) -> Option { - let at = s.find("BroLink setup started")?; + let at = s.find("Latch setup started")?; s[at..].find(needle).map(|i| i + at) } @@ -895,15 +909,15 @@ mod tests { #[test] fn script_escapes_quotes_and_skips_what_is_not_wanted() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&Plan { - sunshine_user: "brolink", + sunshine_user: "latch", sunshine_pass: "p'w", adapter: "Ethernet", adapter_description: "Realtek PCIe GbE", ..plan(&exe, false) }); - assert!(s.contains("--creds 'brolink' 'p''w'"), "{s}"); + assert!(s.contains("--creds 'latch' 'p''w'"), "{s}"); assert!(!s.contains("Downloading the streaming engine")); assert!(s.contains("$wakeAdapter = 'Ethernet'"), "{s}"); assert!(s.contains("Set-NetAdapterPowerManagement -Name $wakeAdapter")); @@ -912,7 +926,7 @@ mod tests { assert!(s.contains("HiberbootEnabled -Value 0")); assert!(s.contains("powercfg /change standby-timeout-ac 0")); assert!(s.contains("powercfg /change hibernate-timeout-ac 0")); - assert!(s.contains("protocol=UDP localport=9 program='C:\\x\\brolink-host.exe'")); + assert!(s.contains("protocol=UDP localport=9 program='C:\\x\\latch-host.exe'")); assert!(s.contains("$wakeDevice = 'Realtek PCIe GbE'")); assert!(s.contains("powercfg /deviceenablewake $wakeDevice")); // A name only ever appears as a single-quoted literal: in a @@ -929,12 +943,12 @@ mod tests { assert!(named.contains("$wakeDevice = 'NIC $env:TEMP'"), "{named}"); assert_eq!(named.matches("Stop-Computer").count(), 1, "{named}"); assert!( - s.contains("localport=47850 remoteip=100.64.0.0/10 program='C:\\x\\brolink-host.exe'") + s.contains("localport=47850 remoteip=100.64.0.0/10 program='C:\\x\\latch-host.exe'") ); - let dollar = PathBuf::from(r"C:\Users\joe$lab\BroLink\brolink-host.exe"); + let dollar = PathBuf::from(r"C:\Users\joe$lab\Latch\latch-host.exe"); let s = script(&plan(&dollar, false)); assert!( - s.contains("program='C:\\Users\\joe$lab\\BroLink\\brolink-host.exe'"), + s.contains("program='C:\\Users\\joe$lab\\Latch\\latch-host.exe'"), "firewall path must be a single-quoted PowerShell literal:\n{s}" ); assert!( @@ -950,7 +964,7 @@ mod tests { #[test] fn a_fresh_install_unpacks_the_pinned_lite_archive_and_leaves_no_upstream_traces() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, true)); assert!(s.contains(ENGINE_ZIP), "{s}"); @@ -979,7 +993,7 @@ mod tests { #[test] fn a_missing_or_wrong_archive_throws_before_anything_is_unpacked() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = code(&script(&plan(&exe, true))); let missing = s @@ -1014,7 +1028,7 @@ mod tests { #[test] fn a_failed_extract_throws_before_the_service_is_registered() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = code(&script(&plan(&exe, true))); assert!( @@ -1043,18 +1057,18 @@ mod tests { } #[test] - fn the_service_is_brolinks_and_the_fallback_is_proven_not_assumed() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + fn the_latch_service_and_the_fallback_are_proven_not_assumed() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, true)); - assert_eq!(SERVICE, "BroLinkStream"); - assert_eq!(SERVICE_DISPLAY, "BroLink Streaming"); + assert_eq!(SERVICE, "LatchStream"); + assert_eq!(SERVICE_DISPLAY, "Latch Streaming"); assert!(s.contains(&format!("$svc = '{SERVICE}'")), "{s}"); assert_eq!( s.matches(&format!("-DisplayName '{SERVICE_DISPLAY}'")) .count(), 2, - "both registrations show the BroLink name:\n{s}" + "both registrations show the Latch name:\n{s}" ); assert!(!code(&s).contains("Sunshine Service"), "{s}"); @@ -1084,7 +1098,7 @@ mod tests { #[test] fn the_virtual_display_learns_every_size_a_mac_can_ask_for() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, false)); let body = code(&s); let step = find_in_body(&body, "Listing the sizes a Mac can ask for").expect("step"); @@ -1107,8 +1121,8 @@ mod tests { } #[test] - fn firewall_rules_are_named_for_brolink_and_leave_the_web_ui_on_loopback() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + fn firewall_rules_are_named_for_latch_and_leave_the_web_ui_on_loopback() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, false)); assert!(s.contains(&format!(r#"name="{TCP_RULE}" dir=in action=allow protocol=TCP localport=47984,47989,48010 remoteip=100.64.0.0/10 program="$engineExe""#)), "{s}"); @@ -1124,14 +1138,14 @@ mod tests { "the old range included the web UI port:\n{s}" ); assert!( - s.contains("'BroLink Sunshine TCP', 'BroLink Sunshine UDP'"), + s.contains("'Latch Sunshine TCP', 'Latch Sunshine UDP'"), "the rules this replaces are cleaned up:\n{s}" ); } #[test] - fn setup_looks_for_brolinks_own_engine_before_any_other() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + fn setup_looks_for_the_latch_engine_before_any_other() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, false)); let line = s .lines() @@ -1148,7 +1162,7 @@ mod tests { #[test] fn a_failed_copy_is_checked_on_the_destination_before_the_service_is_registered() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = code(&script(&plan(&exe, true))); let dest = s .find(r#"throw "the engine did not copy to"#) @@ -1169,7 +1183,7 @@ mod tests { #[test] fn engine_failures_are_terminating_and_reported_as_nonzero_exit() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = code(&script(&plan(&exe, true))); let stop = s @@ -1200,15 +1214,15 @@ mod tests { #[test] fn staging_is_unique_per_run_and_cleaned_in_finally() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, true)); assert!(s.contains("[guid]::NewGuid()"), "{s}"); assert!( - !code(&s).contains("brolink-engine-unpack"), + !code(&s).contains("latch-engine-unpack"), "fixed staging name is gone:\n{s}" ); assert!( - !code(&s).contains("brolink-engine.zip"), + !code(&s).contains("latch-engine.zip"), "fixed download name is gone:\n{s}" ); assert!( @@ -1219,7 +1233,7 @@ mod tests { #[test] fn native_command_failures_are_checked() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, true)); assert!(s.contains("if ($LASTEXITCODE -ne 0) { throw \"could not reach GitHub")); assert!( @@ -1270,7 +1284,7 @@ system_tray = enabled #[test] fn the_engine_conf_is_written_before_the_service_starts() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, true)); for (k, v) in ENGINE_CONF { assert!( @@ -1295,7 +1309,7 @@ system_tray = enabled #[test] fn the_script_does_not_bind_the_engine_to_loopback() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = code(&script(&plan(&exe, true))); assert!( !s.contains("bind_address"), @@ -1314,7 +1328,7 @@ system_tray = enabled #[test] fn old_engine_is_disabled_before_the_new_one_starts() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, true)); let disable = s .find("StartupType Disabled") @@ -1334,13 +1348,13 @@ system_tray = enabled .expect("creds restart"); assert!( restart.contains(crate::migrate::SERVICE_MATCH), - "creds restart must see BroLinkStream:\n{restart}" + "creds restart must see LatchStream:\n{restart}" ); } #[test] fn setup_starts_the_engine_as_the_signed_in_user_after_the_service() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let s = script(&plan(&exe, true)); let restart = s .lines() @@ -1367,7 +1381,7 @@ system_tray = enabled #[test] fn migrate_skips_creds_and_fresh_install_still_sets_them() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let fresh = script(&plan(&exe, true)); assert!(fresh.contains("--creds 'u' 'p'"), "{fresh}"); assert!(fresh.contains("$migrate = $false"), "{fresh}"); @@ -1384,7 +1398,7 @@ system_tray = enabled #[test] fn migrate_uninstalls_only_after_prove_and_dry_run_never_calls_msiexec() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let mig = script(&migrate_plan(&exe, false)); let copy = mig.find("Copy-EngineState").expect("copy"); let verify = mig.find("Assert-EngineState").expect("verify"); @@ -1405,7 +1419,7 @@ system_tray = enabled assert!(mig.contains("/x"), "{mig}"); assert!( mig.contains("$needFiles = (-not $dir) -or ($dir -ne '"), - "Sunshine already installed must still unpack BroLink:\n{mig}" + "Sunshine already installed must still unpack Latch:\n{mig}" ); assert!( mig.contains("Stop-Service -Name $svc -Force"), @@ -1426,8 +1440,8 @@ system_tray = enabled } #[test] - fn branding_follows_the_copy_touches_only_brolinks_engine_and_skips_dry_runs() { - let exe = PathBuf::from(r"C:\x\brolink-host.exe"); + fn branding_follows_the_copy_touches_only_the_latch_engine_and_skips_dry_runs() { + let exe = PathBuf::from(r"C:\x\latch-host.exe"); let fresh = code(&script(&plan(&exe, true))); let copied = fresh .find("the engine did not copy to") @@ -1436,16 +1450,15 @@ system_tray = enabled let register = fresh.find("Registering the").expect("register"); assert!(copied < brand && brand < register, "{fresh}"); assert!( - fresh - .contains(r"if ($dir -eq 'C:\Program Files\BroLink\engine') { Brand-Engine $dir }"), + fresh.contains(r"if ($dir -eq 'C:\Program Files\Latch\engine') { Brand-Engine $dir }"), "{fresh}" ); assert!( - fresh.contains(".VersionInfo.FileDescription -eq 'BroLink Streaming'"), + fresh.contains(".VersionInfo.FileDescription -eq 'Latch Streaming'"), "{fresh}" ); assert!( - fresh.contains(r"-FilePath 'C:\x\brolink-host.exe'"), + fresh.contains(r"-FilePath 'C:\x\latch-host.exe'"), "{fresh}" ); assert!(fresh.contains("'--brand-engine'"), "{fresh}"); @@ -1472,22 +1485,22 @@ system_tray = enabled /// The seam CI's PowerShell syntax check runs through (see /// `.github/workflows/ci.yml`): the generated script is the real /// artefact, so it has to be obtainable without running setup. - /// `BROLINK_DUMP_SETUP_SCRIPT=/tmp/setup.ps1` writes one; the other - /// `BROLINK_DUMP_*` variables pick the plan. + /// `LATCH_DUMP_SETUP_SCRIPT=/tmp/setup.ps1` writes one; the other + /// `LATCH_DUMP_*` variables pick the plan. #[test] fn the_generated_script_can_be_dumped_for_a_syntax_check() { - let Ok(out) = std::env::var("BROLINK_DUMP_SETUP_SCRIPT") else { + let Ok(out) = std::env::var("LATCH_DUMP_SETUP_SCRIPT") else { return; }; let var = |k: &str, default: &str| std::env::var(k).unwrap_or_else(|_| default.into()); - let exe = PathBuf::from(var("BROLINK_DUMP_EXE", r"C:\x\brolink-host.exe")); - let user = var("BROLINK_DUMP_USER", "u"); - let pass = var("BROLINK_DUMP_PASS", "p"); - let adapter = var("BROLINK_DUMP_ADAPTER", ""); - let desc = var("BROLINK_DUMP_ADAPTER_DESC", ""); + let exe = PathBuf::from(var("LATCH_DUMP_EXE", r"C:\x\latch-host.exe")); + let user = var("LATCH_DUMP_USER", "u"); + let pass = var("LATCH_DUMP_PASS", "p"); + let adapter = var("LATCH_DUMP_ADAPTER", ""); + let desc = var("LATCH_DUMP_ADAPTER_DESC", ""); let p = Plan { - migrate: var("BROLINK_DUMP_MIGRATE", "0") == "1", - dry_run: var("BROLINK_DUMP_DRY_RUN", "0") == "1", + migrate: var("LATCH_DUMP_MIGRATE", "0") == "1", + dry_run: var("LATCH_DUMP_DRY_RUN", "0") == "1", sunshine_user: &user, sunshine_pass: &pass, adapter: &adapter, diff --git a/crates/host/src/streamer.rs b/crates/host/src/streamer.rs index 2978c22..690c506 100644 --- a/crates/host/src/streamer.rs +++ b/crates/host/src/streamer.rs @@ -1,23 +1,26 @@ //! Sunshine (or Apollo, its fork) on this PC: where it is, whether it is up, -//! and the two web-API calls BroLink needs. The API is HTTPS with a +//! and the two web-API calls Latch needs. The API is HTTPS with a //! self-signed certificate on localhost, so calls go through `curl.exe -k` //! rather than a TLS stack of our own. use anyhow::{bail, Context, Result}; -use brolink_core::{SUNSHINE_PORT, SUNSHINE_WEB_PORT}; +use latch_core::{SUNSHINE_PORT, SUNSHINE_WEB_PORT}; use std::net::{SocketAddr, TcpStream}; use std::path::PathBuf; use std::process::Command; use std::time::Duration; -/// Where BroLink unpacks its own copy of the streaming engine. Setup +/// Where Latch unpacks its own copy of the streaming engine. Setup /// installs here; an engine installed by someone else stays where it is. -pub const ENGINE_DIR: &str = r"C:\Program Files\BroLink\engine"; +pub const ENGINE_DIR: &str = r"C:\Program Files\Latch\engine"; /// Install directories, in the order they are preferred when several -/// exist. BroLink's own engine wins: it is the one setup configured. -pub const INSTALL_DIRS: [(&str, &str); 3] = [ - ("BroLink", ENGINE_DIR), +/// exist. Latch's own engine wins: it is the one setup configured. The +/// folder an earlier version used comes next: it is Latch's engine until +/// setup moves it. +pub const INSTALL_DIRS: [(&str, &str); 4] = [ + ("Latch", ENGINE_DIR), + ("Latch", crate::legacy::WINDOWS_ENGINE_DIR), ("Sunshine", r"C:\Program Files\Sunshine"), ("Apollo", r"C:\Program Files\Apollo"), ]; @@ -560,16 +563,20 @@ mod tests { } #[test] - fn discovery_prefers_the_brolink_engine_over_sunshine() { - assert_eq!(INSTALL_DIRS[0], ("BroLink", ENGINE_DIR)); - assert_eq!(INSTALL_DIRS[1].0, "Sunshine"); - assert_eq!(INSTALL_DIRS[2].0, "Apollo"); + fn discovery_prefers_the_latch_engine_over_sunshine() { + assert_eq!(INSTALL_DIRS[0], ("Latch", ENGINE_DIR)); + assert_eq!( + INSTALL_DIRS[1], + ("Latch", crate::legacy::WINDOWS_ENGINE_DIR) + ); + assert_eq!(INSTALL_DIRS[2].0, "Sunshine"); + assert_eq!(INSTALL_DIRS[3].0, "Apollo"); let existing = [ENGINE_DIR, r"C:\Program Files\Sunshine"]; let winner = INSTALL_DIRS .iter() .find(|(_, d)| existing.contains(d)) .expect("both exist"); - assert_eq!(*winner, ("BroLink", ENGINE_DIR)); + assert_eq!(*winner, ("Latch", ENGINE_DIR)); let _ = SUNSHINE_PORT; } diff --git a/crates/host/src/unix_setup.rs b/crates/host/src/unix_setup.rs index 2272647..e034815 100644 --- a/crates/host/src/unix_setup.rs +++ b/crates/host/src/unix_setup.rs @@ -1,12 +1,12 @@ //! Install and start the streaming engine on macOS and Linux. //! //! Windows still uses the elevated PowerShell script. Here there is no UAC -//! prompt: BroLink unpacks Sunshine next to its own data, writes the same +//! prompt: Latch unpacks Sunshine next to its own data, writes the same //! conf keys, and keeps the process up from the background service. use anyhow::{bail, Context, Result}; #[cfg(target_os = "macos")] -use brolink_core::update::sha256_hex; +use latch_core::update::sha256_hex; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -24,7 +24,7 @@ pub const MAC_DMG_SHA256: &str = "b630d35a184d8eaff39c5104f3c6a0c40e91ddc447ccf7 fn write_engine_login(path: &Path, user: &str, password: &str) -> Result<()> { use std::io::Write; let salt = crate::config::random_password(); - let hash = brolink_core::update::sha256_hex(format!("{password}{salt}").as_bytes()); + let hash = latch_core::update::sha256_hex(format!("{password}{salt}").as_bytes()); let hash: String = hash .as_bytes() .as_chunks::<2>() @@ -59,28 +59,79 @@ fn write_engine_login(path: &Path, user: &str, password: &str) -> Result<()> { } pub fn engine_dir() -> Result { - Ok(brolink_core::config::data_dir()?.join("engine")) + Ok(latch_core::config::data_dir()?.join("engine")) } pub fn conf_path() -> Result { Ok(engine_dir()?.join("config").join("sunshine.conf")) } -/// Candidate engine installs, BroLink's copy first. +/// The engine login file an earlier version wrote, and what it is called now. +const OLD_LOGIN_FILE: &str = "brolink-web.json"; +const LOGIN_FILE: &str = "latch-web.json"; + +/// An install that an older version set up has two things in the engine's +/// `sunshine.conf` that name it: the path of the engine login file, which is +/// called `brolink-web.json` and sits wherever setup put it, and any path +/// under the old data folder, which this copy has moved. Point both at what +/// is there now, and rename the login file, so the engine finds the login +/// it was set up with. Nothing happens on an install that was never moved. +pub fn repair_adopted_engine_config() { + let (Ok(conf), Ok(data), Some(old)) = ( + conf_path(), + latch_core::config::data_dir(), + latch_core::legacy::data_dir(), + ) else { + return; + }; + repair_conf( + &conf, + &old.display().to_string(), + &data.display().to_string(), + ); +} + +/// [`repair_adopted_engine_config`] for the conf at `conf`, with the old and +/// the new data folder as strings. +fn repair_conf(conf: &Path, old: &str, data: &str) { + let Ok(text) = fs::read_to_string(conf) else { + return; + }; + let fixed: Vec = text + .lines() + .map(|line| match line.split_once('=') { + Some((key, value)) if key.trim() == "credentials_file" => { + let path = PathBuf::from(value.trim().replace(old, data)); + if path.file_name().is_some_and(|n| n == OLD_LOGIN_FILE) { + let renamed = path.with_file_name(LOGIN_FILE); + if path.exists() && !renamed.exists() { + let _ = fs::rename(&path, &renamed); + } + return format!("credentials_file = {}", renamed.display()); + } + format!("credentials_file = {}", path.display()) + } + _ => line.replace(old, data), + }) + .collect(); + let fixed = fixed.join("\n") + "\n"; + if fixed != text { + let _ = fs::write(conf, fixed); + } +} + +/// Candidate engine installs, Latch's copy first. pub fn candidates() -> Vec { let mut v = Vec::new(); if let Ok(dir) = engine_dir() { - v.push(Install { - kind: "BroLink", - dir, - }); + v.push(Install { kind: "Latch", dir }); } #[cfg(target_os = "linux")] { - // BroLink's renamed engine binary, then the distro package we wrap. + // Latch's renamed engine binary, then the distro package we wrap. for p in ["/usr/local/bin", "/usr/bin"] { v.push(Install { - kind: "BroLink", + kind: "Latch", dir: PathBuf::from(p), }); } @@ -88,8 +139,17 @@ pub fn candidates() -> Vec { v } +/// The names the engine's program may have, Latch's first, an earlier +/// version's after it, and Sunshine's own last. +fn engine_exe_names() -> impl Iterator { + ["LatchStreaming", "latch-engine"] + .into_iter() + .chain(crate::legacy::ENGINE_EXE_NAMES) + .chain(std::iter::once("sunshine")) +} + pub fn exe_in(dir: &Path) -> PathBuf { - for name in ["BroLinkStreaming", "brolink-engine", "sunshine"] { + for name in engine_exe_names() { let app = dir.join("Contents/MacOS").join(name); if app.exists() { return app; @@ -103,12 +163,12 @@ pub fn exe_in(dir: &Path) -> PathBuf { return bin; } } - dir.join("brolink-engine") + dir.join("latch-engine") } pub fn run(p: &Plan<'_>) -> Result<()> { let log = - crate::setup::log_path().unwrap_or_else(|| std::env::temp_dir().join("brolink-setup.log")); + crate::setup::log_path().unwrap_or_else(|| std::env::temp_dir().join("latch-setup.log")); let mut lines = Vec::new(); let result = run_inner(p, &mut lines); let body = lines.join("\n") + "\n"; @@ -134,7 +194,7 @@ fn run_inner(p: &Plan<'_>, log: &mut Vec) -> Result<()> { stop_engine(); let conf = conf_path()?; let existing = fs::read_to_string(&conf).unwrap_or_default(); - let credentials = conf.with_file_name("brolink-web.json"); + let credentials = conf.with_file_name(LOGIN_FILE); let profile = conceal_conf(&existing) .lines() .filter(|line| { @@ -173,7 +233,7 @@ fn run_inner(p: &Plan<'_>, log: &mut Vec) -> Result<()> { if let Ok(exe) = std::env::current_exe() { set_autostart(true, &exe)?; - step(log, "BroLink starts at login so others can connect"); + step(log, "Latch starts at login so others can connect"); } Ok(()) } @@ -189,12 +249,12 @@ fn install_engine(dir: &Path, log: &mut Vec) -> Result<()> { } #[cfg(target_os = "linux")] { - if which("brolink-engine").is_some() || which("sunshine").is_some() { + if engine_exe_names().any(|n| which(n).is_some()) { log.push("using the system streaming engine".into()); return Ok(()); } bail!( - "the streaming engine is not installed. On this VPS use the BroLink node Docker image, or run setup again." + "the streaming engine is not installed. On this VPS use the Latch node Docker image, or run setup again." ); } #[cfg(not(any(target_os = "macos", target_os = "linux")))] @@ -208,9 +268,9 @@ fn install_engine(dir: &Path, log: &mut Vec) -> Result<()> { /// or Activity Monitor. The on-disk `.app` folder keeps its upstream name /// because the engine looks up resources next to that bundle. pub fn conceal_info_plist(xml: &str) -> String { - let mut xml = set_plist_string(xml, "CFBundleName", "BroLink"); - xml = set_plist_string(&xml, "CFBundleDisplayName", "BroLink"); - xml = set_plist_string(&xml, "CFBundleExecutable", "BroLinkStreaming"); + let mut xml = set_plist_string(xml, "CFBundleName", "Latch"); + xml = set_plist_string(&xml, "CFBundleDisplayName", "Latch"); + xml = set_plist_string(&xml, "CFBundleExecutable", "LatchStreaming"); if xml.contains("LSUIElement") { xml } else { @@ -239,9 +299,7 @@ fn set_plist_string(xml: &str, key: &str, value: &str) -> String { #[cfg(target_os = "macos")] fn install_macos(dir: &Path, log: &mut Vec) -> Result<()> { let dest = dir.join("Sunshine.app"); - if dest.join("Contents/MacOS/BroLinkStreaming").exists() - || dest.join("Contents/MacOS/sunshine").exists() - { + if engine_exe_names().any(|n| dest.join("Contents/MacOS").join(n).exists()) { conceal_engine_bundle(&dest)?; log.push("streaming engine already unpacked".into()); return Ok(()); @@ -253,7 +311,7 @@ fn install_macos(dir: &Path, log: &mut Vec) -> Result<()> { ); log.push("downloading the streaming engine".into()); let status = Command::new("curl") - .args(["-fsSL", "-A", "brolink", "-o"]) + .args(["-fsSL", "-A", "latch", "-o"]) .arg(&dmg) .arg(&url) .status() @@ -265,7 +323,7 @@ fn install_macos(dir: &Path, log: &mut Vec) -> Result<()> { got.eq_ignore_ascii_case(MAC_DMG_SHA256), "streaming engine digest mismatch: expected {MAC_DMG_SHA256}, got {got}" ); - let mount = std::env::temp_dir().join(format!("brolink-engine-{}", std::process::id())); + let mount = std::env::temp_dir().join(format!("latch-engine-{}", std::process::id())); let _ = fs::create_dir_all(&mount); let attach = Command::new("hdiutil") .args(["attach", "-nobrowse", "-readonly", "-mountpoint"]) @@ -294,17 +352,20 @@ fn install_macos(dir: &Path, log: &mut Vec) -> Result<()> { let _ = fs::remove_file(&dmg); copied?; conceal_engine_bundle(&dest)?; - log.push("streaming engine unpacked into BroLink's data folder".into()); + log.push("streaming engine unpacked into Latch's data folder".into()); Ok(()) } #[cfg(target_os = "macos")] fn conceal_engine_bundle(app: &Path) -> Result<()> { let macos = app.join("Contents/MacOS"); - let src = macos.join("sunshine"); - let dest = macos.join("BroLinkStreaming"); - if src.exists() && !dest.exists() { - fs::rename(&src, &dest).context("rename engine binary")?; + let dest = macos.join("LatchStreaming"); + // Upstream's name, or the name an earlier version gave it. + for from in ["sunshine", "BroLinkStreaming"] { + let src = macos.join(from); + if src.exists() && !dest.exists() { + fs::rename(&src, &dest).context("rename engine binary")?; + } } let plist = app.join("Contents/Info.plist"); if plist.exists() { @@ -327,7 +388,7 @@ fn which(name: &str) -> Option { } pub fn stop_engine() { - for name in ["brolink-engine", "BroLinkStreaming", "sunshine"] { + for name in engine_exe_names() { let _ = Command::new("pkill") .args(["-x", name]) .stdout(Stdio::null()) @@ -363,16 +424,19 @@ pub fn set_autostart(enable: bool, exe: &Path) -> Result<()> { pub fn register_autostart(exe: &Path) -> Result<()> { #[cfg(target_os = "macos")] { - write_launch_agent(exe).map(|_| ()) + write_launch_agent(exe)?; + retire_legacy_launch_agent(); + Ok(()) } #[cfg(target_os = "linux")] { write_user_unit(exe)?; // Links the unit into default.target; without `--now` nothing starts. anyhow::ensure!( - systemctl_user(&["enable", "brolink.service"])?, - "could not enable BroLink service" + systemctl_user(&["enable", "latch.service"])?, + "could not enable Latch service" ); + retire_legacy_user_unit(); Ok(()) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] @@ -386,10 +450,12 @@ pub fn autostart_enabled() -> bool { #[cfg(target_os = "macos")] { launch_agent_path().is_ok_and(|p| p.exists()) + || legacy_launch_agent_path().is_ok_and(|p| p.exists()) } #[cfg(target_os = "linux")] { user_unit_path().is_ok_and(|p| p.exists()) + || legacy_user_unit_path().is_ok_and(|p| p.exists()) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { @@ -398,7 +464,7 @@ pub fn autostart_enabled() -> bool { } #[cfg(target_os = "macos")] -const LAUNCH_AGENT_LABEL: &str = "dev.brolink.node"; +const LAUNCH_AGENT_LABEL: &str = "com.bardbro.latch.node"; #[cfg(target_os = "macos")] fn launch_agent_path() -> Result { @@ -406,6 +472,110 @@ fn launch_agent_path() -> Result { Ok(home.join(format!("Library/LaunchAgents/{LAUNCH_AGENT_LABEL}.plist"))) } +/// The launch agent file 4.0 and older wrote. +#[cfg(target_os = "macos")] +fn legacy_launch_agent_path() -> Result { + let home = dirs_home()?; + Ok(home.join(format!( + "Library/LaunchAgents/{}.plist", + crate::legacy::MAC_LAUNCH_AGENT + ))) +} + +/// Stop launchd starting the old agent at login, by removing its file. The +/// job launchd already has loaded is left alone: it may be the process that +/// is calling this. +#[cfg(target_os = "macos")] +fn retire_legacy_launch_agent() { + if let Ok(old) = legacy_launch_agent_path() { + if old.exists() { + let _ = fs::remove_file(&old); + } + } +} + +/// A copy running from the app folder 4.0 and older used, `BroLink.app`, +/// moves into `Latch.app` beside it and carries on from there. The release +/// publishes the new app under the old name as well, because a 4.0 updater +/// installs only a folder of that name, and the first run lands here. +/// +/// The login item goes with it: the new launch agent is written (only if +/// the old one was there, so a Mac that never started at login does not +/// begin to), the old file removed, the old job unloaded and the new one +/// loaded. Run from the window, that is done before the window starts; +/// run by launchd under the old label, it only re-registers, because +/// unloading the label would end this very process, and `exec`s the new +/// path under the old label for this session. Returns only when there was +/// nothing to adopt or something failed, in which case the copy runs on +/// where it is. +#[cfg(target_os = "macos")] +pub fn adopt_legacy_bundle(background: bool) -> Result<()> { + use std::os::unix::process::CommandExt; + let Some(old) = latch_client::update::bundle_path().filter(|b| { + b.file_name() + .is_some_and(|n| n == crate::legacy::MAC_BUNDLE) + }) else { + return Ok(()); + }; + let new = old.with_file_name("Latch.app"); + if new.exists() { + tracing::warn!( + "{} is here as well as {}; leaving both", + new.display(), + old.display() + ); + return Ok(()); + } + fs::rename(&old, &new).with_context(|| format!("rename {}", old.display()))?; + tracing::info!("moved {} to {}", old.display(), new.display()); + let exe = new.join("Contents/MacOS/Latch"); + let had_agent = legacy_launch_agent_path().is_ok_and(|p| p.exists()) + || launch_agent_path().is_ok_and(|p| p.exists()); + if had_agent { + write_launch_agent(&exe)?; + } + 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(); + // The old job, which launchd runs from the path that no longer exists. + let _ = Command::new("launchctl") + .args([ + "bootout", + &format!("gui/{uid}/{}", crate::legacy::MAC_LAUNCH_AGENT), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + // A service nobody supervised, started by an earlier window. + let _ = latch_core::http::request( + ("127.0.0.1", latch_core::CONTROL_PORT), + "POST", + "/v1/quit", + None, + Duration::from_secs(2), + ); + std::thread::sleep(Duration::from_millis(600)); + if had_agent { + if let Ok(plist) = launch_agent_path() { + let _ = Command::new("launchctl") + .args(["bootstrap", &format!("gui/{uid}")]) + .arg(&plist) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + } + } + let args: Vec<_> = std::env::args_os().skip(1).collect(); + let error = Command::new(&exe).args(args).exec(); + bail!("could not start {}: {error}", exe.display()) +} + /// The launch agent: start at login, and restart only after a failure. A /// copy that finds the port taken exits 0 (see `Service::run`), which with /// `SuccessfulExit = false` is where launchd leaves it; `KeepAlive = true` @@ -425,7 +595,7 @@ fn launch_agent_plist(exe: &Path) -> String { - Labeldev.brolink.node + Labelcom.bardbro.latch.node ProgramArguments {exe} @@ -479,9 +649,11 @@ fn macos_launch_agent(enable: bool, exe: &Path) -> Result<()> { if path.exists() { fs::remove_file(&path)?; } + retire_legacy_launch_agent(); return Ok(()); } write_launch_agent(exe)?; + retire_legacy_launch_agent(); if launch_agent_loaded() { return Ok(()); } @@ -493,15 +665,39 @@ fn macos_launch_agent(enable: bool, exe: &Path) -> Result<()> { .stdout(Stdio::null()) .stderr(Stdio::null()) .status() - .context("load BroLink launch agent")?; - anyhow::ensure!(loaded.success(), "could not load BroLink launch agent"); + .context("load Latch launch agent")?; + anyhow::ensure!(loaded.success(), "could not load Latch launch agent"); Ok(()) } #[cfg(target_os = "linux")] fn user_unit_path() -> Result { let home = dirs_home()?; - Ok(home.join(".config/systemd/user/brolink.service")) + Ok(home.join(".config/systemd/user/latch.service")) +} + +/// The user unit 4.0 and older wrote. +#[cfg(target_os = "linux")] +fn legacy_user_unit_path() -> Result { + let home = dirs_home()?; + Ok(home.join(format!( + ".config/systemd/user/{}", + crate::legacy::LINUX_USER_UNIT + ))) +} + +/// Stop systemd starting the old unit at login: disabled and its file +/// removed. It is not stopped, because it may be the one running this. +#[cfg(target_os = "linux")] +fn retire_legacy_user_unit() { + let Ok(old) = legacy_user_unit_path() else { + return; + }; + if old.exists() { + let _ = systemctl_user(&["disable", crate::legacy::LINUX_USER_UNIT]); + let _ = fs::remove_file(&old); + let _ = systemctl_user(&["daemon-reload"]); + } } #[cfg(target_os = "linux")] @@ -525,7 +721,7 @@ fn write_user_unit(exe: &Path) -> Result<()> { fs::create_dir_all(dir)?; } let unit = format!( - "[Unit]\nDescription=BroLink\nAfter=network.target\n\n[Service]\nExecStart=\"{}\" --background\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n", + "[Unit]\nDescription=Latch\nAfter=network.target\n\n[Service]\nExecStart=\"{}\" --background\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n", exe.display().to_string().replace('\\', "\\\\").replace('"', "\\\"") .replace('%', "%%").replace('$', "$$").replace('\n', "\\n").replace('\r', "\\r") ); @@ -544,17 +740,19 @@ fn write_user_unit(exe: &Path) -> Result<()> { fn linux_user_unit(enable: bool, exe: &Path) -> Result<()> { let path = user_unit_path()?; if !enable { - let _ = systemctl_user(&["disable", "brolink.service"]); + let _ = systemctl_user(&["disable", "latch.service"]); if path.exists() { fs::remove_file(&path)?; } + retire_legacy_user_unit(); let _ = systemctl_user(&["daemon-reload"]); return Ok(()); } write_user_unit(exe)?; + retire_legacy_user_unit(); anyhow::ensure!( - systemctl_user(&["enable", "--now", "brolink.service"])?, - "could not enable BroLink service" + systemctl_user(&["enable", "--now", "latch.service"])?, + "could not enable Latch service" ); Ok(()) } @@ -572,7 +770,7 @@ mod tests { #[test] fn launch_agent_restarts_only_after_a_failure_and_escapes_the_path() { let plist = launch_agent_plist(Path::new( - "/Applications/B&L's .app/Contents/MacOS/BroLink", + "/Applications/B&L's .app/Contents/MacOS/Latch", )); assert!( plist.contains("SuccessfulExit"), @@ -584,12 +782,47 @@ mod tests { assert!(plist.contains("RunAtLoad")); } + #[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())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let conf = dir.join("sunshine.conf"); + let old_login = dir.join("brolink-web.json"); + fs::write(&old_login, "{}").unwrap(); + fs::write( + &conf, + format!( + "origin_web_ui_allowed = pc\ncredentials_file = {}\nlog_path = /old/share/brolink/sunshine.log\n", + old_login.display() + ), + ) + .unwrap(); + repair_conf(&conf, "/old/share/brolink", "/new/share/latch"); + let fixed = fs::read_to_string(&conf).unwrap(); + assert!( + fixed.contains(&format!( + "credentials_file = {}", + dir.join("latch-web.json").display() + )), + "{fixed}" + ); + assert!( + fixed.contains("log_path = /new/share/latch/sunshine.log"), + "{fixed}" + ); + assert!(fixed.contains("origin_web_ui_allowed = pc"), "{fixed}"); + assert!(dir.join("latch-web.json").exists() && !old_login.exists()); + // A second pass changes nothing. + repair_conf(&conf, "/old/share/brolink", "/new/share/latch"); + assert_eq!(fs::read_to_string(&conf).unwrap(), fixed); + fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn engine_login_uses_sunshine_hash_format_without_plaintext() { - let dir = std::env::temp_dir().join(format!( - "brolink-login-{}", - crate::config::random_password() - )); + let dir = + std::env::temp_dir().join(format!("latch-login-{}", crate::config::random_password())); fs::create_dir(&dir).unwrap(); let path = dir.join("login.json"); write_engine_login(&path, "user", "secret").unwrap(); @@ -597,7 +830,7 @@ mod tests { let value: serde_json::Value = serde_json::from_str(&text).unwrap(); assert!(!text.contains("secret")); assert_eq!(value["username"], "user"); - let expected = brolink_core::update::sha256_hex( + let expected = latch_core::update::sha256_hex( format!("secret{}", value["salt"].as_str().unwrap()).as_bytes(), ); let stored = value["password"].as_str().unwrap(); @@ -639,17 +872,13 @@ mod tests { #[test] fn exe_in_prefers_app_bundle_then_bare_binary() { - let tmp = std::env::temp_dir().join(format!("brolink-exe-{}", std::process::id())); + let tmp = std::env::temp_dir().join(format!("latch-exe-{}", std::process::id())); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(tmp.join("Sunshine.app/Contents/MacOS")).unwrap(); fs::write(tmp.join("Sunshine.app/Contents/MacOS/sunshine"), b"").unwrap(); assert!(exe_in(&tmp).ends_with("Contents/MacOS/sunshine")); - fs::write( - tmp.join("Sunshine.app/Contents/MacOS/BroLinkStreaming"), - b"", - ) - .unwrap(); - assert!(exe_in(&tmp).ends_with("Contents/MacOS/BroLinkStreaming")); + fs::write(tmp.join("Sunshine.app/Contents/MacOS/LatchStreaming"), b"").unwrap(); + assert!(exe_in(&tmp).ends_with("Contents/MacOS/LatchStreaming")); let _ = fs::remove_dir_all(&tmp); } @@ -668,8 +897,8 @@ mod tests { "#; let got = conceal_info_plist(xml); - assert!(got.contains("BroLink"), "{got}"); - assert!(got.contains("BroLinkStreaming"), "{got}"); + assert!(got.contains("Latch"), "{got}"); + assert!(got.contains("LatchStreaming"), "{got}"); assert!(got.contains("LSUIElement"), "{got}"); assert!( got.contains("dev.lizardbyte.sunshine"), diff --git a/crates/host/src/update.rs b/crates/host/src/update.rs index d113cd7..8cffb80 100644 --- a/crates/host/src/update.rs +++ b/crates/host/src/update.rs @@ -1,4 +1,4 @@ -//! Taking a new `brolink-host.exe` from a Mac on the tailnet and swapping it +//! Taking a new `latch-host.exe` from a Mac on the tailnet and swapping it //! in. //! //! The Mac does the fetching (it has the GitHub login; the PC has none) and @@ -12,9 +12,10 @@ //! old one put back, still serving. use anyhow::{Context, Result}; -use brolink_core::api::{UPDATE_MAX_BYTES, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; -use brolink_core::http::Request; -use brolink_core::update; +use latch_core::api::{UPDATE_MAX_BYTES, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; +use latch_core::http::Request; +use latch_core::legacy; +use latch_core::update; use semver::Version; use std::io::ErrorKind; use std::path::{Path, PathBuf}; @@ -50,12 +51,12 @@ impl Rejected { } } -/// Where the upload waits: `brolink-host.exe.new`. +/// Where the upload waits: `latch-host.exe.new`. pub fn staged(exe: &Path) -> PathBuf { with_suffix(exe, ".new") } -/// Where the running executable goes during the swap: `brolink-host.exe.old`. +/// Where the running executable goes during the swap: `latch-host.exe.old`. pub fn retired(exe: &Path) -> PathBuf { with_suffix(exe, ".old") } @@ -75,17 +76,19 @@ pub fn refuse_self_update(engine_running: bool, session_active: bool) -> bool { pub fn stage(req: &Request, exe: &Path) -> Result { let version = req .header(UPDATE_VERSION_HEADER) + .or_else(|| req.header(legacy::UPDATE_VERSION_HEADER)) .ok_or_else(|| Rejected::Bad(format!("missing {UPDATE_VERSION_HEADER}")))?; let version = Version::parse(version.trim()) .map_err(|_| Rejected::Bad(format!("{version:?} is not a version")))?; let current = update::current(); if version <= current { return Err(Rejected::NotNewer(format!( - "this PC already runs BroLink Host {current}" + "this PC already runs Latch Host {current}" ))); } let want = req .header(UPDATE_SHA256_HEADER) + .or_else(|| req.header(legacy::UPDATE_SHA256_HEADER)) .ok_or_else(|| Rejected::Bad(format!("missing {UPDATE_SHA256_HEADER}")))?; if req.body.len() < MIN_SIZE || req.body.len() > UPDATE_MAX_BYTES @@ -258,7 +261,7 @@ mod tests { } Request { method: "POST".into(), - path: brolink_core::api::UPDATE_PATH.into(), + path: latch_core::api::UPDATE_PATH.into(), headers, body, } @@ -302,22 +305,22 @@ mod tests { #[test] fn names_sit_beside_the_executable() { - let exe = Path::new(r"C:\Users\Ada\AppData\Local\BroLink\brolink-host.exe"); + let exe = Path::new(r"C:\Users\Ada\AppData\Local\Latch\latch-host.exe"); assert_eq!( staged(exe), - Path::new(r"C:\Users\Ada\AppData\Local\BroLink\brolink-host.exe.new") + Path::new(r"C:\Users\Ada\AppData\Local\Latch\latch-host.exe.new") ); assert_eq!( retired(exe), - Path::new(r"C:\Users\Ada\AppData\Local\BroLink\brolink-host.exe.old") + Path::new(r"C:\Users\Ada\AppData\Local\Latch\latch-host.exe.old") ); } #[test] fn uploads_are_checked_before_anything_is_written() { - let dir = std::env::temp_dir().join(format!("brolink-upd-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("latch-upd-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let exe = dir.join("brolink-host.exe"); + let exe = dir.join("latch-host.exe"); let body = fake_exe(); let sha = update::sha256_hex(&body); let newer = format!("{}.0.0", update::current().major + 1); @@ -332,7 +335,7 @@ mod tests { let r = stage(&req(Some(¤t), Some(&sha), body.clone()), &exe).unwrap_err(); assert_eq!( r, - Rejected::NotNewer(format!("this PC already runs BroLink Host {current}")) + Rejected::NotNewer(format!("this PC already runs Latch Host {current}")) ); let r = stage(&req(Some(&newer), None, body.clone()), &exe).unwrap_err(); assert_eq!(r.status(), 400); @@ -374,9 +377,9 @@ mod tests { #[test] fn failed_launch_restores_the_previous_executable() { - let dir = std::env::temp_dir().join(format!("brolink-swap-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("latch-swap-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let exe = dir.join("brolink-host.exe"); + let exe = dir.join("latch-host.exe"); std::fs::write(&exe, b"old").unwrap(); assert!(apply(&exe).is_err()); assert_eq!(std::fs::read(&exe).unwrap(), b"old"); @@ -407,10 +410,10 @@ mod tests { assert_eq!(std::fs::read(retired(&exe)).unwrap(), b"old"); } std::fs::write(retired(&exe), b"retired").unwrap(); - std::fs::write(dir.join("brolink-host.exe.old-notes"), b"keep").unwrap(); + std::fs::write(dir.join("latch-host.exe.old-notes"), b"keep").unwrap(); tidy(&exe); assert!(!retired(&exe).exists()); - assert!(dir.join("brolink-host.exe.old-notes").exists()); + assert!(dir.join("latch-host.exe.old-notes").exists()); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/host/src/verinfo.rs b/crates/host/src/verinfo.rs index 7945a3e..ad8adad 100644 --- a/crates/host/src/verinfo.rs +++ b/crates/host/src/verinfo.rs @@ -1,7 +1,7 @@ //! VS_VERSIONINFO, the block Task Manager, the volume mixer and Explorer's //! Details tab read a program's name from. Built by hand because Windows //! offers no call that changes one string in an existing block: setup gives -//! the streaming engine's executables BroLink's description and icon, and +//! the streaming engine's executables Latch's description and icon, and //! everything else they carried - copyright, licence, version numbers - is //! read out first and written back unchanged. //! @@ -219,15 +219,12 @@ mod tests { fixed[8..12].copy_from_slice(&0x0000_07E2u32.to_le_bytes()); // some file version let given = vec![ ("CompanyName".to_string(), "LizardByte".to_string()), - ( - "FileDescription".to_string(), - "BroLink Streaming".to_string(), - ), + ("FileDescription".to_string(), "Latch Streaming".to_string()), ( "LegalCopyright".to_string(), "https://example/LICENSE".to_string(), ), - ("ProductName".to_string(), "BroLink Streaming".to_string()), + ("ProductName".to_string(), "Latch Streaming".to_string()), ("ProductVersion".to_string(), "2026.906.222525".to_string()), ]; let b = build(&fixed, &given, LANG, CODEPAGE); @@ -278,29 +275,26 @@ mod tests { let m = merged( &theirs, &[ - ("FileDescription", "BroLink Streaming"), - ("ProductName", "BroLink Streaming"), + ("FileDescription", "Latch Streaming"), + ("ProductName", "Latch Streaming"), ], ); assert_eq!( m, vec![ ("CompanyName".to_string(), "LizardByte".to_string()), - ( - "FileDescription".to_string(), - "BroLink Streaming".to_string() - ), + ("FileDescription".to_string(), "Latch Streaming".to_string()), ("LegalCopyright".to_string(), "their licence".to_string()), - ("ProductName".to_string(), "BroLink Streaming".to_string()), + ("ProductName".to_string(), "Latch Streaming".to_string()), ("Custom".to_string(), "kept".to_string()), ] ); - // A file with no block at all gets only what BroLink says. + // A file with no block at all gets only what Latch says. assert_eq!( - merged(&[], &[("FileDescription", "BroLink Streaming Service")]), + merged(&[], &[("FileDescription", "Latch Streaming Service")]), vec![( "FileDescription".to_string(), - "BroLink Streaming Service".to_string() + "Latch Streaming Service".to_string() )] ); } diff --git a/crates/host/src/virtual_display.rs b/crates/host/src/virtual_display.rs index cb528b4..6a10ea6 100644 --- a/crates/host/src/virtual_display.rs +++ b/crates/host/src/virtual_display.rs @@ -9,7 +9,7 @@ //! everything else the file says, and the service reports when any are //! missing so the setup card can say so. -use brolink_core::screens::stream_modes; +use latch_core::screens::stream_modes; /// The driver's name in Device Manager. pub const DEVICE: &str = "Virtual Display Driver"; diff --git a/crates/host/src/wake.rs b/crates/host/src/wake.rs index 3115a48..319473b 100644 --- a/crates/host/src/wake.rs +++ b/crates/host/src/wake.rs @@ -2,7 +2,7 @@ //! a listener that notices wake packets while the PC is awake. use anyhow::{Context, Result}; -use brolink_core::wake::{magic_target, MacAddr, WOL_PORTS}; +use latch_core::wake::{magic_target, MacAddr, WOL_PORTS}; use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; use std::time::Duration; @@ -76,7 +76,7 @@ fn parse_probe(line: &str) -> WakeInfo { let mut f = line.trim().split('|').map(str::trim); let mac = f .next() - .and_then(brolink_core::wake::MacAddr::parse) + .and_then(latch_core::wake::MacAddr::parse) .map(|m| m.to_string()); let adapter = f.next().unwrap_or("").to_string(); let description = f.next().unwrap_or("").to_string(); diff --git a/crates/host/windows/take-over-engine.cmd b/crates/host/windows/take-over-engine.cmd index fc644d2..0700072 100644 --- a/crates/host/windows/take-over-engine.cmd +++ b/crates/host/windows/take-over-engine.cmd @@ -1,4 +1,4 @@ @echo off rem A task registered by an older version still runs this file. Hand off to the rem windowless launcher at once so this console is gone within a blink. -start "" /b wscript.exe //B //Nologo "%LOCALAPPDATA%\BroLink\take-over-engine.vbs" +start "" /b wscript.exe //B //Nologo "%LOCALAPPDATA%\Latch\take-over-engine.vbs" diff --git a/crates/host/windows/take-over-engine.ps1 b/crates/host/windows/take-over-engine.ps1 index ab0d81a..84789ad 100644 --- a/crates/host/windows/take-over-engine.ps1 +++ b/crates/host/windows/take-over-engine.ps1 @@ -2,7 +2,11 @@ # device back as the Windows default. The engine's own startup clears the # default when Steam Streaming Speakers are the only active device. $ErrorActionPreference = "Continue" -$engineDir = "C:\Program Files\BroLink\engine" +# The engine's folder is Latch's, or the one an earlier version used until +# setup moves it; the service is named the same way. Either may be there. +$engineDir = @("C:\Program Files\Latch\engine", "C:\Program Files\BroLink\engine") | + Where-Object { Test-Path (Join-Path $_ "sunshine.exe") } | Select-Object -First 1 +if (-not $engineDir) { Write-Output "NO_ENGINE"; exit 0 } $exe = Join-Path $engineDir "sunshine.exe" $conf = Join-Path $engineDir "config\sunshine.conf" @@ -59,7 +63,7 @@ internal interface IPolicyConfig { [PreserveSig] int SetDefaultEndpoint(string pszDeviceName, int eRole); [PreserveSig] int SetEndpointVisibility(string pszDeviceName, bool bVisible); } -public static class BroLinkAudio { +public static class LatchAudio { public static int SetDefault(string id) { var cfg = (IPolicyConfig)new PolicyConfigClient(); int hr = 0; @@ -75,8 +79,10 @@ public static class BroLinkAudio { $me = "$env:USERDOMAIN\$env:USERNAME" $sun = Get-EngineOwner if (-not $sun -or $sun.User -ine $me) { - Set-Service BroLinkStream -StartupType Manual -ErrorAction SilentlyContinue - Stop-Service BroLinkStream -Force -ErrorAction SilentlyContinue + 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 } @@ -109,5 +115,5 @@ $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 = [BroLinkAudio]::SetDefault($pick.Id) +$hr = [LatchAudio]::SetDefault($pick.Id) Write-Output ("OK {0} hr=0x{1:X8}" -f $pick.Id, $hr) diff --git a/crates/host/windows/take-over-engine.vbs b/crates/host/windows/take-over-engine.vbs index 210f946..16177a0 100644 --- a/crates/host/windows/take-over-engine.vbs +++ b/crates/host/windows/take-over-engine.vbs @@ -2,5 +2,5 @@ ' runs in the user's desktop session, where a .cmd or a plain powershell.exe ' would flash a terminal; wscript is a windowed host, so nothing shows. Set shell = CreateObject("WScript.Shell") -script = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%") & "\BroLink\take-over-engine.ps1" +script = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%") & "\Latch\take-over-engine.ps1" shell.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -File """ & script & """", 0, True diff --git a/crates/stream/Cargo.toml b/crates/stream/Cargo.toml index e8404e9..2886cf9 100644 --- a/crates/stream/Cargo.toml +++ b/crates/stream/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "brolink-stream" +name = "latch-stream" version.workspace = true edition.workspace = true license.workspace = true @@ -9,7 +9,7 @@ build = "build.rs" links = "moonlight-common-c" [dependencies] -brolink-core.workspace = true +latch-core.workspace = true anyhow.workspace = true tracing.workspace = true rand.workspace = true diff --git a/crates/stream/build.rs b/crates/stream/build.rs index e6dbed3..d371e4b 100644 --- a/crates/stream/build.rs +++ b/crates/stream/build.rs @@ -45,10 +45,10 @@ fn main() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../third_party/moonlight-common-c"); println!("cargo:rerun-if-changed=csrc"); println!("cargo:rerun-if-changed={}", root.display()); - println!("cargo:rerun-if-env-changed=BROLINK_SKIP_C"); + println!("cargo:rerun-if-env-changed=LATCH_SKIP_C"); // `cargo check --target aarch64-apple-darwin` from a non-Mac has no C // toolchain for the target; the Rust side can still be type-checked. - if std::env::var_os("BROLINK_SKIP_C").is_some() { + if std::env::var_os("LATCH_SKIP_C").is_some() { return; } let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); diff --git a/crates/stream/src/audio.rs b/crates/stream/src/audio.rs index bcad24b..57bf620 100644 --- a/crates/stream/src/audio.rs +++ b/crates/stream/src/audio.rs @@ -67,7 +67,7 @@ impl Player { mapping: &[u8], ) -> Result { if channels == 0 || channels > MAX_CHANNELS || mapping.len() < channels { - bail!("audio with {channels} channels is not something BroLink plays"); + bail!("audio with {channels} channels is not something Latch plays"); } // Validate before any integer casts, allocation, or call into Opus. if !matches!(sample_rate, 8_000 | 12_000 | 16_000 | 24_000 | 48_000) { diff --git a/crates/stream/src/identity.rs b/crates/stream/src/identity.rs index 88a6a53..b01b61f 100644 --- a/crates/stream/src/identity.rs +++ b/crates/stream/src/identity.rs @@ -113,7 +113,7 @@ fn generate() -> Result<(String, String)> { params.distinguished_name = rcgen::DistinguishedName::new(); params .distinguished_name - .push(rcgen::DnType::CommonName, "BroLink"); + .push(rcgen::DnType::CommonName, "Latch"); params.not_before = rcgen::date_time_ymd(2026, 1, 1); params.not_after = rcgen::date_time_ymd(2046, 1, 1); let cert = params.self_signed(&pair)?; @@ -149,7 +149,7 @@ mod tests { #[test] fn identity_is_created_once_and_signs_verifiably() { - let dir = std::env::temp_dir().join(format!("brolink-id-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("latch-id-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let a = Identity::load_or_create(&dir).unwrap(); let key_bytes = std::fs::read(dir.join("client.key")).unwrap(); diff --git a/crates/stream/src/lib.rs b/crates/stream/src/lib.rs index 0b615d3..f18fb63 100644 --- a/crates/stream/src/lib.rs +++ b/crates/stream/src/lib.rs @@ -1,4 +1,4 @@ -//! BroLink's own Moonlight-protocol client. moonlight-common-c (compiled in +//! Latch's own Moonlight-protocol client. moonlight-common-c (compiled in //! from `third_party/`) speaks the wire protocol; everything around it is //! Rust: pairing and the HTTPS control API ([`nvhttp`]), the client identity //! ([`identity`]), the crypto primitives the C code calls ([`crypto`]), video diff --git a/crates/stream/src/nvhttp.rs b/crates/stream/src/nvhttp.rs index 36d5ee4..c2603b6 100644 --- a/crates/stream/src/nvhttp.rs +++ b/crates/stream/src/nvhttp.rs @@ -7,7 +7,7 @@ use crate::identity::{self, Identity}; use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit}; use aes::Aes128; use anyhow::{anyhow, bail, Context, Result}; -use brolink_core::http; +use latch_core::http; use sha2::{Digest, Sha256}; use std::net::{IpAddr, SocketAddr, TcpStream}; use std::sync::Arc; @@ -678,16 +678,16 @@ mod tests { } } -/// Against the Sunshine on this machine, with BroLink Host entering the PIN: -/// `cargo test -p brolink-stream pair_real -- --ignored --nocapture` +/// Against the Sunshine on this machine, with Latch Host entering the PIN: +/// `cargo test -p latch-stream pair_real -- --ignored --nocapture` #[cfg(test)] mod real { use super::*; #[test] - #[ignore = "needs Sunshine and BroLink Host on this machine"] + #[ignore = "needs Sunshine and Latch Host on this machine"] fn pair_real() { - let dir = std::env::temp_dir().join("brolink-pair-test"); + let dir = std::env::temp_dir().join("latch-pair-test"); let identity = Identity::load_or_create(&dir).unwrap(); let ip: IpAddr = "127.0.0.1".parse().unwrap(); let mut c = Client::new(&identity, ip, None).unwrap(); @@ -695,14 +695,14 @@ mod real { eprintln!("before: {info:?}"); let pin = "4321"; let der = c - .pair(pin, "brolink-test", || { + .pair(pin, "latch-test", || { for _ in 0..20 { - let r: Result = http::post_json( - ("127.0.0.1", brolink_core::CONTROL_PORT), + let r: Result = http::post_json( + ("127.0.0.1", latch_core::CONTROL_PORT), "/v1/pin", - &brolink_core::api::PinRequest { + &latch_core::api::PinRequest { pin: pin.into(), - name: "brolink-test".into(), + name: "latch-test".into(), }, Duration::from_secs(10), ); diff --git a/crates/stream/src/session.rs b/crates/stream/src/session.rs index ee1a796..1b4f55a 100644 --- a/crates/stream/src/session.rs +++ b/crates/stream/src/session.rs @@ -373,14 +373,14 @@ impl Drop for Session { /// /// Neither side encodes at the number it is given. moonlight-common-c /// (`SdpGenerator.c`) keeps 20% of the request for FEC and, on a remote -/// stream, another 500 kbps for audio and control (BroLink never flags a +/// stream, another 500 kbps for audio and control (Latch never flags a /// stream remote). Sunshine ignores that reduced figure anyway: it takes the /// raw request (`x-ml-video.configuredBitrateKbps`, /// `rtsp.cpp`) and makes its own deductions, 20% for FEC, then audio /// (512 kbps for high-quality stereo, at most a fifth of what is left) and /// 500 kbps for packet and control overhead (at most a tenth). A plain /// request of 20 Mbps therefore encodes at 15 Mbps, and one padded only for -/// moonlight's 20% still lands at 18988 kbps. BroLink treats the user's +/// moonlight's 20% still lands at 18988 kbps. Latch treats the user's /// number as the video target, not a total budget, so this inverts the /// whole chain: the smallest request that survives every deduction at or /// above the target. @@ -410,7 +410,7 @@ const SUNSHINE_CONTROL_KBPS: u64 = 500; /// What Sunshine's encoder targets for a moonlight request of `request` /// kbps: `rtsp.cpp`'s arithmetic on `configuredBitrateKbps`, FEC at -/// BroLink's 20% (`fec_percentage` in the engine profile), high-quality +/// Latch's 20% (`fec_percentage` in the engine profile), high-quality /// stereo audio. moonlight's own remote-stream deduction of 500 kbps only /// touches the figure Sunshine does not use, so it plays no part. fn sunshine_encoder_kbps(request: u64) -> u64 { @@ -448,7 +448,7 @@ fn run(inner: Arc, server: Server, s: Settings, ri_key: [u8; 16], ri_iv: fps: s.fps as c_int, bitrate_kbps: request_bitrate_kbps(s.bitrate_kbps) as c_int, // moonlight-common-c would cap a remote IPv4 stream at 1024-byte - // packets to survive raw-internet fragmentation. BroLink never rides + // packets to survive raw-internet fragmentation. Latch never rides // raw internet: every stream goes through a Tailscale (WireGuard) // tunnel whose path MTU is a guaranteed 1280 bytes, so 1184 fits with // room for RTP/UDP/IP headers, the value moonlight itself trusts on a @@ -914,7 +914,7 @@ mod tests { } /// Stream from the Sunshine on this machine for a few seconds: -/// `cargo test -p brolink-stream stream_real -- --ignored --nocapture` +/// `cargo test -p latch-stream stream_real -- --ignored --nocapture` /// (run `pair_real` first). #[cfg(test)] mod real { @@ -930,25 +930,25 @@ mod real { .map(|value| value.parse::().expect(name)) .unwrap_or(default) }; - let width = number("BROLINK_TEST_WIDTH", 1280); - let height = number("BROLINK_TEST_HEIGHT", 720); - let fps = number("BROLINK_TEST_FPS", 60); - let bitrate_kbps = number("BROLINK_TEST_BITRATE_KBPS", 10_000); - let seconds = number("BROLINK_TEST_SECONDS", 12); + let width = number("LATCH_TEST_WIDTH", 1280); + let height = number("LATCH_TEST_HEIGHT", 720); + let fps = number("LATCH_TEST_FPS", 60); + let bitrate_kbps = number("LATCH_TEST_BITRATE_KBPS", 10_000); + let seconds = number("LATCH_TEST_SECONDS", 12); assert!(width > 0 && height > 0 && fps > 0 && bitrate_kbps > 0 && seconds >= 12); eprintln!("requested: {width}x{height} {fps} fps {bitrate_kbps} kbps duration={seconds}s"); - let dir = std::env::var_os("BROLINK_TEST_DIR") + let dir = std::env::var_os("LATCH_TEST_DIR") .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::env::temp_dir().join("brolink-pair-test")); + .unwrap_or_else(|| std::env::temp_dir().join("latch-pair-test")); let identity = crate::Identity::load_or_create(&dir).unwrap(); - let ip: std::net::IpAddr = std::env::var("BROLINK_TEST_IP") + let ip: std::net::IpAddr = std::env::var("LATCH_TEST_IP") .unwrap_or_else(|_| "127.0.0.1".into()) .parse() .unwrap(); let cert = std::fs::read(dir.join("server.der")).ok(); let mut client = Client::new(&identity, ip, cert).unwrap(); let mut info = client.server_info().unwrap(); - if std::env::var_os("BROLINK_TEST_IP").is_some() { + if std::env::var_os("LATCH_TEST_IP").is_some() { assert!( info.paired, "a remote PC must be reached without pairing again" @@ -956,13 +956,13 @@ mod real { } if !info.paired { let der = client - .pair("4321", "brolink-test", || { - let _ = brolink_core::http::post_json::<_, brolink_core::api::Ack>( - ("127.0.0.1", brolink_core::CONTROL_PORT), + .pair("4321", "latch-test", || { + let _ = latch_core::http::post_json::<_, latch_core::api::Ack>( + ("127.0.0.1", latch_core::CONTROL_PORT), "/v1/pin", - &brolink_core::api::PinRequest { + &latch_core::api::PinRequest { pin: "4321".into(), - name: "brolink-test".into(), + name: "latch-test".into(), }, Duration::from_secs(10), ); @@ -1007,7 +1007,7 @@ mod real { height, fps, bitrate_kbps, - hevc: std::env::var_os("BROLINK_TEST_HEVC").is_some(), + hevc: std::env::var_os("LATCH_TEST_HEVC").is_some(), }, ri_key, ri_iv, @@ -1028,7 +1028,7 @@ mod real { panic!("session ended early: {ev:?}"); } } - if connected && std::env::var_os("BROLINK_TEST_MOUSE_SWEEP").is_some() { + if connected && std::env::var_os("LATCH_TEST_MOUSE_SWEEP").is_some() { // Oscillate so the cursor stays on screen while a game reading // raw input sees continuous relative motion. Watch the PC's // cursor (GetCursorPos) to confirm the relative injection lands. diff --git a/crates/stream/src/video/openh264.rs b/crates/stream/src/video/openh264.rs index c5941a5..b077bcf 100644 --- a/crates/stream/src/video/openh264.rs +++ b/crates/stream/src/video/openh264.rs @@ -1,5 +1,5 @@ //! Software H.264 through Cisco's OpenH264. Used on Windows and Linux, where -//! BroLink is a development and testing client rather than the product. +//! Latch is a development and testing client rather than the product. use super::{interleave_uv, Decoder, Frame}; use anyhow::{bail, Result}; diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 13a213e..b17f9d0 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "brolink-ui" +name = "latch-ui" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "Shared egui theme and widgets for the BroLink host and client" +description = "Shared egui theme and widgets for the Latch host and client" [dependencies] -brolink-core.workspace = true +latch-core.workspace = true egui.workspace = true diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index e18f4ad..7f6b8be 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,4 +1,4 @@ -//! BroLink's design system: the tokens and components every window and +//! Latch's design system: the tokens and components every window and //! overlay is built from. //! //! The apps are small egui programs aimed at people who are not developers. diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 6dcba7b..933beed 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -6,7 +6,7 @@ //! hairlines, so the app is too: neutral surfaces a step apart, one text //! colour in three strengths, white for the primary action and for "on", //! and colour only where it carries meaning — green, amber and red for -//! status, and the logo's violet for keyboard focus, selection and +//! status, and the logo's blue for keyboard focus, selection and //! progress. Screens take every colour, size and distance from here; a //! retune is a change to this file. @@ -58,8 +58,9 @@ pub struct Palette { pub primary_pressed: Color32, /// Text and marks drawn on `primary`. pub on_primary: Color32, - /// The logo's violet, lightened to read on the dark surfaces. Keyboard - /// focus, text selection, progress. Never a fill behind text. + /// The logo's blue, lightened to read on the dark surfaces. Keyboard + /// focus, text selection, progress, and the logo's own square. Never a + /// fill behind text. pub accent: Color32, /// Healthy, online, ready. pub success: Color32, @@ -91,7 +92,7 @@ pub const PALETTE: Palette = Palette { primary_hover: Color32::from_rgb(0xd4, 0xd4, 0xd4), primary_pressed: Color32::from_rgb(0xbd, 0xbd, 0xbd), on_primary: Color32::from_rgb(0x0a, 0x0a, 0x0a), - accent: Color32::from_rgb(0xa7, 0x8b, 0xfa), + accent: Color32::from_rgb(0x4f, 0x8f, 0xe8), success: Color32::from_rgb(0x4c, 0xc3, 0x8a), warning: Color32::from_rgb(0xf0, 0xb2, 0x49), danger: Color32::from_rgb(0xff, 0x63, 0x69), diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index 33abd74..fcdb000 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -1,4 +1,4 @@ -//! The components every BroLink screen is assembled from. +//! The components every Latch screen is assembled from. //! //! Everything here takes its colours, sizes and distances from //! [`theme`](crate::theme); nothing carries its own, so the machine list, @@ -8,10 +8,9 @@ use crate::theme::{self, radius, size, space, stroke, text, PALETTE as P}; use egui::{ - collapsing_header::CollapsingState, Align, Align2, Color32, ColorImage, CornerRadius, Frame, - Id, InnerResponse, Label, Layout, Margin, Painter, Pos2, Rect, Response, RichText, Sense, - Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, UiBuilder, Vec2, WidgetInfo, - WidgetText, WidgetType, + collapsing_header::CollapsingState, Align, Align2, Color32, CornerRadius, Frame, Id, + InnerResponse, Label, Layout, Margin, Painter, Pos2, Rect, Response, RichText, Sense, Shape, + Stroke, StrokeKind, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, }; /// What a mark, a notice or a status line means. @@ -300,7 +299,7 @@ pub fn disclosure( default_open: bool, body: impl FnOnce(&mut Ui) -> R, ) -> Option { - let id = Id::new("brolink.disclosure").with(id); + let id = Id::new("latch.disclosure").with(id); let mut state = CollapsingState::load_with_default_open(ui.ctx(), id, default_open); let galley = ui.painter().layout_no_wrap( title.to_owned(), @@ -665,7 +664,7 @@ pub fn action_row(ui: &mut Ui, gap: f32, add: impl FnOnce(&mut Ui)) { } fn flush_key(row: Id) -> Id { - Id::new("brolink.action_row").with(row) + Id::new("latch.action_row").with(row) } /// Whether the next widget starts a line of an [`action_row`]. @@ -1137,7 +1136,7 @@ pub fn open_menu_rect(ctx: &egui::Context) -> Option { } fn open_menu_key() -> Id { - Id::new("brolink.open_menu") + Id::new("latch.open_menu") } /// Open a popup menu under `trigger` while it is toggled on. A real egui @@ -1176,7 +1175,7 @@ fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R ui.set_min_width(200.0); ui.set_max_width(320.0); let max = (drop.max_height - chrome).max(size::CONTROL_SM); - clipped_scroll(ui, "brolink.menu", max, |ui| { + clipped_scroll(ui, "latch.menu", max, |ui| { ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().interact_size.y = size::CONTROL_SM; ui.with_layout(Layout::top_down_justified(Align::Min), add) @@ -1556,7 +1555,7 @@ pub fn select( ); }) .show_ui(ui, |ui| { - let out = clipped_scroll(ui, "brolink.select", height, |ui| { + 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); add(ui) @@ -1712,7 +1711,7 @@ pub(crate) fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { /// anything is shown. pub fn trailing(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { let avail = ui.available_rect_before_wrap(); - let key = ui.auto_id_with("brolink.trailing"); + let key = ui.auto_id_with("latch.trailing"); let known: f32 = ui.data(|d| d.get_temp(key)).unwrap_or(0.0); let w = known.min(avail.width()).max(0.0); let rect = Rect::from_min_max(Pos2::new(avail.max.x - w, avail.min.y), avail.max); @@ -2068,29 +2067,19 @@ pub fn shortcut(ui: &mut Ui, keys: &[&str], does: &str) -> Response { // Brand // --------------------------------------------------------------------------- -/// The app icon as a texture, for the window's top bar. -pub struct Brand { - tex: TextureHandle, -} +/// The logo, for the window's top bar. +pub struct Brand; impl Brand { - pub fn new(ctx: &egui::Context) -> Self { - let px = 64; - let img = - ColorImage::from_rgba_unmultiplied([px, px], &brolink_core::icon::render(px as u32)); - Self { - tex: ctx.load_texture("brolink-brand", img, TextureOptions::LINEAR), - } + pub fn new(_ctx: &egui::Context) -> Self { + Self } /// The mark and the product name, as on the product site. pub fn lockup(&self, ui: &mut Ui, name: &str) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = space::SM; - ui.add( - egui::Image::new((self.tex.id(), Vec2::splat(22.0))) - .corner_radius(CornerRadius::same(5)), - ); + mark(ui, 20.0); ui.label( RichText::new(name) .font(theme::semibold(text::TITLE + 1.0)) @@ -2101,6 +2090,33 @@ impl Brand { } } +/// The logo, `size` points square: a square with its lower right corner +/// cut away, and a smaller blue square in the cut. Drawn from rectangles +/// on the logo's own grid (its 72 units run 14 to 86 in `docs/brand`), so +/// it is sharp at any size and on any screen. +pub fn mark(ui: &mut Ui, size: f32) -> Response { + let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + if ui.is_rect_visible(rect) { + paint_mark(ui, rect, P.text, P.accent); + } + response +} + +fn paint_mark(ui: &Ui, rect: egui::Rect, ink: Color32, accent: Color32) { + use egui::emath::GuiRounding; + let ppp = ui.ctx().pixels_per_point(); + let unit = rect.width() / 72.0; + let at = |x: f32, y: f32| rect.min + Vec2::new((x - 14.0) * unit, (y - 14.0) * unit); + let part = |x0: f32, y0: f32, x1: f32, y1: f32, colour: Color32| { + // Edges on whole pixels, so the corners stay crisp at 1x. + let r = egui::Rect::from_min_max(at(x0, y0), at(x1, y1)).round_to_pixels(ppp); + ui.painter().rect_filled(r, CornerRadius::ZERO, colour); + }; + part(14.0, 14.0, 86.0, 54.0, ink); + part(14.0, 54.0, 54.0, 86.0, ink); + part(60.0, 60.0, 86.0, 86.0, accent); +} + /// 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. diff --git a/deploy/native/README.md b/deploy/native/README.md index 9830875..4ba9976 100644 --- a/deploy/native/README.md +++ b/deploy/native/README.md @@ -3,14 +3,14 @@ The deployed `relay` desktop runs directly on Ubuntu as user `ubuntu`, not in Docker. The four remaining containers provide the Tailscale peer relay and the three website services. Tailscale identity and streaming pairing were preserved -when retiring `brolink-node` on 2026-09-21 UTC. +when retiring `latch-node` on 2026-09-21 UTC. ## Runtime - Xvfb provides a headless 1920x1080 display on `:1`; XFCE runs on the VPS host. - Sunshine v2026.906.222525 (official Ubuntu 24.04 ARM64 package) captures X11 and encodes H.264 in software. The VPS has no GPU encoder. -- `/usr/local/bin/brolink-host` runs the BroLink control service. +- `/usr/local/bin/latch-host` runs the Latch control service. - `/usr/local/bin/tailscale` is the CLI only. It uses the existing relay container's socket at `/var/run/tailscale/tailscaled.sock`; no second daemon or Tailscale identity is installed on the host. @@ -20,41 +20,56 @@ when retiring `brolink-node` on 2026-09-21 UTC. ## Files and services The `.service` files in this directory are installed in -`~/.config/systemd/user/`. BroLink maintains its own `brolink.service` base unit; -`brolink.service.d/desktop.conf` adds the display environment and engine startup -ordering without conflicting with BroLink's automatic registration. -`wait-engine.py` is installed as `/usr/local/libexec/brolink-wait-engine.py`. +`~/.config/systemd/user/`. Latch maintains its own `latch.service` base unit; +`latch.service.d/desktop.conf` adds the display environment and engine startup +ordering without conflicting with Latch's automatic registration. +`wait-engine.py` is installed as `/usr/local/libexec/latch-wait-engine.py`. -`brolink.service` pulls in `brolink-engine.service`, -`brolink-desktop.service`, and `brolink-display.service`. PulseAudio runs as +`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`: ```sh -systemctl --user status brolink brolink-engine brolink-desktop brolink-display -journalctl --user -u brolink-engine -n 50 +systemctl --user status latch latch-engine latch-desktop latch-display +journalctl --user -u latch-engine -n 50 ``` Streaming configuration, certificates, and paired clients live in -`~/.config/sunshine/`. BroLink's host configuration lives in -`~/.local/share/brolink/host.toml`; its `engine/config` path is a symlink to the +`~/.config/sunshine/`. Latch's host configuration lives in +`~/.local/share/latch/host.toml`; its `engine/config` path is a symlink to the Sunshine directory. Keep these private and preserve them during upgrades. The X server disables TCP and uses an authentication cookie in -`/run/user/1001/brolink.Xauthority`. The `ubuntu` account is deliberately **not** +`/run/user/1001/latch.Xauthority`. The `ubuntu` account is deliberately **not** 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. +## Upgrading from BroLink (before 4.1.0) + +Latch was called BroLink until 4.1.0, and a node that was set up then has +`brolink*.service` units, `/usr/local/bin/brolink-host`, a `brolink.Xauthority` +cookie and `~/.local/share/brolink`. `migrate-from-brolink.sh` in this +directory swaps the units, the cookie name and the wait script for the new +ones and starts Latch; `latch-host` moves the data folder and the engine's +login file itself the first time it runs, keeping the pairing and the +Tailscale identity. + +```sh +sudo install -m 0755 latch-host /usr/local/bin/latch-host +bash migrate-from-brolink.sh +``` + ## Migration and recovery The old container's complete `.config` and `.local` directories are saved in -`/home/ubuntu/brolink-native-migration/container-backup/` with private permissions. +`/home/ubuntu/latch-native-migration/container-backup/` with private permissions. They contain pairing credentials; do not commit or publish them. The original Docker recipe remains available in `deploy/node/` for other uses, but it must not be started on this VPS alongside the native services because both would claim the same streaming and control ports. -Verified through the installed Mac BroLink app: existing pairing, 1080p/60 H.264 +Verified through the installed Mac Latch app: existing pairing, 1080p/60 H.264 video, mouse clicks, terminal keyboard input, host identity (`relay`, `ubuntu`, `kvm`, `/home/ubuntu`), and clean disconnect. User-manager restart is checked to verify automatic startup without rebooting the websites or packet relay. diff --git a/deploy/native/brolink-display.service b/deploy/native/brolink-display.service deleted file mode 100644 index 3369d10..0000000 --- a/deploy/native/brolink-display.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=BroLink native headless display - -[Service] -ExecStartPre=/bin/sh -c 'umask 077; touch "%t/brolink.Xauthority"; /usr/bin/xauth -f "%t/brolink.Xauthority" add :1 . "$(/usr/bin/mcookie)"' -ExecStart=/usr/bin/Xvfb :1 -screen 0 1920x1080x24 -nolisten tcp -auth %t/brolink.Xauthority -Restart=on-failure -RestartSec=3 - -[Install] -WantedBy=default.target diff --git a/deploy/native/brolink.service.d/desktop.conf b/deploy/native/brolink.service.d/desktop.conf deleted file mode 100644 index ae3843a..0000000 --- a/deploy/native/brolink.service.d/desktop.conf +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Requires=brolink-engine.service -After=brolink-engine.service - -[Service] -Environment=DISPLAY=:1 -Environment=XAUTHORITY=%t/brolink.Xauthority -Environment=PATH=/usr/local/bin:/usr/bin:/bin -ExecStartPre=/usr/bin/python3 /usr/local/libexec/brolink-wait-engine.py -Restart=on-failure -RestartSec=3 - diff --git a/deploy/native/brolink-desktop.service b/deploy/native/latch-desktop.service similarity index 64% rename from deploy/native/brolink-desktop.service rename to deploy/native/latch-desktop.service index 13a7c4d..7062fbf 100644 --- a/deploy/native/brolink-desktop.service +++ b/deploy/native/latch-desktop.service @@ -1,12 +1,12 @@ [Unit] -Description=BroLink native XFCE desktop -Requires=brolink-display.service -After=brolink-display.service +Description=Latch native XFCE desktop +Requires=latch-display.service +After=latch-display.service Wants=pulseaudio.service [Service] Environment=DISPLAY=:1 -Environment=XAUTHORITY=%t/brolink.Xauthority +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 diff --git a/deploy/native/latch-display.service b/deploy/native/latch-display.service new file mode 100644 index 0000000..dda40e9 --- /dev/null +++ b/deploy/native/latch-display.service @@ -0,0 +1,11 @@ +[Unit] +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 +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/deploy/native/brolink-engine.service b/deploy/native/latch-engine.service similarity index 66% rename from deploy/native/brolink-engine.service rename to deploy/native/latch-engine.service index 5e3f02f..ef115cc 100644 --- a/deploy/native/brolink-engine.service +++ b/deploy/native/latch-engine.service @@ -1,12 +1,12 @@ [Unit] -Description=BroLink native streaming engine -Requires=brolink-desktop.service -After=brolink-desktop.service pulseaudio.service +Description=Latch native streaming engine +Requires=latch-desktop.service +After=latch-desktop.service pulseaudio.service Wants=pulseaudio.service [Service] Environment=DISPLAY=:1 -Environment=XAUTHORITY=%t/brolink.Xauthority +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 diff --git a/deploy/native/latch.service.d/desktop.conf b/deploy/native/latch.service.d/desktop.conf new file mode 100644 index 0000000..5192956 --- /dev/null +++ b/deploy/native/latch.service.d/desktop.conf @@ -0,0 +1,12 @@ +[Unit] +Requires=latch-engine.service +After=latch-engine.service + +[Service] +Environment=DISPLAY=:1 +Environment=XAUTHORITY=%t/latch.Xauthority +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStartPre=/usr/bin/python3 /usr/local/libexec/latch-wait-engine.py +Restart=on-failure +RestartSec=3 + diff --git a/deploy/native/migrate-from-brolink.sh b/deploy/native/migrate-from-brolink.sh new file mode 100755 index 0000000..a52a49f --- /dev/null +++ b/deploy/native/migrate-from-brolink.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Move a native Linux node from the names Latch had before 4.1.0 (when it was +# called BroLink) to the new ones. Run it as the user that owns the desktop, +# from this directory, after putting the new latch-host at /usr/local/bin: +# +# sudo install -m 0755 latch-host /usr/local/bin/latch-host +# bash migrate-from-brolink.sh +# +# It stops the old stack, swaps the user units, the display cookie name and +# the wait script, and starts the new one. The data folder (settings, +# pairing identity) and the engine's login are moved by latch-host itself the +# first time it runs, so nothing is copied here. Safe to run twice. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN="${LATCH_BIN:-/usr/local/bin/latch-host}" +UNITS="$HOME/.config/systemd/user" +OLD_UNITS=(brolink brolink-engine brolink-desktop brolink-display) + +[[ -x "$BIN" ]] || { echo "install the new latch-host at $BIN first" >&2; exit 1; } +mkdir -p "$UNITS" + +echo "==> stopping the old stack" +for u in "${OLD_UNITS[@]}"; do + systemctl --user stop "$u.service" 2>/dev/null || true + systemctl --user disable "$u.service" 2>/dev/null || true +done +for u in "${OLD_UNITS[@]}"; do rm -f "$UNITS/$u.service"; done +rm -rf "$UNITS/brolink.service.d" + +echo "==> installing the new units" +for u in latch-display latch-desktop latch-engine; do + install -m 0644 "$HERE/$u.service" "$UNITS/$u.service" +done +mkdir -p "$UNITS/latch.service.d" +install -m 0644 "$HERE/latch.service.d/desktop.conf" "$UNITS/latch.service.d/desktop.conf" +# The base unit is the one latch-host writes itself; the same text, so it +# finds nothing to change when it starts. +printf '[Unit]\nDescription=Latch\nAfter=network.target\n\n[Service]\nExecStart="%s" --background\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n' "$BIN" >"$UNITS/latch.service" + +echo "==> display cookie and wait script" +env_file="$HOME/.config/environment.d/10-display.conf" +if [[ -f "$env_file" ]]; then sed -i 's/brolink\.Xauthority/latch.Xauthority/g' "$env_file"; fi +# The wait script is root's to install; LATCH_SKIP_ROOT=1 leaves it to someone +# who already has (an account without sudo, run by an administrator). +if [[ -z "${LATCH_SKIP_ROOT:-}" ]]; then + sudo install -D -m 0755 "$HERE/wait-engine.py" /usr/local/libexec/latch-wait-engine.py + sudo rm -f /usr/local/libexec/brolink-wait-engine.py +fi + +# Sunshine's own config, which latch-host does not manage here, names the engine +# login file after the old product. +conf="$HOME/.config/sunshine/sunshine.conf" +if [[ -f "$conf" ]] && grep -q 'brolink-web\.json' "$conf"; then + echo "==> engine login file" + if [[ -f "$HOME/.config/sunshine/brolink-web.json" && ! -e "$HOME/.config/sunshine/latch-web.json" ]]; then + mv "$HOME/.config/sunshine/brolink-web.json" "$HOME/.config/sunshine/latch-web.json" + fi + sed -i 's/brolink-web\.json/latch-web.json/g' "$conf" +fi + +echo "==> starting Latch" +systemctl --user daemon-reload +systemctl --user enable latch-display.service latch-desktop.service latch-engine.service +systemctl --user enable --now latch.service + +for _ in $(seq 1 30); do + if status="$(curl -fsS --max-time 2 http://127.0.0.1:47850/v1/status 2>/dev/null)"; then + echo "$status" | python3 -c 'import json,sys; s=json.load(sys.stdin); print("app=%s version=%s name=%s streamer=%s running=%s" % (s["app"], s["version"], s["name"], s["streamer"]["kind"], s["streamer"]["running"]))' + echo "done. The old binary /usr/local/bin/brolink-host can be removed once this looks right." + exit 0 + fi + sleep 2 +done +echo "latch.service did not answer on 47850; see: journalctl --user -u latch -n 50" >&2 +exit 1 diff --git a/deploy/native/wait-engine.py b/deploy/native/wait-engine.py index 3906bf2..2c36cff 100644 --- a/deploy/native/wait-engine.py +++ b/deploy/native/wait-engine.py @@ -1,5 +1,5 @@ #!/usr/bin/python3 -"""Wait until the systemd-managed engine is listening before starting BroLink.""" +"""Wait until the systemd-managed engine is listening before starting Latch.""" import socket import time @@ -10,4 +10,4 @@ except OSError: time.sleep(0.2) else: - raise SystemExit("BroLink streaming engine did not start listening") + raise SystemExit("Latch streaming engine did not start listening") diff --git a/deploy/node/Dockerfile b/deploy/node/Dockerfile index 24d7c72..4d2fcf5 100644 --- a/deploy/node/Dockerfile +++ b/deploy/node/Dockerfile @@ -1,8 +1,8 @@ -# BroLink node: a virtual desktop plus Sunshine plus the BroLink control +# Latch node: a virtual desktop plus Sunshine plus the Latch control # service, optionally joined to Tailscale as its own machine. # # Build from the repository root: -# docker build -f deploy/node/Dockerfile -t brolink-node . +# docker build -f deploy/node/Dockerfile -t latch-node . # # Ubuntu 24.04 matches the pinned Sunshine .deb. @@ -16,7 +16,7 @@ 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 brolink-host +RUN cargo build --release -p latch-host FROM ubuntu:24.04 ARG SUNSHINE_TAG=v2026.906.222525 @@ -46,16 +46,16 @@ RUN set -eux; \ # Optional Tailscale userspace client so this container is its own node. COPY --from=docker.io/tailscale/tailscale:stable /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/ -COPY --from=build /src/target/release/brolink-host /usr/local/bin/brolink-host +COPY --from=build /src/target/release/latch-host /usr/local/bin/latch-host COPY deploy/node/entrypoint.sh /entrypoint.sh -COPY deploy/node/bootstrap.py /opt/brolink/bootstrap.py +COPY deploy/node/bootstrap.py /opt/latch/bootstrap.py RUN chmod +x /entrypoint.sh \ && mkdir -p /root/.config /var/lib/tailscale ENV DISPLAY=:1 \ HOME=/root \ - BROLINK_USER=brolink \ - BROLINK_PASS= + LATCH_USER=latch \ + LATCH_PASS= VOLUME ["/root/.config", "/root/.local", "/var/lib/tailscale"] ENTRYPOINT ["/entrypoint.sh"] diff --git a/deploy/node/NODE.md b/deploy/node/NODE.md index 3ff5e6d..66afd8c 100644 --- a/deploy/node/NODE.md +++ b/deploy/node/NODE.md @@ -1,6 +1,6 @@ -# BroLink node (Docker) +# Latch node (Docker) -A container that **shares a desktop** on your Tailscale network. Any BroLink +A container that **shares a desktop** on your Tailscale network. Any Latch app — Mac, Windows, or another node — lists it and can Connect. Use this for a VPS, or run several copies on one host so each container is @@ -11,13 +11,13 @@ It is not the packet relay. The relay kit stays in `deploy/relay/`. ## What runs inside - Xvfb + XFCE (a virtual 1920×1080 desktop) -- Sunshine (the same streaming engine BroLink uses on Windows and macOS) -- BroLink's control service on TCP 47850 +- 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`) ## One node -From the BroLink repository: +From the Latch repository: ```bash cp deploy/node/env.example deploy/node/.env @@ -26,7 +26,7 @@ docker compose -f deploy/node/docker-compose.yml up -d --build ``` Sign the auth key in at [the admin console](https://login.tailscale.com/admin/machines) -if it asks. Open BroLink on your Mac or Windows PC: the hostname appears +if it asks. Open Latch on your Mac or Windows PC: the hostname appears under **Your machines**. Click **Connect**. ## Several containers on one VPS @@ -34,10 +34,10 @@ under **Your machines**. Click **Connect**. Each compose project needs its own name and hostname: ```bash -BROLINK_CONTAINER=brolink-node-a TS_HOSTNAME=vps-a \ +LATCH_CONTAINER=latch-node-a TS_HOSTNAME=vps-a \ docker compose -p node-a -f deploy/node/docker-compose.yml up -d --build -BROLINK_CONTAINER=brolink-node-b TS_HOSTNAME=vps-b \ +LATCH_CONTAINER=latch-node-b TS_HOSTNAME=vps-b \ docker compose -p node-b -f deploy/node/docker-compose.yml up -d --build ``` @@ -52,7 +52,7 @@ identity, so it shows up as a machine instead of being hidden as a relay. ## Build notes -The image compiles `brolink-host` and installs the Ubuntu 24.04 Sunshine +The image compiles `latch-host` 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`. diff --git a/deploy/node/bootstrap.py b/deploy/node/bootstrap.py index 530050a..1832707 100644 --- a/deploy/node/bootstrap.py +++ b/deploy/node/bootstrap.py @@ -26,8 +26,8 @@ def atomic_write(path, content): def configure(host, credentials, environ): text = host.read_text() if host.exists() else "power_allowed = true\nstart_with_windows = true\nstay_awake = true\n" cfg = tomllib.loads(text) # Do not overwrite a damaged or unreadable config. - user = environ.get("BROLINK_USER") or cfg.get("sunshine_user") or "brolink" - password = environ.get("BROLINK_PASS") or cfg.get("sunshine_pass") or secrets.token_urlsafe(24) + user = environ.get("LATCH_USER") or cfg.get("sunshine_user") or "latch" + password = environ.get("LATCH_PASS") or cfg.get("sunshine_pass") or secrets.token_urlsafe(24) for key, value in (("sunshine_user", user), ("sunshine_pass", password)): line = key + " = " + json.dumps(value) + "\n" pattern = re.compile(r"(?m)^" + key + r"\s*=.*(?:\n|$)") @@ -45,5 +45,5 @@ def configure(host, credentials, environ): if __name__ == "__main__": - configure(Path("/root/.local/share/brolink/host.toml"), - Path("/root/.config/sunshine/brolink-web.json"), os.environ) + configure(Path("/root/.local/share/latch/host.toml"), + Path("/root/.config/sunshine/latch-web.json"), os.environ) diff --git a/deploy/node/docker-compose.yml b/deploy/node/docker-compose.yml index 6ded0e3..4f0352b 100644 --- a/deploy/node/docker-compose.yml +++ b/deploy/node/docker-compose.yml @@ -1,5 +1,5 @@ -# BroLink node — a virtual desktop on this VPS (or any Docker host) that -# other BroLink machines on your Tailscale account can open. +# Latch node — a virtual desktop on this VPS (or any Docker host) that +# other Latch machines on your Tailscale account can open. # # From the repository root: # cp deploy/node/env.example deploy/node/.env @@ -9,15 +9,15 @@ # Repeat with a different project name / TS_HOSTNAME for another container # on the same host; each one is its own tailnet machine. -name: brolink-node +name: latch-node services: node: build: context: ../.. dockerfile: deploy/node/Dockerfile - image: ${BROLINK_IMAGE:-brolink-node:local} - container_name: ${BROLINK_CONTAINER:-brolink-node} + image: ${LATCH_IMAGE:-latch-node:local} + container_name: ${LATCH_CONTAINER:-latch-node} restart: unless-stopped init: true env_file: diff --git a/deploy/node/entrypoint.sh b/deploy/node/entrypoint.sh index a4b72d8..554d46e 100755 --- a/deploy/node/entrypoint.sh +++ b/deploy/node/entrypoint.sh @@ -1,28 +1,28 @@ #!/bin/bash -# Start a virtual desktop, the streaming engine, BroLink's control service, +# Start a virtual desktop, the streaming engine, Latch's control service, # and (optionally) Tailscale so this container is a machine on the tailnet. set -euo pipefail -log() { echo "[brolink-node] $*"; } +log() { echo "[latch-node] $*"; } -install -d -m 700 /root/.config/sunshine /root/.local/share/brolink /var/run/tailscale -if [[ ! -x /usr/local/bin/brolink-engine && -x /usr/bin/sunshine ]]; then - cp /usr/bin/sunshine /usr/local/bin/brolink-engine +install -d -m 700 /root/.config/sunshine /root/.local/share/latch /var/run/tailscale +if [[ ! -x /usr/local/bin/latch-engine && -x /usr/bin/sunshine ]]; then + cp /usr/bin/sunshine /usr/local/bin/latch-engine fi -ENGINE=/usr/local/bin/brolink-engine +ENGINE=/usr/local/bin/latch-engine if [[ ! -x "$ENGINE" ]]; then ENGINE=/usr/bin/sunshine fi if [[ -n "${TS_AUTHKEY:-}" ]]; then - log "starting Tailscale (userspace) as ${TS_HOSTNAME:-brolink-node}" + log "starting Tailscale (userspace) as ${TS_HOSTNAME:-latch-node}" tailscaled --tun=userspace-networking --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 sleep 0.5 done - tailscale up --authkey="$TS_AUTHKEY" --hostname="${TS_HOSTNAME:-brolink-node}" \ + tailscale up --authkey="$TS_AUTHKEY" --hostname="${TS_HOSTNAME:-latch-node}" \ --accept-dns=false fi @@ -41,7 +41,7 @@ CONF=/root/.config/sunshine/sunshine.conf cat >"$CONF" <<'EOF' system_tray = disabled origin_web_ui_allowed = pc -credentials_file = /root/.config/sunshine/brolink-web.json +credentials_file = /root/.config/sunshine/latch-web.json max_bitrate = 0 minimum_fps_target = 60 fec_percentage = 20 @@ -68,7 +68,7 @@ EOF # One persisted source of truth for the host and engine. Fails closed on a # damaged config, and never exposes the engine password in process arguments. -python3 /opt/brolink/bootstrap.py +python3 /opt/latch/bootstrap.py log "starting streaming engine" "$ENGINE" "$CONF" >/var/log/sunshine.log 2>&1 & @@ -97,5 +97,5 @@ else: raise SystemExit("Streaming engine did not start listening within 15 seconds") PY -log "starting BroLink control service" -exec /usr/local/bin/brolink-host --background +log "starting Latch control service" +exec /usr/local/bin/latch-host --background diff --git a/deploy/node/env.example b/deploy/node/env.example index 408339b..787e10a 100644 --- a/deploy/node/env.example +++ b/deploy/node/env.example @@ -3,10 +3,10 @@ # Reusable Tailscale auth key (tailscale.com/admin/settings/keys). # The container joins as its own machine; tag it however you like. TS_AUTHKEY=tskey-auth-... -TS_HOSTNAME=brolink-node +TS_HOSTNAME=latch-node # Optional. Generated on first start if empty. -BROLINK_USER=brolink -BROLINK_PASS= +LATCH_USER=latch +LATCH_PASS= SCREEN_SIZE=1920x1080x24 diff --git a/deploy/node/selftest.sh b/deploy/node/selftest.sh index a12b94a..3e0e61c 100755 --- a/deploy/node/selftest.sh +++ b/deploy/node/selftest.sh @@ -10,16 +10,16 @@ need() { fail=1 fi } -need "$ROOT/Dockerfile" "brolink-host" +need "$ROOT/Dockerfile" "latch-host" need "$ROOT/Dockerfile" "ubuntu:24.04" need "$ROOT/Dockerfile" "v2026.906.222525" -need "$ROOT/entrypoint.sh" "brolink-host --background" +need "$ROOT/entrypoint.sh" "latch-host --background" need "$ROOT/entrypoint.sh" "userspace-networking" need "$ROOT/entrypoint.sh" "packetsize = 1184" need "$ROOT/entrypoint.sh" "sw_preset = ultrafast" -need "$ROOT/entrypoint.sh" "brolink-engine" +need "$ROOT/entrypoint.sh" "latch-engine" need "$ROOT/entrypoint.sh" '"name": "Desktop"' -need "$ROOT/docker-compose.yml" "brolink-node" +need "$ROOT/docker-compose.yml" "latch-node" need "$ROOT/docker-compose.yml" "node-tailscale" # Sunshine rejects the entire catalog when its required `env` object is # absent, even though its HTTP health endpoints still return success. diff --git a/deploy/node/test_bootstrap.py b/deploy/node/test_bootstrap.py index 0780344..b478c14 100644 --- a/deploy/node/test_bootstrap.py +++ b/deploy/node/test_bootstrap.py @@ -27,9 +27,9 @@ def test_explicit_password_and_quotes_round_trip(self): with tempfile.TemporaryDirectory() as tmp: host, creds = Path(tmp) / 'host.toml', Path(tmp) / 'web.json' password = 'a"b\\c\n$123' - configure(host, creds, {'BROLINK_PASS': password}) + configure(host, creds, {'LATCH_PASS': password}) self.assertEqual(tomllib.loads(host.read_text())['sunshine_pass'], password) - configure(host, creds, {'BROLINK_PASS': 'new'}) + configure(host, creds, {'LATCH_PASS': 'new'}) self.assertEqual(tomllib.loads(host.read_text())['sunshine_pass'], 'new') def test_corruption_is_not_overwritten(self): diff --git a/deploy/relay/RELAY.md b/deploy/relay/RELAY.md index f0d827c..74a47df 100644 --- a/deploy/relay/RELAY.md +++ b/deploy/relay/RELAY.md @@ -1,16 +1,16 @@ -# BroLink relay +# Latch relay -A BroLink stream is fastest when the two devices connect directly. When a strict NAT +A Latch stream is fastest when the two devices connect directly. When a strict NAT or firewall blocks that, Tailscale falls back to a shared DERP server, which adds latency and caps throughput. This kit turns a machine you own — a cheap cloud VPS is ideal — into a **Tailscale peer relay**, so blocked streams go through your box instead of DERP. -The order is Tailscale's own and needs no BroLink code: **direct → peer relay → DERP.** +The order is Tailscale's own and needs no Latch code: **direct → peer relay → DERP.** DERP stays as the last-resort fallback. If the relay is down, streaming still works, just at DERP quality. -**A relay only serves its own tailnet.** There is no shared BroLink relay and there +**A relay only serves its own tailnet.** There is no shared Latch relay and there cannot be one: a peer relay can only relay for devices in the same tailnet as itself. If you and a friend are on separate tailnets, each of you runs a relay for your own. @@ -107,10 +107,10 @@ tailnet", which for a personal tailnet is what you want. **Do not use `"src": ["*"]`.** `*` includes devices that have no business being relayed and sends traffic on detours that make latency worse. If you want to be -stricter than `autogroup:member`, tag the PCs that host BroLink streams and narrow it: +stricter than `autogroup:member`, tag the PCs that host Latch streams and narrow it: ```json -"src": ["tag:brolink-host"] +"src": ["tag:latch-host"] ``` Tailscale's own guidance: `src` should be devices in a stable location behind a strict @@ -143,7 +143,7 @@ not only a healthy container or saved relay preferences. The default tailnet name is `relay` (`TS_HOSTNAME` in `.env`). To rename an existing node, update `.env`, run -`docker exec brolink-relay tailscale set --hostname=relay`, then +`docker exec latch-relay tailscale set --hostname=relay`, then `docker compose up -d` to persist the container environment. This retains the node identity, IP address, tags, and pairing. @@ -157,7 +157,7 @@ Deleting the volume is the only way to start over: **Stale nodes after a re-login.** A `docker compose down -v` (or a `tailscale logout` inside the container) leaves the previous node listed *offline* in the admin console, and the new node may come up as `relay-1` because the old one still holds the plain name. -Nothing uses the offline node (no grant matches it, and BroLink only looks at `tag:relay` +Nothing uses the offline node (no grant matches it, and Latch only looks at `tag:relay` nodes that are online), so it is cosmetic. Delete it on the Machines page; there is no CLI for that. The live relay is whichever `relay*` row shows `tag:relay` and is online. @@ -170,7 +170,7 @@ opens the new port), then remove the old rule by hand. The relay picks the new p on restart. **Turning it off:** `docker compose down`. Streams fall back to DERP by themselves. -To keep the node but stop relaying: `docker exec brolink-relay tailscale set --relay-server-port=""`. +To keep the node but stop relaying: `docker exec latch-relay tailscale set --relay-server-port=""`. ### Troubleshooting @@ -179,8 +179,8 @@ To keep the node but stop relaying: `docker exec brolink-relay tailscale set --r | `install-relay: no TS_AUTHKEY, and no authenticated state` | Expected on a first run. Nothing was started, nothing changed. Follow the printed steps. | | `relay prefs did NOT take effect. RelayServerPort is 'unset'` | The container is up but not relaying. The script prints the configurator's own log lines underneath; look for `RELAY CONFIG FAILED`. Usual causes are the next two rows. | | `RELAY_PORT='…' in .env is not a UDP port` | Nothing was changed. Fix the value; a trailing `# comment` is fine, anything non-numeric is not. | -| Container up, node `NeedsLogin` | The key was empty, expired, or single-use and already spent. `docker logs brolink-relay \| grep login.tailscale.com` for a manual login URL. | -| `Relay prefs NOT applied` in the logs | The node never authenticated inside `RELAY_WAIT_SECS`. Finish the login, then `docker restart brolink-relay`. | +| Container up, node `NeedsLogin` | The key was empty, expired, or single-use and already spent. `docker logs latch-relay \| grep login.tailscale.com` for a manual login URL. | +| `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`. | diff --git a/deploy/relay/docker-compose.yml b/deploy/relay/docker-compose.yml index 6fdf801..23e90aa 100644 --- a/deploy/relay/docker-compose.yml +++ b/deploy/relay/docker-compose.yml @@ -1,14 +1,14 @@ -# BroLink peer relay — a Tailscale node that relays BroLink streams instead of DERP. +# Latch peer relay — a Tailscale node that relays Latch streams instead of DERP. # Read RELAY.md first. Run ./install-relay.sh; do not `docker compose up` by hand # unless the UDP port is already open in the host firewall and the cloud security list. # Pinned so another stack in a directory called "relay" cannot share this state volume. -name: brolink-relay +name: latch-relay services: relay: image: ${TS_IMAGE:-tailscale/tailscale:latest} - container_name: brolink-relay + container_name: latch-relay restart: unless-stopped # Host networking: a peer relay must own a real UDP port on the public interface. diff --git a/deploy/relay/install-relay.sh b/deploy/relay/install-relay.sh index 8d6d49b..9b3a38f 100755 --- a/deploy/relay/install-relay.sh +++ b/deploy/relay/install-relay.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Bring up (or re-converge) the BroLink peer relay on this host. Safe to run twice: +# Bring up (or re-converge) the Latch peer relay on this host. Safe to run twice: # every step checks before it changes anything, and the node keeps its identity # because state lives in a named volume. # @@ -10,8 +10,8 @@ set -euo pipefail cd "$(dirname "$0")" -CONTAINER=brolink-relay -STATE_VOLUME=brolink-relay_relay-state +CONTAINER=latch-relay +STATE_VOLUME=latch-relay_relay-state # Overridable so ./selftest.sh can drive the real script instead of a patched copy. TUN_DEVICE=${TUN_DEVICE:-/dev/net/tun} AUTH_WAIT_SECS=${AUTH_WAIT_SECS:-120} @@ -140,7 +140,7 @@ num target prot opt source destination [ "$out" = "60" ] || die "selftest: yaml wait, got '$out'" out=$(printf ' RELAY_PORT: 40000\n' | yaml_env_value RELAY_PORT) [ "$out" = "40000" ] || die "selftest: unquoted yaml, got '$out'" - out=$(printf ' TS_HOSTNAME: brolink-relay\n' | yaml_env_value RELAY_PORT) + out=$(printf ' TS_HOSTNAME: latch-relay\n' | yaml_env_value RELAY_PORT) [ -z "$out" ] || die "selftest: absent yaml key produced '$out'" out=$(printf ' RELAY_PORT: "99999999"\n' | yaml_env_value RELAY_PORT) [ "$out" = "99999999" ] || die "selftest: malformed port was silently repaired to '$out'" @@ -298,12 +298,12 @@ container_started_at() { docker inspect -f '{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null || true } -# Only `brolink-relay:` lines from the current start — never dump tailscaled. +# Only `latch-relay:` lines from the current start — never dump tailscaled. configurator_lines() { local since since=$(container_started_at) [ -n "$since" ] || return 0 - docker logs --since "$since" "$CONTAINER" 2>&1 | grep 'brolink-relay:' || true + docker logs --since "$since" "$CONTAINER" 2>&1 | grep 'latch-relay:' || true } current_config_marker() { diff --git a/deploy/relay/relay-entrypoint.sh b/deploy/relay/relay-entrypoint.sh index d08cbef..5dcfd23 100755 --- a/deploy/relay/relay-entrypoint.sh +++ b/deploy/relay/relay-entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/sh -# BroLink peer-relay entrypoint. +# Latch peer-relay entrypoint. # # The official tailscale image entrypoint (containerboot) exposes no relay setting # and does not run a user command, so relay prefs have to be applied with @@ -17,7 +17,7 @@ RELAY_STATIC_ENDPOINTS="${RELAY_STATIC_ENDPOINTS:-}" RELAY_MIN_MAJOR=1 RELAY_MIN_MINOR=86 -log() { echo "brolink-relay: $*" >&2; } +log() { echo "latch-relay: $*" >&2; } # 0 if $1 (e.g. "1.102.3") is >= RELAY_MIN_MAJOR.RELAY_MIN_MINOR. # Numeric on purpose: a string compare would rank "1.102" below "1.86". @@ -82,7 +82,7 @@ wait_for_running() { NeedsLogin) # `[ ] && log` would abort this subshell under `set -e` on the false branch. if [ "$((i % 30))" -eq 0 ]; then - log "waiting for login (docker logs brolink-relay | grep login.tailscale.com)" + log "waiting for login (docker logs latch-relay | grep login.tailscale.com)" fi ;; esac @@ -90,7 +90,7 @@ wait_for_running() { sleep 1 done log "BackendState never reached Running within ${RELAY_WAIT_SECS}s (last: ${state:-unknown})." - log "Relay prefs NOT applied. Authenticate, then: docker restart brolink-relay" + log "Relay prefs NOT applied. Authenticate, then: docker restart latch-relay" return 1 } diff --git a/deploy/relay/selftest.sh b/deploy/relay/selftest.sh index e6b04eb..fac8a3c 100755 --- a/deploy/relay/selftest.sh +++ b/deploy/relay/selftest.sh @@ -45,13 +45,13 @@ make_stubs() { # $1 = sandbox dir fi } emit_old() { - echo "brolink-relay: RELAY CONFIG OK port=40000" + echo "latch-relay: RELAY CONFIG OK port=40000" echo "magicsock: old-lifecycle chatter" } emit_current() { case "$(marker_now)" in - FAILED) echo "brolink-relay: RELAY CONFIG FAILED port=40000 - prefs not applied, this node is NOT a peer relay" ;; - OK) echo "brolink-relay: RELAY CONFIG OK port=40000" ;; + FAILED) echo "latch-relay: RELAY CONFIG FAILED port=40000 - prefs not applied, this node is NOT a peer relay" ;; + OK) echo "latch-relay: RELAY CONFIG OK port=40000" ;; esac i=0 while [ "$i" -lt "${STUB_VERBOSE:-0}" ]; do @@ -77,9 +77,9 @@ make_stubs() { # $1 = sandbox dir port=${port%% #*}; wait=${wait%% #*} port=${port%\"}; port=${port#\"} wait=${wait%\"}; wait=${wait#\"} - printf 'name: brolink-relay\nservices:\n relay:\n environment:\n RELAY_PORT: "%s"\n RELAY_WAIT_SECS: "%s"\n' "$port" "$wait" + printf 'name: latch-relay\nservices:\n relay:\n environment:\n RELAY_PORT: "%s"\n RELAY_WAIT_SECS: "%s"\n' "$port" "$wait" exit 0 ;; - up) echo "Container brolink-relay Started"; exit 0 ;; + up) echo "Container latch-relay Started"; exit 0 ;; esac ;; volume) [ -n "${STUB_STATE_DIR:-}" ] || exit 1 @@ -92,16 +92,16 @@ make_stubs() { # $1 = sandbox dir exit 1 ;; exec) case "$*" in - "brolink-relay tailscale status --json") + "latch-relay tailscale status --json") printf '{\n\t"BackendState": "%s"\n}\n' "${STUB_BACKEND:-Running}"; exit 0 ;; - "brolink-relay tailscale debug prefs") + "latch-relay tailscale debug prefs") if [ -n "${STUB_PREFS_PORT:-}" ]; then printf '{\n\t"WantRunning": true,\n\t"RelayServerPort": %s\n}\n' "$STUB_PREFS_PORT" else printf '{\n\t"WantRunning": true\n}\n' fi; exit 0 ;; - "brolink-relay tailscale status") - echo "100.64.0.1 brolink-relay me@ linux -"; exit 0 ;; + "latch-relay tailscale status") + echo "100.64.0.1 latch-relay me@ linux -"; exit 0 ;; esac ;; inspect) cat "$STUB_DIR/started_at"; exit 0 ;; @@ -187,7 +187,7 @@ run_case() { # $1 = sandbox dir ; remaining env comes from the caller ) >"$1/out" 2>&1 } -WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/brolink-relay-selftest.XXXXXX") +WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/latch-relay-selftest.XXXXXX") # Sibling named like the old glob. If cleanup still does rm -rf .../relaykit.*, # this vanishes and the ownership check fails. DECOY=$(mktemp -d "${TMPDIR:-/tmp}/relaykit.XXXXXX") diff --git a/docs/MACOS.md b/docs/MACOS.md index 45fb57b..3e263f9 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -1,10 +1,10 @@ # macOS client (Apple Silicon) -BroLink for the Mac is one native app: every machine on your Tailscale +Latch for the Mac is one native app: every machine on your Tailscale account, a Connect button each, a power menu, the stream itself, and a -**Sharing** tab so a Windows PC or another Mac can open this desktop. BroLink's stream protocol is compiled in, video is decoded by +**Sharing** tab so a Windows PC or another Mac can open this desktop. Latch's stream protocol is compiled in, video is decoded by VideoToolbox (H.264 and HEVC), audio by Opus, and the picture is drawn in -BroLink's own window under a toolbar. +Latch's own window under a toolbar. ## Requirements @@ -21,19 +21,19 @@ the [GitHub CLI](https://cli.github.com) and sign in (`brew install gh && gh auth login`) so the script can fall back to `gh`. ```bash -curl -fsSL https://raw.githubusercontent.com/MrBeldum/brolink/main/scripts/install-macos-release.sh | bash +curl -fsSL https://raw.githubusercontent.com/MrBeldum/latch/main/scripts/install-macos-release.sh | bash ``` It checks the release SHA-256, verifies the ad-hoc signature, swaps the app into `/Applications` (the live copy is not deleted first) and opens it. -By hand: download `brolink-macos-arm64.tar.gz` from the -[latest release](https://github.com/MrBeldum/brolink/releases/latest), then +By hand: download `latch-macos-arm64.tar.gz` from the +[latest release](https://github.com/MrBeldum/latch/releases/latest), then ```bash -tar xzf brolink-macos-arm64.tar.gz -xattr -dr com.apple.quarantine BroLink.app -open BroLink.app +tar xzf latch-macos-arm64.tar.gz +xattr -dr com.apple.quarantine Latch.app +open Latch.app ``` The `xattr` step is required for a browser download. The app is ad-hoc @@ -48,7 +48,7 @@ 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/BroLink.app`, and installs it to +needed for the C parts), bundles `dist/Latch.app`, and installs it to `/Applications`. A locally built app never carries the quarantine flag. ### Signing @@ -59,9 +59,9 @@ notarize: ```bash CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./scripts/bundle-macos.sh -ditto -c -k --keepParent dist/BroLink.app dist/BroLink.zip -xcrun notarytool submit dist/BroLink.zip --keychain-profile "notary" --wait -xcrun stapler staple dist/BroLink.app +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 ``` ## First run @@ -72,11 +72,11 @@ xcrun stapler staple dist/BroLink.app first. A dot and one line say whether each is ready, asleep, or missing something on its side. 3. Click **Connect** (**Wake and connect** for a machine that is asleep). - The first time with a machine, BroLink pairs with it: it picks a - random PIN, sends it to BroLink on that machine over Tailscale, and - BroLink there enters it for you. You see the PIN but never need it. If - BroLink is not sharing on that machine, pairing cannot finish: open - BroLink there and set up sharing on its **Sharing** tab first. + The first time with a machine, Latch pairs with it: it picks a + random PIN, sends it to Latch on that machine over Tailscale, and + Latch there enters it for you. You see the PIN but never need it. If + Latch is not sharing on that machine, pairing cannot finish: open + Latch there and set up sharing on its **Sharing** tab first. 4. The desktop appears. Command-1 opens **Machines**, Command-2 **Sharing** and Command-comma @@ -112,12 +112,12 @@ the Mac cursor's position is sent 1:1 instead and nothing is captured. | Toolbar item | What it does | |--------------|--------------| | **● Machine name · 1920×1080 · 60 fps · HEVC** | The stream as negotiated (the numbers show on wide windows); the dot turns amber while the connection is poor | -| **Direct · 38 ms / Relayed via Tokyo · 210 ms / Via your relay · 60 ms** | The path Tailscale found and its round trip. Amber means every packet goes through one of Tailscale's shared relays, which adds delay; **Connection details** under the machine list say why and what would give a direct path. What BroLink asks for is the same on every path | +| **Direct · 38 ms / Relayed via Tokyo · 210 ms / Via your relay · 60 ms** | The path Tailscale found and its round trip. Amber means every packet goes through one of Tailscale's shared relays, which adds delay; **Connection details** under the machine list say why and what would give a direct path. What Latch asks for is the same on every path | | **Mouse** | **Captured, for games** (the default): a click on the picture hides the cursor and sends raw movement, which games read; the host key frees it. **Free, for desktops**: the Mac cursor's position is sent 1:1 and nothing is captured. The choice is remembered, and is also in Settings | | **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 BroLink Host…** for a PC whose host is older than 3.1 | +| **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 | | **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 | @@ -130,9 +130,9 @@ they do on the Mac, and ⌘ can stay held across several of them. ### Clipboard -With BroLink Host 3.1 or newer on the PC, the clipboard follows you both +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 (BroLink sends it to the PC first, +⌘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. @@ -144,31 +144,31 @@ both routers. When it cannot, every packet goes through a relay: your own peer relay if you run one (**Relay** in Settings), otherwise one of Tailscale's (DERP). A relay adds a detour, and the round trip it adds is a floor under how quickly the PC answers a click; it changes nothing about -what BroLink asks for. Recommended quality is this screen's size at 50 +what Latch asks for. Recommended quality is this screen's size at 50 Mbps on every path, so a relayed stream is laggier, not blurrier. The encoder holds that rate (CBR); a still desktop no longer drops to a trickle. Tailscale's own relays are shared, so a stream through one can also stutter at busy times; a peer relay you run is yours alone. -BroLink shows which case you are in next to each PC (**Relayed via Tokyo · +Latch shows which case you are in next to each PC (**Relayed via Tokyo · 210 ms**, **Via your relay · 60 ms**) and, when relayed, **Connection details** under the list says which router is in the way and what would fix it: UPnP or NAT-PMP turned on in the PC's router, a UDP port forwarded to the PC, or IPv6 on both networks. Both machines report their own side -(`tailscale netcheck`); the PC's report needs BroLink Host 3.1. +(`tailscale netcheck`); the PC's report needs Latch Host 3.1. ## A machine's menu The **…** button at the end of each row offers what that machine allows: -**Wake** (asleep, and BroLink has learned how to wake it; sends the packet +**Wake** (asleep, and Latch has learned how to wake it; sends the packet without connecting), **Test waking it from this network** (awake), **Sleep**, **Restart…** and **Shut down…** (when that machine allows remote power actions), and **Copy Tailscale address**. Restart and shut down ask first; both force-close programs there. **Test waking it from this network** sends the wake packet to the awake -machine and asks BroLink there whether it arrived. A pass means waking it +machine and asks Latch there whether it arrived. A pass means waking it from where you are will work; a failure means the packets are not reaching it from this network (see below). @@ -186,7 +186,7 @@ it from this network (see below). | Command acts as Ctrl | Off makes ⌘ the Windows key (Mac only) | | 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 BroLink up to date | Updates this app and the Windows PCs' BroLink Host; **Check now** checks at once | +| Keep Latch up to date | Updates this app and the Windows PCs' Latch Host; **Check now** checks at once | | Relay | Whether a relay of your own is in use, and the grant it needs | | About | Version, licence, source, and the open-source notices | @@ -196,14 +196,14 @@ asks for; the machine list shows the same. A session also reconnects by itself, at the new size, when this Mac's window moves to a display of a different size. -Settings are saved in `~/Library/Application Support/dev.brolink.BroLink/client.toml`, -along with the MAC, LAN address and pairing certificate of each PC BroLink +Settings are saved in `~/Library/Application Support/com.bardbro.Latch/client.toml`, +along with the MAC, LAN address and pairing certificate of each PC Latch has paired with. ## Waking a PC from another network -Asleep, the PC's Tailscale is off, so BroLink cannot reach it over the -tailnet. Leave the PC on (BroLink keeps it awake while plugged in) +Asleep, the PC's Tailscale is off, so Latch cannot reach it over the +tailnet. Leave the PC on (Latch keeps it awake while plugged in) if you want **Connect** from anywhere. The wake packet is sent to the LAN broadcast, to the PC's LAN address and @@ -218,14 +218,14 @@ a Raspberry Pi, or a router that runs Tailscale) is the alternative. Use - **Local network**: macOS 15 may ask; allow it so the wake broadcast can go out. - **Incoming connections**: if the macOS firewall is on it may ask once, - the first time the Mac serves a BroLink Host install through the stream. -- Nothing else. BroLink captures no screen and reads the keyboard and + the first time the Mac serves a Latch Host install through the stream. +- Nothing else. Latch captures no screen and reads the keyboard and mouse only in its own window. ## Connected, but the picture is black A connection can carry valid video that contains only black pixels. -BroLink now detects this after five seconds and shows a persistent message +Latch now detects this after five seconds and shows a persistent message instead of presenting the stream as healthy. **Restart stream** reconnects and, for Desktop, resets the PC's capture session. If no video arrives or decoding fails, the message distinguishes those problems too. @@ -255,29 +255,29 @@ capture failing while the desktop plainly has a picture. The app checks GitHub for a new release about every six hours and twenty seconds after it starts. A newer version is downloaded to -`~/Library/Application Support/dev.brolink.BroLink/updates//`, checked against the +`~/Library/Application Support/com.bardbro.Latch/updates//`, checked against the SHA-256 GitHub publishes for the asset and against its own code signature, -and moved over `/Applications/BroLink.app` once no stream is running; the -app then relaunches. The Mac also sends the new `brolink-host.exe` to each -PC whose BroLink Host is older and already speaks `/v1/update` (3.1+). +and moved over `/Applications/Latch.app` once no stream is running; the +app then relaunches. The Mac also sends the new `latch-host.exe` to each +PC whose Latch Host is older and already speaks `/v1/update` (3.1+). A PC still on 3.0 cannot take that, and nobody may be at the PC to install -by hand. So the stream's **PC → Update BroLink Host…** does it through -the stream: this Mac serves the new `brolink-host.exe` and a short +by hand. So the stream's **PC → Update Latch Host…** does it through +the stream: this Mac serves the new `latch-host.exe` and a short PowerShell script on its own Tailscale address (TCP 47851, to that one PC only, for ten minutes), presses Win+R on the PC, types one line (`powershell -ep bypass -c "irm http://:47851/u.ps1|iex"`) and presses Enter. A PowerShell window on the PC fetches the executable, checks its SHA-256 against the one in the script, stops the old service, swaps the file where it stands (found from the running process, or -`%LOCALAPPDATA%\BroLink`), registers it to start at logon and starts it. +`%LOCALAPPDATA%\Latch`), registers it to start at logon and starts it. The stream is not interrupted; the toolbar reports each step and the lobby shows the new version a few seconds later. The PC's desktop has to be unlocked and in front, since the Run box needs it. From then on updates arrive by themselves. Public releases need no GitHub login. If the repository is private, the -app uses, in order: `github_token` in `client.toml`, `BROLINK_GITHUB_TOKEN` +app uses, in order: `github_token` in `client.toml`, `LATCH_GITHUB_TOKEN` in the environment, and the token git has stored for github.com (which is there after `install-macos-release.sh` or any `git` use with the osxkeychain helper). Settings has the switch and a **Check now** button; the line under diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 311e8d0..46576af 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,7 +8,7 @@ rendezvous and PIN dialogs. Replaced in 2.0; the git history has it. ## v2.0 (retired) Glue around Sunshine, Moonlight and Tailscale: the Mac app installed -Moonlight and launched it; BroLink itself did wake, pairing and power. +Moonlight and launched it; Latch itself did wake, pairing and power. ## v3.0 diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 6d1b08d..102eaaa 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -1,6 +1,6 @@ # Windows -`brolink-host.exe` is BroLink on Windows: a background service other +`latch-host.exe` is Latch on Windows: a background service other machines talk to over Tailscale, and a window that lists every machine on your account (Connect opens their desktop) and shares this PC. The streaming engine, which ships in the same zip, does the capture. @@ -15,21 +15,21 @@ streaming engine, which ships in the same zip, does the capture. ## Install -1. Unzip the release and run `brolink-host.exe`, or run `install-host.ps1` - to copy it (and the bundled engine archive) to `%LOCALAPPDATA%\BroLink`, +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. 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%\BroLink\engine` (downloads that pinned release if the - archive is not beside the exe), and gives its executables BroLink's - name and icon so Task Manager and the volume mixer show "BroLink + `%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); - 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 flowing while the desktop is still; - - gives BroLink a login to the engine and restarts the engine service; + - gives Latch a login to the engine and restarts the engine service; - adds firewall rules: TCP 47850 inbound from `100.64.0.0/10` only, UDP 9 for the wake-packet listener, and the streaming ports from the tailnet; - on a PC with the [Virtual Display Driver](https://github.com/VirtualDrivers/Virtual-Display-Driver), @@ -44,13 +44,13 @@ streaming engine, which ships in the same zip, does the capture. `*PMNSOffload`, restarts the adapter if any changed, and lets the device wake the PC (`powercfg /deviceenablewake`). - The script's transcript is in `%LOCALAPPDATA%\BroLink\setup.log` and is + 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. 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 BroLink: BroLink +Advanced engine settings are not exposed anywhere in Latch: Latch configures the engine itself. ## Slow streams: the network, or the encoder @@ -75,7 +75,7 @@ engine service. ## What the service does -`brolink-host.exe --background` listens on TCP 47850 and answers these +`latch-host.exe --background` listens on TCP 47850 and answers these requests, all JSON: | Request | Effect | @@ -85,7 +85,7 @@ requests, all JSON: | `POST /v1/power {"action"}` | `sleep`, `restart`, or `shutdown` (closes the running app first) | | `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 `brolink-host.exe`; see Updates | +| `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 | | `POST /v1/display {"advanced_color"}` | Turns the HDR (advanced colour) desktop off or on | @@ -98,7 +98,7 @@ The service also listens on UDP 9. A magic packet for this PC's MAC that arrives while it is awake is logged and reported in the status, which is what **Test wake** on the Mac reads. -Logs: `%LOCALAPPDATA%\BroLink\service.log` and `panel.log`. +Logs: `%LOCALAPPDATA%\Latch\service.log` and `panel.log`. ## Waking it from the Mac, and turning it off @@ -123,7 +123,7 @@ if you would rather the Mac could not. Remote power actions force-close programs, because nobody is there to answer a save prompt. After a **restart**, the engine is back before anyone logs in (it is a -service), so the Mac can stream the login screen and sign in. BroLink's +service), so the Mac can stream the login screen and sign in. Latch's service starts at logon, so sleep and shutdown from the Mac return once someone is signed in. @@ -132,7 +132,7 @@ someone is signed in. Windows needs an active display for capture. If the connection works but the picture is black, attach a monitor or an HDMI/DisplayPort dummy plug, or install the signed [Virtual Display Driver](https://github.com/VirtualDrivers/Virtual-Display-Driver/releases). -It supplies a display even when the physical monitor is off and works with BroLink's +It supplies a display even when the physical monitor is off and works with Latch's bundled engine. Setup lists every screen size a Mac can ask for in the driver's settings file, and the engine switches the virtual display to the Mac's size at each connect, so there is nothing to choose in Windows Display settings. If the @@ -142,12 +142,12 @@ Mac again. ## Using an engine you installed yourself -BroLink streams through whichever compatible engine is already on the PC +Latch streams through whichever compatible engine is already on the PC before it installs its own. [Apollo](https://github.com/ClassicOldSong/Apollo) is one: it has a built-in virtual display that can match the Mac's resolution for a pixel-for-pixel 16:10 desktop on a MacBook screen. Install it in `C:\Program Files\Apollo` from its releases -page before running setup, and BroLink uses it instead of installing its +page before running setup, and Latch uses it instead of installing its own engine; the API and config layout are the same. ## Gamepads @@ -167,9 +167,9 @@ recommended. Sharing tab, or read `setup.log`. The usual causes are the UAC prompt being dismissed, or a driver that refuses the wake keywords. -- **Other machines list the PC as online, but "BroLink isn't installed"**: +- **Other machines list the PC as online, but "Latch isn't installed"**: the control service is not running or the firewall rule is missing. Open - BroLink on the PC (it restarts the service) and choose **Run setup again** + Latch on the PC (it restarts the service) and choose **Run setup again** on the Sharing tab. - **The Mac says the PC is asleep but it is on**: Tailscale's online flag lags by up to half a minute; Connect probes the PC directly and will work. @@ -185,20 +185,20 @@ recommended. ## Updates -BroLink Host does not download anything. The Mac fetches each release and -POSTs the new `brolink-host.exe` to `/v1/update` on the control port with +Latch Host does not download anything. The Mac fetches each release and +POSTs the new `latch-host.exe` to `/v1/update` on the control port with its version and SHA-256, from a machine on the PC's own Tailscale account, the same check that guards remote power actions. The service verifies the digest, that the bytes are a Windows executable and a newer version, writes -`brolink-host.exe.new` beside itself, renames the running file to -`brolink-host.exe.old`, moves the new one in and starts it with +`latch-host.exe.new` beside itself, renames the running file to +`latch-host.exe.old`, moves the new one in and starts it with `--replaces `; the new service waits for the old one to release the port, then removes the `.old` file. Both events appear in the host log and the streaming session, if any, is not interrupted. Hosts older than 3.1 have no update route, and the Mac does not POST the executable at them (that used to show as a broken pipe). Instead the Mac -installs 3.1 through the stream: from the stream's **PC → Update BroLink +installs 3.1 through the stream: from the stream's **PC → Update Latch Host…**, the Mac serves the new executable on its Tailscale address, presses Win+R here, types `powershell -ep bypass -c "irm http://:47851/u.ps1|iex"` and presses Enter. The script fetches the @@ -211,7 +211,7 @@ automatic. The background service starts with Windows by default and sets that again at every start, so a PC nobody can reach in person comes back after a -restart; **Start BroLink when you log in** on the Sharing tab is the only +restart; **Start Latch when you log in** on the Sharing tab is the only thing that turns it off. It also holds Windows awake while plugged in (**Keep this PC awake while plugged in**, same place), because a sleeping PC's Tailscale is asleep and a Mac on another network cannot diff --git a/docs/brand/logo-ink.png b/docs/brand/logo-ink.png new file mode 100644 index 0000000..bd8f403 Binary files /dev/null and b/docs/brand/logo-ink.png differ diff --git a/docs/brand/logo-ink.svg b/docs/brand/logo-ink.svg new file mode 100644 index 0000000..037e242 --- /dev/null +++ b/docs/brand/logo-ink.svg @@ -0,0 +1,6 @@ + + + Logo, one ink + + + diff --git a/docs/brand/logo-knockout.png b/docs/brand/logo-knockout.png new file mode 100644 index 0000000..f990e07 Binary files /dev/null and b/docs/brand/logo-knockout.png differ diff --git a/docs/brand/logo-knockout.svg b/docs/brand/logo-knockout.svg new file mode 100644 index 0000000..28b2287 --- /dev/null +++ b/docs/brand/logo-knockout.svg @@ -0,0 +1,6 @@ + + + Logo, knockout + + + diff --git a/docs/brand/logo-rails-ink.svg b/docs/brand/logo-rails-ink.svg new file mode 100644 index 0000000..87e8d7e --- /dev/null +++ b/docs/brand/logo-rails-ink.svg @@ -0,0 +1,8 @@ + + + Logo, rails, one ink + + + + + diff --git a/docs/brand/logo-rails.png b/docs/brand/logo-rails.png new file mode 100644 index 0000000..600ed09 Binary files /dev/null and b/docs/brand/logo-rails.png differ diff --git a/docs/brand/logo-rails.svg b/docs/brand/logo-rails.svg new file mode 100644 index 0000000..edb7933 --- /dev/null +++ b/docs/brand/logo-rails.svg @@ -0,0 +1,8 @@ + + + Logo, rails + + + + + diff --git a/docs/brand/logo.png b/docs/brand/logo.png new file mode 100644 index 0000000..5cc17e1 Binary files /dev/null and b/docs/brand/logo.png differ diff --git a/docs/brand/logo.svg b/docs/brand/logo.svg new file mode 100644 index 0000000..928ffea --- /dev/null +++ b/docs/brand/logo.svg @@ -0,0 +1,6 @@ + + + Logo + + + diff --git a/docs/design/AUDIT.md b/docs/design/AUDIT.md index e2970c4..a5601a3 100644 --- a/docs/design/AUDIT.md +++ b/docs/design/AUDIT.md @@ -2,11 +2,11 @@ A harsh pass over every screen of the unified window, the Sharing page and the stream overlay, as rendered by the snapshot harness and as read -in code. Severity is the effect on someone using BroLink, not the effort +in code. Severity is the effect on someone using Latch, not the effort to fix. Every item was found on 4.0.2 (`f96e1a3`); the last column is its state on this branch. -Renders: `cargo test -p brolink-client -p brolink-host snapshots -- --ignored` +Renders: `cargo test -p latch-client -p latch-host snapshots -- --ignored` writes `target/ui-snapshots/`. Names below refer to that folder (`client-lobby-1280x800@2x.png` and so on). @@ -67,16 +67,16 @@ All pairs are asserted in `crates/ui/src/theme.rs` and `widgets.rs` tests. | # | Severity | Finding | State | |---|----------|---------|-------| | U1 | High | "This Mac" everywhere, on Windows and Linux viewers too: errors, key-expiry notices, path explanations, settings hints. | Fixed: "this machine", or the right noun per OS. | -| U2 | High | Errors sent people to a UI that no longer exists: "Open BroLink Host on the PC and run setup", "Check Wake-on-LAN in BroLink Host". | Fixed: they point at the Sharing tab and say what to do. | +| U2 | High | Errors sent people to a UI that no longer exists: "Open Latch Host on the PC and run setup", "Check Wake-on-LAN in Latch Host". | Fixed: they point at the Sharing tab and say what to do. | | U3 | Medium | "Keep this machine awake while plugged in" on Mac and Linux, where `keep_awake` does nothing. | Fixed: Windows only. | | U4 | Medium | "Command key acts as Ctrl" on Windows and Linux viewers, which have no Command key. | Fixed: Mac only. | | U5 | Medium | Connect buttons vanished while any connection ran, reflowing the list. | Fixed: disabled in place, with a tooltip. | | U6 | Medium | "Wake" beside "Connect", which also wakes. | Fixed: "Wake and connect"; Wake alone is in the row's menu. | | U7 | Medium | The lobby's power menu was "PC" for every OS and hid Test wake behind power permissions. | Fixed: a "…" menu with what each machine allows, plus Copy Tailscale address. | -| U8 | Medium | While the app updated itself the connection card had an empty title. | Fixed: "Installing a BroLink update". | +| U8 | Medium | While the app updated itself the connection card had an empty title. | Fixed: "Installing a Latch update". | | U9 | Medium | Every failure offered "Try again at Smooth (1080p · 20 Mbps)", even a wake failure it cannot help, and a connection that never started was titled "… disconnected". | Fixed: "Couldn't connect to …" or "… disconnected"; the lighter profile only for stream failures. | | U10 | Medium | "Connecting failed at {stage} (code {code})." | Fixed: says the machine answered and points at the network. | -| U11 | Medium | Pairing called the other side "BroLink Host"; a stuck PIN hand-off showed as a violet notice. | Fixed: PIN in boxes, plain explanation, amber notice. | +| U11 | Medium | Pairing called the other side "Latch Host"; a stuck PIN hand-off showed as a violet notice. | Fixed: PIN in boxes, plain explanation, amber notice. | | U12 | Medium | "Tailscale: Tailscale is stopped." beside "Sign in to Tailscale to see your machines" over a list of remembered machines. | Fixed: one banner that says what happened and what to do; the list stays. | | U13 | Medium | Stream quality was two controls for one choice: four unselectable "quick profile" buttons plus Recommended/Manual, and a Reset. | Fixed: one control, Recommended · Smooth · Balanced · Sharp · Custom. | | U14 | Low | Settings was one long card; the licence only as raw NOTICE text. | Fixed: grouped sections and an About section. | @@ -94,7 +94,7 @@ All pairs are asserted in `crates/ui/src/theme.rs` and `widgets.rs` tests. | U26 | Low | "Paired Macs", "Start the background service with Windows" on a Mac. | Fixed. | | U27 | Low | The poor-network toast said "Open Stats" without saying how to reach the toolbar. | Fixed. | | U28 | Low | The Sharing page's network line said "a Mac … reaches this PC" on a Mac. | Fixed in the page; the service log's own line is unchanged. | -| U29 | Low | Phones on the tailnet were listed as "BroLink isn't installed". | Fixed. | +| U29 | Low | Phones on the tailnet were listed as "Latch isn't installed". | Fixed. | | U30 | Low | A relayed path was red, like a failure. | Fixed: amber, with the explanation in Connection details. | ### Accessibility and input @@ -102,7 +102,7 @@ All pairs are asserted in `crates/ui/src/theme.rs` and `widgets.rs` tests. | # | Severity | Finding | State | |---|----------|---------|-------| | X1 | High | Tab order ran backwards through right-aligned controls (egui focuses in creation order; right-to-left layouts create the rightmost first): a machine's menu before its Connect, the toolbar from Disconnect leftwards. | Fixed: `trailing` layout; tested. | -| X2 | Medium | Focus rings drawn inside controls in text colour, and egui's own widgets showed focus only as the pressed style. | Fixed: a violet ring outside every custom control and on built-in ones. | +| X2 | Medium | Focus rings drawn inside controls in text colour, and egui's own widgets showed focus only as the pressed style. | Fixed: a ring in the logo's blue outside every custom control and on built-in ones. | | X3 | Medium | No window shortcuts. | Fixed: Command/Ctrl-1, 2, comma; Escape. | | X4 | Low | Switch hit target 40×22 (< 24). | Fixed: 36×24. | | X5 | Low | Pills and other painted text were missing from the accessibility tree. | Fixed: status, tags, keycaps and the PIN report labels. | diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index 187a097..bae6b50 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -13,46 +13,46 @@ newest() { done printf '%s' "$pick" } -BIN="$(newest "$ROOT/target/aarch64-apple-darwin/release/brolink-host" "$ROOT/target/release/brolink-host")" +BIN="$(newest "$ROOT/target/aarch64-apple-darwin/release/latch-host" "$ROOT/target/release/latch-host")" if [[ -z "$BIN" ]]; then - echo "build the app first: cargo build --release -p brolink-host" >&2 + echo "build the app first: cargo build --release -p latch-host" >&2 exit 1 fi -APP="$ROOT/dist/BroLink.app" +APP="$ROOT/dist/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/BroLink.app. +# /Applications/Latch.app. touch "$ROOT/dist/.metadata_never_index" -cp "$BIN" "$APP/Contents/MacOS/BroLink" -chmod +x "$APP/Contents/MacOS/BroLink" -# The Finder/Dock icon: the logo tile filling the canvas, built by -# scripts/make-macos-icon.py. macOS applies the rounded app-icon shape. -cp "$ROOT/crates/client/BroLink.icns" "$APP/Contents/Resources/BroLink.icns" +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 +# scripts/make-icons.py. macOS applies the rounded app-icon shape. +cp "$ROOT/crates/client/Latch.icns" "$APP/Contents/Resources/Latch.icns" cp "$ROOT/LICENSE" "$ROOT/NOTICE" "$APP/Contents/Resources/" cat > "$APP/Contents/Info.plist" < - CFBundleNameBroLink - CFBundleDisplayNameBroLink - CFBundleIdentifierdev.brolink.client + CFBundleNameLatch + CFBundleDisplayNameLatch + CFBundleIdentifiercom.bardbro.latch CFBundleVersion${VERSION} CFBundleShortVersionString${VERSION} - CFBundleExecutableBroLink - CFBundleIconFileBroLink + CFBundleExecutableLatch + CFBundleIconFileLatch CFBundlePackageTypeAPPL LSMinimumSystemVersion13.0 LSApplicationCategoryType public.app-category.utilities NSHumanReadableCopyright - Copyright © 2026 BroLink Contributors + Copyright © 2026 Latch Contributors NSHighResolutionCapable NSLocalNetworkUsageDescription - BroLink sends the wake-up packet to your PC over the local network. + Latch sends the wake-up packet to your PC over the local network. PLIST diff --git a/scripts/install-host.ps1 b/scripts/install-host.ps1 index 7bd745b..3537ca0 100644 --- a/scripts/install-host.ps1 +++ b/scripts/install-host.ps1 @@ -1,5 +1,5 @@ -# Install BroLink Host for the current user: copy the exe and the bundled -# engine archive into %LOCALAPPDATA%\BroLink, add Start Menu and Desktop +# Install Latch Host for the current user: copy the exe and the bundled +# 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". [CmdletBinding()] @@ -14,28 +14,38 @@ if (-not $Exe) { $Here = Split-Path -Parent $MyInvocation.MyCommand.Path $Root = Split-Path -Parent $Here foreach ($c in @( - (Join-Path $Here "brolink-host.exe"), - (Join-Path $Root "brolink-host.exe"), - (Join-Path $Root "target\release\brolink-host.exe") + (Join-Path $Here "latch-host.exe"), + (Join-Path $Root "latch-host.exe"), + (Join-Path $Root "target\release\latch-host.exe") )) { if (Test-Path $c) { $Exe = $c; break } } } if (-not $Exe -or -not (Test-Path $Exe)) { - Write-Error "brolink-host.exe not found. Download a release or build with: cargo build --release -p brolink-host" + Write-Error "latch-host.exe not found. Download a release or build with: cargo build --release -p latch-host" } -$DestDir = Join-Path $env:LOCALAPPDATA "BroLink" -New-Item -ItemType Directory -Force -Path $DestDir | Out-Null -$DestExe = Join-Path $DestDir "brolink-host.exe" +$DestDir = Join-Path $env:LOCALAPPDATA "Latch" +# Where versions before 4.1 installed (they were called BroLink). Their +# settings, pairing identity and engine archive come across, and the folder +# goes once this one is in place. +$OldDir = Join-Path $env:LOCALAPPDATA "BroLink" +$DestExe = Join-Path $DestDir "latch-host.exe" # A running service or panel holds the old exe open; stop them first. try { Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:47850/v1/quit" -ContentType 'application/json' -Body '{}' -TimeoutSec 2 | Out-Null Start-Sleep -Milliseconds 800 } catch {} -Get-Process brolink-host -ErrorAction SilentlyContinue | Stop-Process -Force +Get-Process latch-host, brolink-host -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Milliseconds 300 +if ((Test-Path $OldDir) -and -not (Test-Path (Join-Path $DestDir "host.toml"))) { + New-Item -ItemType Directory -Force -Path $DestDir | Out-Null + Get-ChildItem -LiteralPath $OldDir -Force | Where-Object { $_.Name -notmatch '\.exe(\.old|\.new)?$' } | + ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $DestDir -Recurse -Force } + Write-Host "Settings carried over from $OldDir" +} +New-Item -ItemType Directory -Force -Path $DestDir | Out-Null Copy-Item $Exe $DestExe -Force Write-Host "Installed: $DestExe" # Same filename setup.rs ENGINE_ZIP looks for beside the exe. @@ -50,7 +60,8 @@ $Desktop = [Environment]::GetFolderPath("Desktop") $StartMenu = Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs" New-Item -ItemType Directory -Force -Path $StartMenu | Out-Null foreach ($folder in @($Desktop, $StartMenu)) { - $Lnk = $Wsh.CreateShortcut((Join-Path $folder "BroLink Host.lnk")) + Remove-Item -LiteralPath (Join-Path $folder "BroLink Host.lnk") -Force -ErrorAction SilentlyContinue + $Lnk = $Wsh.CreateShortcut((Join-Path $folder "Latch Host.lnk")) $Lnk.TargetPath = $DestExe $Lnk.WorkingDirectory = $DestDir $Lnk.Description = "Your PC, from your Mac" @@ -59,10 +70,16 @@ foreach ($folder in @($Desktop, $StartMenu)) { # The background service at logon; the app offers the same toggle. $Run = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -New-ItemProperty -Path $Run -Name "BroLinkHost" -Value "`"$DestExe`" --background" -PropertyType String -Force | Out-Null +Remove-ItemProperty -Path $Run -Name "BroLinkHost" -ErrorAction SilentlyContinue +New-ItemProperty -Path $Run -Name "LatchHost" -Value "`"$DestExe`" --background" -PropertyType String -Force | Out-Null + +if (Test-Path $OldDir) { + Remove-Item -LiteralPath $OldDir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $OldDir) { Write-Host "Could not remove $OldDir completely; it is safe to delete." } +} -Write-Host "Shortcut: Desktop\BroLink Host.lnk" -Write-Host "Open BroLink Host and click 'Set up this PC' once." +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) { Start-Process -FilePath $DestExe -WorkingDirectory $DestDir diff --git a/scripts/install-macos-release.sh b/scripts/install-macos-release.sh index be21274..9d167f5 100644 --- a/scripts/install-macos-release.sh +++ b/scripts/install-macos-release.sh @@ -7,17 +7,17 @@ # bash scripts/install-macos-release.sh # set -euo pipefail -REPO="${BROLINK_REPO:-MrBeldum/brolink}" -URL="https://github.com/$REPO/releases/latest/download/brolink-macos-arm64.tar.gz" +REPO="${LATCH_REPO:-MrBeldum/latch}" +URL="https://github.com/$REPO/releases/latest/download/latch-macos-arm64.tar.gz" API="https://api.github.com/repos/$REPO/releases/latest" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT echo "downloading $URL" -if ! curl -fsSL -o "$TMP/brolink-macos-arm64.tar.gz" "$URL"; then +if ! curl -fsSL -o "$TMP/latch-macos-arm64.tar.gz" "$URL"; then if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then echo "anonymous download failed; downloading with gh" - gh release download --repo "$REPO" --pattern brolink-macos-arm64.tar.gz --dir "$TMP" --clobber + gh release download --repo "$REPO" --pattern latch-macos-arm64.tar.gz --dir "$TMP" --clobber else echo "download failed; a private repository requires gh auth login" >&2 exit 1 @@ -31,7 +31,7 @@ if JSON=$(curl -fsSL -H "Accept: application/vnd.github+json" "$API" 2>/dev/null import json, sys rel = json.load(sys.stdin) for a in rel.get("assets") or []: - if a.get("name") == "brolink-macos-arm64.tar.gz": + if a.get("name") == "latch-macos-arm64.tar.gz": d = a.get("digest") or "" print(d[7:] if d.lower().startswith("sha256:") else d) break @@ -39,14 +39,14 @@ for a in rel.get("assets") or []: fi if [[ -z "$DIGEST" ]] && command -v gh >/dev/null 2>&1; then DIGEST=$(gh api "repos/$REPO/releases/latest" --jq ' - .assets[] | select(.name=="brolink-macos-arm64.tar.gz") | .digest // "" + .assets[] | select(.name=="latch-macos-arm64.tar.gz") | .digest // "" ' 2>/dev/null | sed 's/^[Ss][Hh][Aa]256://' || true) fi if [[ ! "$DIGEST" =~ ^[0-9a-fA-F]{64}$ ]]; then - echo "release published no SHA-256 for brolink-macos-arm64.tar.gz; refusing to install" >&2 + echo "release published no SHA-256 for latch-macos-arm64.tar.gz; refusing to install" >&2 exit 1 fi -GOT=$(shasum -a 256 "$TMP/brolink-macos-arm64.tar.gz" | awk '{print $1}') +GOT=$(shasum -a 256 "$TMP/latch-macos-arm64.tar.gz" | awk '{print $1}') lc() { printf '%s' "$1" | tr 'A-Z' 'a-z'; } if [[ "$(lc "$GOT")" != "$(lc "$DIGEST")" ]]; then echo "download does not match the published SHA-256" >&2 @@ -55,39 +55,46 @@ if [[ "$(lc "$GOT")" != "$(lc "$DIGEST")" ]]; then exit 1 fi -tar xzf "$TMP/brolink-macos-arm64.tar.gz" -C "$TMP" -APP="$TMP/BroLink.app" -if [[ ! -x "$APP/Contents/MacOS/BroLink" ]]; then - echo "the download holds no BroLink.app" >&2 +tar xzf "$TMP/latch-macos-arm64.tar.gz" -C "$TMP" +APP="$TMP/Latch.app" +if [[ ! -x "$APP/Contents/MacOS/Latch" ]]; then + echo "the download holds no Latch.app" >&2 exit 1 fi xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true codesign --verify --deep --strict "$APP" -DEST="/Applications/BroLink.app" -STAGE="/Applications/.BroLink-install-$$.app" +DEST="/Applications/Latch.app" +STAGE="/Applications/.Latch-install-$$.app" rm -rf "$STAGE" cp -R "$APP" "$STAGE" # Swap last: a failed copy must not delete the live app. if [[ -d "$DEST" ]]; then - PREV="/Applications/.BroLink-previous-$$.app" + PREV="/Applications/.Latch-previous-$$.app" rm -rf "$PREV" mv "$DEST" "$PREV" if ! mv "$STAGE" "$DEST"; then mv "$PREV" "$DEST" - echo "could not replace /Applications/BroLink.app; previous copy restored" >&2 + echo "could not replace /Applications/Latch.app; previous copy restored" >&2 exit 1 fi rm -rf "$PREV" else mv "$STAGE" "$DEST" fi +# An install from before 4.1 (the app was called BroLink): stop its +# background job, drop its login item and remove its app, so only Latch is +# left. The new app registers its own login item when it first runs. +OLD_AGENT="gui/$(id -u)/dev.brolink.node" +launchctl bootout "$OLD_AGENT" >/dev/null 2>&1 || true +rm -f "$HOME/Library/LaunchAgents/dev.brolink.node.plist" +rm -rf /Applications/BroLink.app # The background service keeps running the old binary until it restarts: # replacing the bundle does not stop it, and launchd restarts it only after # a failure. Restart it through launchd so it runs the new one now. -AGENT="gui/$(id -u)/dev.brolink.node" +AGENT="gui/$(id -u)/com.bardbro.latch.node" if launchctl print "$AGENT" >/dev/null 2>&1; then - launchctl kickstart -k "$AGENT" && echo "restarted the BroLink background service" + launchctl kickstart -k "$AGENT" && echo "restarted the Latch background service" fi echo "installed $DEST" open "$DEST" diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh index ccb3d0c..33f5560 100755 --- a/scripts/install-macos.sh +++ b/scripts/install-macos.sh @@ -1,32 +1,39 @@ #!/usr/bin/env bash -# Build and install the BroLink Mac app into /Applications. A locally built +# Build and install the Latch Mac app into /Applications. A locally built # app never carries the quarantine flag, so Gatekeeper leaves it alone. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" rustup target add aarch64-apple-darwin >/dev/null -cargo build --release -p brolink-host --target aarch64-apple-darwin +cargo build --release -p latch-host --target aarch64-apple-darwin bash "$ROOT/scripts/bundle-macos.sh" -APP="$ROOT/dist/BroLink.app" +APP="$ROOT/dist/Latch.app" if [[ ! -d "$APP" ]]; then echo "bundle failed" >&2 exit 1 fi xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true -rm -rf /Applications/BroLink.app -cp -R "$APP" /Applications/BroLink.app -# The staging bundle in dist/ would otherwise appear as a second BroLink +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. LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" "$LSREGISTER" -u "$APP" 2>/dev/null || true -"$LSREGISTER" -f /Applications/BroLink.app 2>/dev/null || true +"$LSREGISTER" -f /Applications/Latch.app 2>/dev/null || true rm -rf "$APP" +# An install from before 4.1 (the app was called BroLink): stop its +# background job, drop its login item and remove its app, so only Latch is +# left. The new app registers its own login item when it first runs. +OLD_AGENT="gui/$(id -u)/dev.brolink.node" +launchctl bootout "$OLD_AGENT" >/dev/null 2>&1 || true +rm -f "$HOME/Library/LaunchAgents/dev.brolink.node.plist" +rm -rf /Applications/BroLink.app # The background service keeps running the old binary until it restarts: # replacing the bundle does not stop it, and launchd restarts it only after # a failure. Restart it through launchd so it runs the new one now. -AGENT="gui/$(id -u)/dev.brolink.node" +AGENT="gui/$(id -u)/com.bardbro.latch.node" if launchctl print "$AGENT" >/dev/null 2>&1; then - launchctl kickstart -k "$AGENT" && echo "restarted the BroLink background service" + launchctl kickstart -k "$AGENT" && echo "restarted the Latch background service" fi -echo "installed /Applications/BroLink.app" +echo "installed /Applications/Latch.app" echo "open it; it lists every machine on your Tailscale account, and can share this Mac." diff --git a/scripts/make-icons.py b/scripts/make-icons.py new file mode 100644 index 0000000..8a640b5 --- /dev/null +++ b/scripts/make-icons.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Build the app icon from the logo's geometry. + +Writes crates/core/assets/logo-1024.png (the bitmap every window, the +Windows executable and the Mac bundle start from) and crates/client/Latch.icns. + +The mark is drawn from the same rectangles as docs/brand/logo.svg, on a +view box whose mark runs 14..86: a square with the lower right corner cut +away, and a smaller square in the cut. The icon is a full-bleed ink tile +with the mark knocked out in white; macOS applies the rounded app-icon +shape and Windows 11 rounds the square, so nothing here is pre-masked. The +blue is the logo's, lightened to read on ink. + + python3 scripts/make-icons.py +""" +import pathlib +import shutil +import subprocess +import tempfile + +from PIL import Image, ImageDraw + +ROOT = pathlib.Path(__file__).resolve().parent.parent +LOGO = ROOT / "crates/core/assets/logo-1024.png" +ICNS = ROOT / "crates/client/Latch.icns" + +INK = (0x1C, 0x1D, 0x1A) +KNOCKOUT = (0xFF, 0xFF, 0xFF) +BLUE = (0x4F, 0x8F, 0xE8) + +CANVAS = 1024 +# How much of the tile the mark's 72-unit span fills. +FILL = 0.60 +SUPERSAMPLE = 4 + + +def draw(size): + big = size * SUPERSAMPLE + im = Image.new("RGB", (big, big), INK) + d = ImageDraw.Draw(im) + unit = big * FILL / 72.0 + origin = (big - 72 * unit) / 2 - 14 * unit + + def rect(x0, y0, x1, y1, colour): + d.rectangle( + [ + round(origin + x0 * unit), + round(origin + y0 * unit), + round(origin + x1 * unit) - 1, + round(origin + y1 * unit) - 1, + ], + fill=colour, + ) + + rect(14, 14, 86, 54, KNOCKOUT) + rect(14, 54, 54, 86, KNOCKOUT) + rect(60, 60, 86, 86, BLUE) + return im.resize((size, size), Image.LANCZOS).convert("RGBA") + + +def main(): + canvas = draw(CANVAS) + canvas.save(LOGO, optimize=True) + print(f"wrote {LOGO}") + with tempfile.TemporaryDirectory() as tmp: + iconset = pathlib.Path(tmp) / "Latch.iconset" + iconset.mkdir() + for points in (16, 32, 128, 256, 512): + for scale in (1, 2): + px = points * scale + name = f"icon_{points}x{points}" + ("@2x" if scale == 2 else "") + ".png" + canvas.resize((px, px), Image.LANCZOS).save(iconset / name) + subprocess.run( + ["iconutil", "--convert", "icns", "--output", str(ICNS), str(iconset)], + check=True, + ) + shutil.copy(iconset / "icon_512x512@2x.png", pathlib.Path(tmp) / "preview.png") + print(f"wrote {ICNS}") + + +if __name__ == "__main__": + main() diff --git a/scripts/make-macos-icon.py b/scripts/make-macos-icon.py deleted file mode 100755 index 969e041..0000000 --- a/scripts/make-macos-icon.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 -"""Build crates/client/BroLink.icns from crates/core/assets/logo-1024.png. - -The logo is a rounded tile on a near-black pad. This crops the tile and -fills a 1024 canvas with it. macOS applies the rounded app-icon shape; -pre-masking the tile and leaving transparent corners made a square plate -on macOS 26. - - python3 scripts/make-macos-icon.py -""" -import pathlib -import shutil -import subprocess -import tempfile - -from PIL import Image - -ROOT = pathlib.Path(__file__).resolve().parent.parent -LOGO = ROOT / "crates/core/assets/logo-1024.png" -ICNS = ROOT / "crates/client/BroLink.icns" - -CANVAS = 1024 - - -def tile_bounds(im): - """The rounded tile inside the logo: where the pad colour ends on the - middle row and column.""" - px = im.load() - pad = px[0, 0] - mid = im.width // 2 - xs = [x for x in range(im.width) if px[x, mid] != pad] - ys = [y for y in range(im.height) if px[mid, y] != pad] - return xs[0], ys[0], xs[-1] + 1, ys[-1] + 1 - - -def main(): - logo = Image.open(LOGO).convert("RGBA") - # Opaque fill: the Dock's shape comes from the system, not from alpha. - canvas = ( - logo.crop(tile_bounds(logo)) - .resize((CANVAS, CANVAS), Image.LANCZOS) - .convert("RGB") - .convert("RGBA") - ) - - with tempfile.TemporaryDirectory() as tmp: - iconset = pathlib.Path(tmp) / "BroLink.iconset" - iconset.mkdir() - for points in (16, 32, 128, 256, 512): - for scale in (1, 2): - px = points * scale - name = f"icon_{points}x{points}" + ("@2x" if scale == 2 else "") + ".png" - canvas.resize((px, px), Image.LANCZOS).save(iconset / name) - subprocess.run( - ["iconutil", "--convert", "icns", "--output", str(ICNS), str(iconset)], - check=True, - ) - shutil.copy(iconset / "icon_512x512@2x.png", pathlib.Path(tmp) / "preview.png") - print(f"wrote {ICNS}") - - -if __name__ == "__main__": - main() diff --git a/third_party/moonlight-common-c/VERSION b/third_party/moonlight-common-c/VERSION index f8cf32b..9476256 100644 --- a/third_party/moonlight-common-c/VERSION +++ b/third_party/moonlight-common-c/VERSION @@ -1,6 +1,6 @@ 874ac9548f1bd6f095ef2b435c42cdde460e7821 2026-08-18 -moonlight-common-c: the GameStream protocol engine BroLink's viewer links +moonlight-common-c: the GameStream protocol engine Latch's viewer links (RTSP handshake, control and input channels, RTP video/audio reassembly with FEC). crates/stream/build.rs compiles the files listed in its SOURCES array and nothing else. GPL-3.0-or-later (LICENSE.txt); ENet and nanors keep their @@ -21,7 +21,7 @@ Trimmed (not present here) - src/PlatformCrypto.c: crates/stream/src/crypto.rs implements the same Plt* functions in Rust, so no OpenSSL or mbedTLS is linked. - src/ConnectionTester.c (LiTestClientConnectivity) and src/SimpleStun.c - (LiFindExternalAddressIP4): public helpers BroLink never calls. + (LiFindExternalAddressIP4): public helpers Latch never calls. Limelight.h still declares them; nothing may call them. - src/RecorderCallbacks.c: only used when LC_DEBUG_RECORD_MODE is defined. - enet/compress.c: ENet's range coder; moonlight never enables compression. @@ -30,13 +30,13 @@ Trimmed (not present here) Local patches 1. src/ControlStream.c: enet_peer_timeout(peer, 2, 10000, 10000) becomes - enet_peer_timeout(peer, 3, 15000, 30000). BroLink streams often cross + enet_peer_timeout(peer, 3, 15000, 30000). Latch streams often cross a Tailscale peer relay on a long path, where a burst of loss silences the reliable control channel for seconds; upstream's 10 s limit then declared the PC dead and ended long sessions with "Connection lost (code -1)". A dead PC is still noticed within 30 s. 2. src/SdpGenerator.c: the video bitrate latch is capped at 150000 kbps - instead of GFE's 100000. Sunshine has no 100 Mbps limit and BroLink's + instead of GFE's 100000. Sunshine has no 100 Mbps limit and Latch's bitrate setting goes to 150 Mbps. Re-vendoring @@ -45,4 +45,4 @@ Re-vendoring oblas_lite}.[ch], nanors/deps/obl/gf2_8_*.h and nanors/LICENSE, drop the files listed above, re-apply both patches, update SOURCES in build.rs if upstream added or removed files, and update the commits here. Then run - `cargo test -p brolink-stream` and a live stream_real test. + `cargo test -p latch-stream` and a live stream_real test. diff --git a/third_party/moonlight-common-c/src/ControlStream.c b/third_party/moonlight-common-c/src/ControlStream.c index 1866916..9aad364 100644 --- a/third_party/moonlight-common-c/src/ControlStream.c +++ b/third_party/moonlight-common-c/src/ControlStream.c @@ -1833,8 +1833,8 @@ int startControlStream(void) { // The 3DS can take a bit longer to set up when starting fresh enet_peer_timeout(peer, 2, 60000, 60000); #else - // BroLink change (was: enet_peer_timeout(peer, 2, 10000, 10000)). - // Every BroLink stream is relayed over a Tailscale peer relay across a + // Latch change (was: enet_peer_timeout(peer, 2, 10000, 10000)). + // Every Latch stream is relayed over a Tailscale peer relay across a // long, sometimes trans-Pacific path where a burst of loss can silence // the reliable control channel (ENet, UDP 47999) for several seconds. // Upstream's 10 s peer timeout then declares the peer dead and the diff --git a/third_party/moonlight-common-c/src/SdpGenerator.c b/third_party/moonlight-common-c/src/SdpGenerator.c index 7546025..42ccba2 100644 --- a/third_party/moonlight-common-c/src/SdpGenerator.c +++ b/third_party/moonlight-common-c/src/SdpGenerator.c @@ -349,7 +349,7 @@ static PSDP_OPTION getAttributesList(char*urlSafeAddr) { } } - // GFE capped video at 100 Mbps. Sunshine does not, and BroLink's slider + // GFE capped video at 100 Mbps. Sunshine does not, and Latch's slider // goes to 150 Mbps, so latch as high as the user asked (still bounded). adjustedBitrate = adjustedBitrate > 150000 ? 150000 : adjustedBitrate;