diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb2561d..2ca257d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,6 +131,9 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm test + # What the release workflow writes the update feeds with, on throwaway keys. + - name: Test the updater scripts + run: ../../scripts/test-updater-scripts.sh # The Rust build embeds dist/. - run: pnpm build - name: Rust diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eeb4a4a..51bf7d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,13 @@ name: Release # ad hoc neither # Windows: Authenticode with WINDOWS_CERTIFICATE_PFX and WINDOWS_CERTIFICATE_PASSWORD, # unsigned without them. +# +# Updates: every package is also signed with the updater's keys, and the release +# carries latest.json (Linux, Windows) and appcast.xml (macOS), which installed +# apps check. These need, and the release fails without: +# secrets TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD, SPARKLE_PRIVATE_KEY +# variables TAURI_UPDATER_PUBLIC_KEY, SPARKLE_PUBLIC_KEY +# scripts/create-updater-keys.sh makes them; see RELEASING.md. on: push: @@ -29,7 +36,27 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} + release_url: ${{ steps.version.outputs.release_url }} steps: + # A release without signed updates would leave every user of it on that + # version for good, so none is built without the keys. + - name: Check the updater keys + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} + run: | + missing=() + for name in TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD SPARKLE_PRIVATE_KEY \ + TAURI_UPDATER_PUBLIC_KEY SPARKLE_PUBLIC_KEY; do + [[ -n ${!name} ]] || missing+=("$name") + done + if (( ${#missing[@]} )); then + echo "::error::Missing the updater keys: ${missing[*]}. Create them once with scripts/create-updater-keys.sh, see RELEASING.md (Updates)." + exit 1 + fi - id: version run: | version=${GITHUB_REF_NAME#v} @@ -38,6 +65,7 @@ jobs: exit 1 fi echo "version=$version" >> "$GITHUB_OUTPUT" + echo "release_url=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" macos: name: Build the macOS DMG @@ -53,6 +81,12 @@ jobs: NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} SIGNING_CERTIFICATE_P12: ${{ secrets.SIGNING_CERTIFICATE_P12 }} SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} + # Sparkle's command-line tools, for signing the update; the app gets the + # same version through Swift Package Manager. + SPARKLE_VERSION: 2.10.1 + SPARKLE_SHA256: 1a856d2a9edb8e628de88121d7be04cf0e2167f8641650b78fca00b14244cf27 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.90 @@ -110,6 +144,7 @@ jobs: env: SIGNING_IDENTITY: ${{ steps.identity.outputs.identity || '-' }} DEVELOPER_ID: ${{ steps.signing.outputs.mode == 'developer-id' && '1' || '' }} + FEED_URL: ${{ github.server_url }}/${{ github.repository }}/releases/latest/download/appcast.xml run: | dmg=$(scripts/build-release.sh "${{ needs.version.outputs.version }}" "$GITHUB_RUN_NUMBER" | tail -1) echo "dmg=$dmg" >> "$GITHUB_OUTPUT" @@ -140,12 +175,39 @@ jobs: DMG: ${{ steps.build.outputs.dmg }} run: cd "$(dirname "$DMG")" && shasum -a 256 "$(basename "$DMG")" > "$(basename "$DMG").sha256" + # After notarization, which staples a ticket into the DMG. + - name: Sign the update and write the appcast + id: appcast + env: + DMG: ${{ steps.build.outputs.dmg }} + VERSION: ${{ needs.version.outputs.version }} + RELEASE_URL: ${{ needs.version.outputs.release_url }} + run: | + tools=$RUNNER_TEMP/sparkle + mkdir -p "$tools" + curl -fsSL -o "$tools/sparkle.tar.xz" \ + "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" + echo "$SPARKLE_SHA256 $tools/sparkle.tar.xz" | shasum -a 256 -c - + tar -xJf "$tools/sparkle.tar.xz" -C "$tools" ./bin/sign_update + derived=$(printf '%s' "$SPARKLE_PRIVATE_KEY" | scripts/sparkle-public-key.sh) + if [[ $derived != "$SPARKLE_PUBLIC_KEY" ]]; then + echo "::error::SPARKLE_PRIVATE_KEY isn't the private half of SPARKLE_PUBLIC_KEY: installed apps would reject this update." + exit 1 + fi + signature=$(printf '%s' "$SPARKLE_PRIVATE_KEY" | "$tools/bin/sign_update" --ed-key-file - -p "$DMG") + appcast=$(dirname "$DMG")/appcast.xml + scripts/appcast.sh "$DMG" "$VERSION" "$GITHUB_RUN_NUMBER" "$signature" "$RELEASE_URL" > "$appcast" + printf '%s' "$SPARKLE_PRIVATE_KEY" | "$tools/bin/sign_update" --ed-key-file - "$appcast" + cat "$appcast" + echo "path=$appcast" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 with: name: macos path: | ${{ steps.build.outputs.dmg }} ${{ steps.build.outputs.dmg }}.sha256 + ${{ steps.appcast.outputs.path }} if-no-files-found: error - name: Remove the signing keychain @@ -179,19 +241,42 @@ jobs: cache-dependency-path: apps/desktop/pnpm-lock.yaml - run: pnpm install --frozen-lockfile + # Signed for the updater too (each package gets a .sig), with the public + # key and the feed of this repository built in. - name: Build the .deb, .rpm and AppImage env: VERSION: ${{ needs.version.outputs.version }} - run: pnpm tauri build --bundles deb,rpm,appimage --config "{\"version\":\"$VERSION\"}" + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + FEED_URL: ${{ github.server_url }}/${{ github.repository }}/releases/latest/download/latest.json + run: | + config=$(jq -nc --arg version "$VERSION" --arg key "$PUBLIC_KEY" --arg feed "$FEED_URL" ' + {version: $version, bundle: {createUpdaterArtifacts: true}, + plugins: {updater: {pubkey: $key, endpoints: [$feed]}}}') + pnpm tauri build --bundles deb,rpm,appimage --config "$config" - name: Collect and checksum + env: + VERSION: ${{ needs.version.outputs.version }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | - mkdir -p "$RUNNER_TEMP/linux" + mkdir -p "$RUNNER_TEMP/linux" "$RUNNER_TEMP/signatures" cp src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/rpm/*.rpm \ src-tauri/target/release/bundle/appimage/*.AppImage "$RUNNER_TEMP/linux/" + for package in "$RUNNER_TEMP"/linux/*; do + built=$(find src-tauri/target/release/bundle -name "$(basename "$package").sig" | head -1) + # The bundler signs what the updater installs; the .deb and .rpm are signed here if it didn't. + if [[ -z $built ]]; then + pnpm tauri signer sign --app-version "$VERSION" "$package" + built=$package.sig + fi + mv "$built" "$RUNNER_TEMP/signatures/$(basename "$package").sig" + done cd "$RUNNER_TEMP/linux" for file in *; do sha256sum "$file" > "$file.sha256"; done - ls -l + ls -l . "$RUNNER_TEMP/signatures" - uses: actions/upload-artifact@v4 with: @@ -199,6 +284,12 @@ jobs: path: ${{ runner.temp }}/linux/* if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: signatures-linux + path: ${{ runner.temp }}/signatures/* + if-no-files-found: error + localhostd: name: Build localhostd for WSL needs: version @@ -276,13 +367,20 @@ jobs: if (-not $certificate.HasPrivateKey) { Write-Error "The certificate secret has no private key"; exit 1 } "thumbprint=$($certificate.Thumbprint)" >> $env:GITHUB_OUTPUT + # Signed for the updater too (a .sig beside the installer), with the + # public key and the feed of this repository built in. - name: Build the installer env: VERSION: ${{ needs.version.outputs.version }} THUMBPRINT: ${{ steps.certificate.outputs.thumbprint }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + FEED_URL: ${{ github.server_url }}/${{ github.repository }}/releases/latest/download/latest.json run: | - config=$(jq -nc --arg version "$VERSION" --arg thumbprint "$THUMBPRINT" ' - {version: $version, bundle: {resources: {"wsl/localhostd": "localhostd"}}} + config=$(jq -nc --arg version "$VERSION" --arg thumbprint "$THUMBPRINT" --arg key "$PUBLIC_KEY" --arg feed "$FEED_URL" ' + {version: $version, bundle: {resources: {"wsl/localhostd": "localhostd"}, createUpdaterArtifacts: true}, + plugins: {updater: {pubkey: $key, endpoints: [$feed]}}} * if $thumbprint == "" then {} else {bundle: {windows: {certificateThumbprint: $thumbprint}}} end') pnpm tauri build --bundles nsis --config "$config" @@ -298,11 +396,12 @@ jobs: - name: Collect and checksum run: | - mkdir -p "$RUNNER_TEMP/windows" + mkdir -p "$RUNNER_TEMP/windows" "$RUNNER_TEMP/signatures" cp src-tauri/target/release/bundle/nsis/*-setup.exe "$RUNNER_TEMP/windows/" + cp src-tauri/target/release/bundle/nsis/*-setup.exe.sig "$RUNNER_TEMP/signatures/" cd "$RUNNER_TEMP/windows" for file in *; do sha256sum "$file" > "$file.sha256"; done - ls -l + ls -l . "$RUNNER_TEMP/signatures" - uses: actions/upload-artifact@v4 with: @@ -310,6 +409,12 @@ jobs: path: ${{ runner.temp }}/windows/* if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: signatures-windows + path: ${{ runner.temp }}/signatures/* + if-no-files-found: error + - name: Remove the signing certificate if: always() && steps.certificate.outcome == 'success' shell: pwsh @@ -320,11 +425,29 @@ jobs: needs: [version, macos, linux, windows] runs-on: ubuntu-latest steps: + # For scripts/updater-manifest.sh. + - uses: actions/checkout@v4 + with: + sparse-checkout: scripts + - uses: actions/download-artifact@v4 with: path: assets merge-multiple: true + # The packages' signatures go into latest.json, not the release. + - name: Write latest.json + env: + VERSION: ${{ needs.version.outputs.version }} + RELEASE_URL: ${{ needs.version.outputs.release_url }} + PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + run: | + scripts/updater-manifest.sh assets "$VERSION" "$RELEASE_URL" "$PUBLIC_KEY" > latest.json + rm assets/*.sig + mv latest.json assets/ + cat assets/latest.json + ls -l assets + - name: Draft the GitHub Release env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index 448dc6b..893f2fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ These are product rules, not implementation details. A change that breaks one ne **Sessions** - Closing the window keeps the app and its Services running; the menu bar or tray shows them. Quitting asks first when anything is active, then stops everything in parallel, whoever started it. - The apps and `localhost mcp` drive one shared core, so they see the same Runs, on Windows too. When a client disconnects, the core stops the Runs nobody needs any more: none while an app is connected, else those no remaining client started (with their Dependencies). It exits once no client has been connected for 10 s. +- Updates are offered, never installed unasked: a check at launch and every few hours (off in Settings), a notice, then Update Now and Restart to Update. Restarting to update stops every Service first, like quitting, after listing them, and retires the shared core so the new version starts its own. A `.deb` or `.rpm` only links to the release. Builds without the updater's public key never check. - Each active Run is recorded on disk (pid, process group, start time). At the next launch, survivors are offered as Orphans: Stop them (default) or Leave them. **Detection** @@ -201,6 +202,7 @@ The hard-to-reverse choices, and why. Changing one is an issue and a discussion - **Pipes, not a PTY.** Keeping stderr distinct matters for failures and, later, for agents. The colour hints make up for the missing terminal. - **`$PORT`, and moves scoped to one Run.** It is the existing convention, needs no parser and works the same from any client. A moved port never rewrites the configuration. - **Run stats sampled on request.** Nothing is sampled unless a stats popover is open (every 1.5 s). The tree includes descendants that left the session. Memory is each platform's honest number rather than RSS, which counts forked workers once each. +- **Updates through the platform's updater, stopping Services first.** Sparkle on macOS, Tauri's updater on Linux and Windows, both behind localhost's own notice, both verifying a signature from the release workflow's keys; the feeds are files on GitHub Releases, so there is no update server. An update replaces the executable the shared core runs from (on Windows the installer closes it, and its Job Objects with it), and the new app would otherwise reach an old core, so Runs are stopped, after asking, rather than kept across the update. - **Not sandboxed; no Flatpak or Snap.** Sandboxes can't run arbitrary host commands or signal process groups. ## Design @@ -232,7 +234,7 @@ The log must stay smooth with very chatty Services. Lines never cross to a UI on ## Writing code - **Match the surrounding code**: its naming, idioms and comment density. Doc comments say *why*, not *what*; don't restate a name, narrate history or cite issue numbers. -- **Few dependencies.** Each crate is justified on its own; the macOS app adds no Swift packages. Adding one needs an issue first. Beyond `serde_json`, manifests (TOML, YAML) and Project files are read and written by small built-in readers. +- **Few dependencies.** Each crate is justified on its own; the macOS app adds no Swift packages besides Sparkle, for updates. Adding one needs an issue first. Beyond `serde_json`, manifests (TOML, YAML) and Project files are read and written by small built-in readers. - **Rust**: `cargo fmt`, and `cargo clippy --all-targets -- -D warnings` clean, also for `--target x86_64-pc-windows-msvc`. - **Swift**: Swift 6 language mode, strict concurrency. No `@unchecked Sendable` without a comment saying why it is safe. - **TypeScript**: strict; `pnpm typecheck` and `pnpm test` clean. Never edit `src/bindings.ts`. diff --git a/README.md b/README.md index a26efe3..3a944d8 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,17 @@ Download the latest release from [GitHub Releases](https://github.com/marcelloss Releases aren't notarized or code-signed by a known publisher yet, so the first launch needs one extra step on macOS and Windows; see [First launch](#first-launch). There are no Flatpak or Snap packages: their sandboxes can't run your project's commands. +### Updates + +localhost checks GitHub Releases for a new version at launch and every few hours. When there is one, a notice at the bottom of the sidebar says “localhost X.Y.Z is available”, with its release notes: + +- **Update Now** downloads it in the background, and **Restart to Update** installs it and opens the new version. **Later** hides the notice until the next version. On macOS, an update that has downloaded also installs when you quit localhost. +- **Restarting stops every running Service first**, as quitting does, after listing them and asking. Start them again once the new version opens. +- Each update is signed, and localhost checks the signature before installing it. +- **`.deb` and `.rpm`**: your package manager installs updates, so the notice's **Download** opens the release instead. The AppImage and the Windows installer update themselves; on macOS, so does the app. + +Turn automatic checks off in **Settings → General**; **Check for Updates…** (in the localhost menu on macOS, in Settings or the command palette) checks at any time. Checking is the only thing localhost asks the network for, and builds you make yourself never check. + ## Getting started Shortcuts are written for macOS; on Linux and Windows use Ctrl for ⌘ and Alt for ⌥ (see [Keyboard shortcuts](#keyboard-shortcuts)). diff --git a/RELEASING.md b/RELEASING.md index 6c213f1..46284d5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,7 +2,7 @@ Releases are on GitHub Releases: a DMG for macOS; for Linux, a `.deb`, an `.rpm` and an AppImage; and an installer for Windows. Pushing a `v*` tag runs the [Release workflow](.github/workflows/release.yml). It builds and signs the macOS app into `localhost-.dmg`, and builds the Linux app (`apps/desktop/`) on Ubuntu 22.04 into `localhost-app__amd64.deb`, `localhost-app--1.x86_64.rpm` and `localhost-app__amd64.AppImage`, unsigned. For Windows it builds `localhostd` static for Linux (`x86_64-unknown-linux-musl`), then the same app on Windows into the per-user NSIS installer `localhost__x64-setup.exe`, with `localhostd` installed beside `localhost-app.exe` for the WSL distros. The installer is signed when a certificate exists (below). Then it drafts one GitHub Release with all of them, a SHA-256 for each, and notes generated from the merged PRs. Nothing is public until a maintainer publishes the draft. -There is no in-app update check: users download new versions from Releases. +The release also carries the update feeds that installed apps check: `latest.json` for the Linux and Windows app and `appcast.xml` for the macOS app, both naming this release's packages, each signed with the updater keys (see [Updates](#updates)). Apps read them from `releases/latest/download/`, so an update reaches users only once its release is published, and a draft or pre-release never. ## Versions @@ -23,7 +23,7 @@ Tags are `vMAJOR.MINOR.PATCH`, nothing else; the workflow rejects any other `v*` 3. Wait for the **Release** workflow (Actions tab). It ends with a draft release named "localhost 0.3.0". 4. Open the draft. Edit the notes if needed (the first line says how the build is signed and what the first launch needs). Download the DMG and check that it opens, installs and launches, following the README's first-launch steps. Install the `.deb` on Ubuntu and the `.rpm` on Fedora (or run the AppImage) and check that the app launches and its tray icon appears. On Windows 11 with Ubuntu in WSL 2, run the installer and check that a Project in the distro starts, opens in the browser, and stops when localhost quits. -5. Click **Publish release**. +5. Click **Publish release**. Installed apps see the update at their next check (at launch, or within six hours). Check that one does: open the previous version, use **Check for Updates…**, and install the update with a Service running, which should be listed and stopped first. A fix is a normal change: branch → PR → merge → a patch tag (`v0.3.1`). @@ -97,6 +97,50 @@ gh secret set WINDOWS_CERTIFICATE_PASSWORD Use a code signing certificate from a certificate authority: SmartScreen gives a self-signed one no trust, so it does nothing for users. An OV certificate still builds reputation with each download; EV certificates usually live on hardware tokens, which this workflow can't use. +## Updates + +Each release is signed for the updaters with two keypairs, separate from code signing: one for the Linux and Windows app (Tauri's updater, minisign) and one for the macOS app (Sparkle, EdDSA). Every installed app trusts only the public keys it was built with, so these are created **once and kept**: a lost private key, or a new pair, means every user must download the next version by hand. + +The workflow fails at its first step, before building anything, unless all five exist: + +| | Name | What | +|---|---|---| +| secret | `TAURI_SIGNING_PRIVATE_KEY` | the Tauri private key (the key file's content) | +| secret | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | its password | +| secret | `SPARKLE_PRIVATE_KEY` | the Sparkle private key (base64) | +| variable | `TAURI_UPDATER_PUBLIC_KEY` | the Tauri public key, built into the Linux and Windows app | +| variable | `SPARKLE_PUBLIC_KEY` | the Sparkle public key, built into the macOS app as `SUPublicEDKey` | + +Failing is deliberate: a release built without them would install fine but could never update itself, stranding its users on that version. The workflow also checks that each private key is the pair of its public key before anything is published. + +### Creating the keys (one-time setup) + +From a clone of the repository that publishes releases, on a Mac with `gh` logged in, `pnpm install` done in `apps/desktop`, and OpenSSL 3 (`brew install openssl@3`): + +```sh +scripts/create-updater-keys.sh ~/localhost-updater-keys --upload +``` + +It creates both keypairs in a new folder, prints the public keys, and stores the three secrets and two variables above in `marcellosso/localhost` (set `LOCALHOST_REPO=owner/name` for another repository). If the keys already exist, store them again without making new ones: `scripts/create-updater-keys.sh ~/localhost-updater-keys --upload-only`. The Sparkle key is made with OpenSSL, so nothing is added to your keychain. Without `--upload` it only creates the files; to store them by hand: + +```sh +gh secret set TAURI_SIGNING_PRIVATE_KEY < ~/localhost-updater-keys/tauri-updater.key +gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD < ~/localhost-updater-keys/tauri-updater.password +gh secret set SPARKLE_PRIVATE_KEY < ~/localhost-updater-keys/sparkle-private-key +gh variable set TAURI_UPDATER_PUBLIC_KEY < ~/localhost-updater-keys/tauri-updater.key.pub +gh variable set SPARKLE_PUBLIC_KEY < ~/localhost-updater-keys/sparkle-public-key +``` + +**Back up the folder** (a password manager or an encrypted disk, never the repository). When the repository moves (#40), store the same keys as the new repository's secrets and variables; the feeds' addresses follow the repository the release is built in. + +### How it fits together + +- **Linux and Windows**: `tauri build` gets `createUpdaterArtifacts`, the public key and this repository's `latest.json` address through `--config`, and signs the AppImage and the installer (`.sig` beside each); the workflow signs the `.deb` and `.rpm` if the bundler didn't. `scripts/updater-manifest.sh` writes `latest.json` from those signatures, checking each was made by the public key's pair. The `.sig` files go into `latest.json`, not the release. Signatures are bound to the version (`requireSignedVersion`), so a tampered `latest.json` can't pair a new version number with an older package. +- **macOS**: `scripts/build-release.sh` puts `SPARKLE_PUBLIC_KEY` (and the appcast's address, `FEED_URL`) into the app's Info.plist before signing, and signs Sparkle's helpers inside the app. After notarization, the workflow downloads Sparkle's tools (pinned by version and SHA-256), checks the private key against the public key (`scripts/sparkle-public-key.sh`), signs the DMG, writes `appcast.xml` (`scripts/appcast.sh`) and signs the appcast too: the app requires a signed feed. Sparkle compares the build number (the workflow's run number) to decide what is newer, and shows the version. +- `scripts/test-updater-scripts.sh` checks these scripts with throwaway keys; CI runs it. + +Local builds have no public key, so they never check for updates. + ## Building a release locally `scripts/build-release.sh` is what the workflow runs: diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 218e975..66e6f7c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -567,6 +567,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -590,7 +600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -603,7 +613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -858,7 +868,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1029,7 +1039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1077,6 +1087,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1335,6 +1355,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1637,6 +1668,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1656,9 +1702,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1952,6 +2000,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2125,6 +2203,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-single-instance", + "tauri-plugin-updater", "tauri-specta", "tokio", "webview2-com", @@ -2178,6 +2257,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2227,7 +2312,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2506,6 +2591,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2569,6 +2666,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + [[package]] name = "option-ext" version = "0.2.0" @@ -2585,6 +2688,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "pango" version = "0.18.3" @@ -2996,15 +3113,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3040,6 +3162,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3065,7 +3201,80 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -3083,6 +3292,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3166,7 +3384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3425,6 +3643,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -3450,7 +3684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3582,6 +3816,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.9" @@ -3645,6 +3885,27 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3666,7 +3927,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3676,7 +3937,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3709,6 +3970,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3732,7 +4004,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3903,6 +4175,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" +dependencies = [ + "base64 0.23.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -3913,7 +4218,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3936,7 +4241,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4041,7 +4346,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4166,6 +4471,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.20" @@ -4370,7 +4685,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4399,7 +4714,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4423,6 +4738,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4677,6 +4998,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" @@ -4733,7 +5063,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4754,7 +5084,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.61.2", + "windows-sys 0.60.2", "windows-version", ] @@ -4841,6 +5171,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4868,6 +5209,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -4901,6 +5251,22 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -4911,7 +5277,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", @@ -4942,6 +5308,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" @@ -4954,6 +5326,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" @@ -4966,12 +5344,24 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" @@ -4984,6 +5374,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" @@ -4996,6 +5392,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" @@ -5008,6 +5410,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" @@ -5020,6 +5428,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -5051,7 +5465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5084,7 +5498,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5131,6 +5545,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5245,6 +5669,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1c7173c..9d68d88 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ specta-typescript = "=0.0.9" tauri = { version = "2.12", features = ["tray-icon"] } tauri-plugin-dialog = "2.8" tauri-plugin-single-instance = "2" +tauri-plugin-updater = { version = "2.14", default-features = false, features = ["rustls-tls", "system-proxy"] } tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } tokio = { version = "1", features = ["sync", "macros", "time", "process", "rt"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 00212fe..b915a12 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod services; mod settings; mod targets; mod tray; +mod updates; mod wsl; pub use targets::DAEMON_ARGUMENT; @@ -63,8 +64,8 @@ impl Core { } /// What the commands share: the core, or why it couldn't be opened (the UI -/// says so), the open log streams, the launch questions, the settings and -/// the app's lifetime. +/// says so), the open log streams, the launch questions, the settings, the +/// update on offer and the app's lifetime. pub struct AppState { core: Result, logs: logs::LogStreams, @@ -72,6 +73,7 @@ pub struct AppState { lifecycle: lifecycle::Lifecycle, launch: Mutex, settings: settings::SettingsStore, + updates: updates::Updates, perf: Option, } @@ -150,8 +152,13 @@ fn specta_builder() -> tauri_specta::Builder { perf::finish_perf_run, lifecycle::quit_app, lifecycle::take_reveal, + updates::update_state, + updates::check_for_updates, + updates::put_off_update, + updates::download_update, + updates::install_update, ]) - .events(collect_events![lifecycle::Reveal, launch::OrphansFound]) + .events(collect_events![lifecycle::Reveal, launch::OrphansFound, updates::UpdateState]) } pub fn run() { @@ -161,6 +168,7 @@ pub fn run() { // First, so a second launch only brings the window forward. .plugin(tauri_plugin_single_instance::init(|app, _, _| lifecycle::show_window(app, None))) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(builder.invoke_handler()) .setup(move |app| { builder.mount_events(app); @@ -177,6 +185,7 @@ pub fn run() { Ok(core) if perf.is_none() => core.targets.orphans(), _ => Vec::new(), }; + let is_measuring = perf.is_some(); let settings = settings::SettingsStore::open( app.path().app_config_dir().ok().map(|folder| folder.join("settings.json")), ); @@ -190,6 +199,7 @@ pub fn run() { lifecycle: lifecycle::Lifecycle::default(), launch: launch::Launch::new(orphans), settings, + updates: updates::Updates::default(), perf, }); if let Some(window) = app.get_webview_window(lifecycle::MAIN_WINDOW) { @@ -197,6 +207,9 @@ pub fn run() { } tray::install(app)?; launch::watch_found_orphans(app.handle()); + if !is_measuring { + updates::watch(app.handle()); + } Ok(()) }) .on_window_event(|window, event| match event { diff --git a/apps/desktop/src-tauri/src/lifecycle.rs b/apps/desktop/src-tauri/src/lifecycle.rs index 6d3c66b..8b90351 100644 --- a/apps/desktop/src-tauri/src/lifecycle.rs +++ b/apps/desktop/src-tauri/src/lifecycle.rs @@ -227,6 +227,12 @@ pub fn exit_now(app: &AppHandle) { app.exit(0); } +/// Lets the next exit through without asking, e.g. the restart that +/// installs an update, once it has stopped every Run itself. +pub fn allow_exit(app: &AppHandle) { + app.state::().lifecycle.is_exiting.store(true, Ordering::SeqCst); +} + /// Whether an exit Tauri is about to make may go ahead. `code` is `None` /// when the last window closed, which keeps the app running; an /// `AppHandle::exit` that didn't come through `quit` asks first. diff --git a/apps/desktop/src-tauri/src/settings.rs b/apps/desktop/src-tauri/src/settings.rs index af1abd5..c455f28 100644 --- a/apps/desktop/src-tauri/src/settings.rs +++ b/apps/desktop/src-tauri/src/settings.rs @@ -24,6 +24,8 @@ pub struct Settings { pub appearance: Appearance, /// Points, for the log and the commands in Service rows. pub log_font_size: u8, + /// Whether localhost checks for a new version at launch and every few hours. + pub automatic_update_checks: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -41,7 +43,13 @@ pub const LOG_FONT_SIZES: [u8; 6] = [10, 11, 12, 13, 14, 16]; impl Default for Settings { fn default() -> Settings { - Settings { editor_command: None, automatic_port_move: true, appearance: Appearance::System, log_font_size: 12 } + Settings { + editor_command: None, + automatic_port_move: true, + appearance: Appearance::System, + log_font_size: 12, + automatic_update_checks: true, + } } } @@ -155,6 +163,7 @@ mod tests { automatic_port_move: false, appearance: Appearance::Dark, log_font_size: 14, + automatic_update_checks: false, }; store.save(changed.clone()).unwrap(); assert_eq!(SettingsStore::open(Some(path)).get(), changed); diff --git a/apps/desktop/src-tauri/src/updates.rs b/apps/desktop/src-tauri/src/updates.rs new file mode 100644 index 0000000..f5ab92d --- /dev/null +++ b/apps/desktop/src-tauri/src/updates.rs @@ -0,0 +1,524 @@ +//! Updates from GitHub Releases. The release workflow publishes +//! `latest.json` with every release, each package signed with the +//! updater key; the app checks it at launch and every few hours (unless +//! turned off in Settings), and on Check for Updates. A newer version shows +//! a notice; Update Now downloads it in the background and verifies it +//! against the public key built into the app; Restart to Update installs it. +//! +//! The AppImage and the Windows installer update in place. A `.deb` or an +//! `.rpm` belongs to the package manager, so its notice links to the +//! release instead. A build without the public key (any local build) +//! doesn't check at all. +//! +//! Installing restarts the app, so, like quitting, it stops every Service +//! first, after saying which ones are running. Then it retires the shared +//! core, so the new version starts a core of its own instead of reaching +//! the old one; on Windows the installer would close that core anyway, as +//! it runs from the app's executable. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime}; + +use localhost_core::{Id, Origin, Project, ServiceStatus}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::utils::config::BundleType; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tauri_specta::Event; + +use crate::{AppState, lifecycle}; + +/// How long after launch the first automatic check waits, so it doesn't +/// compete with the window opening. +const FIRST_CHECK_DELAY: Duration = Duration::from_secs(10); +/// How often automatic checks run. +const CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +/// How often the automatic checker wakes to see whether a check is due. +/// Wall-clock time, so a check missed while the computer slept runs soon +/// after it wakes. +const CHECKER_TICK: Duration = Duration::from_secs(15 * 60); +/// How often download progress reaches the window, at most. +const PROGRESS_INTERVAL: Duration = Duration::from_millis(250); + +/// Where a release's notes are when `latest.json` doesn't say. +const RELEASES: &str = "https://github.com/marcellosso/localhost/releases"; + +/// What the update notice shows; also the event sent when it changes. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type, Event)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UpdateState { + /// No newer version known, or the notice was put off with Later. + #[default] + None, + /// A newer version. `can_install` is false where a package manager + /// installs it (`.deb`, `.rpm`); `problem` says why a download failed. + #[serde(rename_all = "camelCase")] + Available { version: String, notes_url: String, can_install: bool, problem: Option }, + /// `progress` is from 0 to 1, `None` while the size isn't known. + #[serde(rename_all = "camelCase")] + Downloading { version: String, notes_url: String, progress: Option }, + /// Downloaded and verified: restarting installs it. + #[serde(rename_all = "camelCase")] + Ready { version: String, notes_url: String }, +} + +/// What Check for Updates found. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UpdateCheck { + /// The notice shows it. + Available { + version: String, + }, + UpToDate { + version: String, + }, + /// The check couldn't be made, or this build doesn't update itself. + Failed { + message: String, + }, +} + +#[derive(Default)] +pub struct Updates { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + state: UpdateState, + /// The update the notice offers, and its package once downloaded. + update: Option, + package: Option>, + /// The version Later put off; automatic checks don't offer it again. + put_off: Option, + last_check: Option, + is_checking: bool, + is_installing: bool, +} + +impl Updates { + fn inner(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn state(&self) -> UpdateState { + self.inner().state.clone() + } +} + +/// Whether this build can check for updates: only the release workflow +/// builds it with the updater's public key. +fn is_configured(app: &AppHandle) -> bool { + let config = app.config().plugins.0.get("updater"); + config + .and_then(|config| config.get("pubkey")) + .and_then(|key| key.as_str()) + .is_some_and(|key| !key.trim().is_empty()) +} + +/// Whether this package replaces itself: the AppImage and the Windows +/// installer do, the package manager installs a `.deb` or an `.rpm`. +fn installs_itself(bundle: Option) -> bool { + matches!(bundle, Some(BundleType::AppImage | BundleType::Nsis)) +} + +/// The release's page: `releaseNotesUrl` in `latest.json`, which the +/// release workflow writes, else the tag's page on GitHub. +fn notes_url(manifest: &serde_json::Value, version: &str) -> String { + match manifest.get("releaseNotesUrl").and_then(|url| url.as_str()) { + Some(url) if url.starts_with("https://") => url.to_string(), + _ => format!("{RELEASES}/tag/v{version}"), + } +} + +fn set_state(app: &AppHandle, inner: &mut Inner, state: UpdateState) { + if inner.state != state { + inner.state = state.clone(); + let _ = state.emit(app); + } +} + +/// Checks at launch and then every few hours, while Settings allows it. +pub fn watch(app: &AppHandle) { + if !is_configured(app) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(FIRST_CHECK_DELAY).await; + loop { + if is_check_due(&app) { + // An automatic check's failure (offline, say) waits for the next one. + if let Err(message) = check(&app, false).await { + eprintln!("localhost: couldn't check for updates: {message}"); + } + } + tokio::time::sleep(CHECKER_TICK).await; + } + }); +} + +fn is_check_due(app: &AppHandle) -> bool { + let state = app.state::(); + if !state.settings.get().automatic_update_checks { + return false; + } + let inner = state.updates.inner(); + let is_busy = matches!(inner.state, UpdateState::Downloading { .. } | UpdateState::Ready { .. }); + let is_recent = inner.last_check.and_then(|last| last.elapsed().ok()).is_some_and(|since| since < CHECK_INTERVAL); + !is_busy && !is_recent +} + +/// Asks for `latest.json` and updates the notice. A version put off with +/// Later shows again only when `is_manual`. +async fn check(app: &AppHandle, is_manual: bool) -> Result, String> { + let updates = &app.state::().updates; + { + let mut inner = updates.inner(); + if inner.is_checking { + return Err("localhost is already checking for updates.".into()); + } + inner.is_checking = true; + } + let found = match app.updater() { + Ok(updater) => updater.check().await.map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + let mut inner = updates.inner(); + inner.is_checking = false; + inner.last_check = Some(SystemTime::now()); + let update = found?; + let Some(update) = update else { + if matches!(inner.state, UpdateState::Available { .. }) { + inner.update = None; + set_state(app, &mut inner, UpdateState::None); + } + return Ok(None); + }; + let version = update.version.clone(); + // Already downloading or downloaded: keep going. + let is_known = inner.update.as_ref().is_some_and(|known| known.version == version); + if is_known && !matches!(inner.state, UpdateState::Available { .. } | UpdateState::None) { + return Ok(Some(version)); + } + if is_manual { + inner.put_off = None; + } + let state = if inner.put_off.as_deref() == Some(version.as_str()) { + UpdateState::None + } else { + UpdateState::Available { + version: version.clone(), + notes_url: notes_url(&update.raw_json, &version), + can_install: installs_itself(tauri::utils::platform::bundle_type()), + problem: None, + } + }; + inner.update = Some(update); + inner.package = None; + set_state(app, &mut inner, state); + Ok(Some(version)) +} + +/// The notice's current state, e.g. for a window that just opened. +#[tauri::command] +#[specta::specta] +pub fn update_state(state: tauri::State<'_, AppState>) -> UpdateState { + state.updates.state() +} + +/// Check for Updates: checks now, and shows the notice again even for a +/// version put off with Later. +#[tauri::command] +#[specta::specta] +pub async fn check_for_updates(app: AppHandle) -> UpdateCheck { + if !is_configured(&app) { + return UpdateCheck::Failed { + message: "This build of localhost doesn’t update itself. Download new versions from GitHub Releases." + .into(), + }; + } + match check(&app, true).await { + Ok(Some(version)) => UpdateCheck::Available { version }, + Ok(None) => UpdateCheck::UpToDate { version: app.package_info().version.to_string() }, + Err(message) => UpdateCheck::Failed { + message: format!("localhost couldn’t check for updates: {message}. Check your connection and try again."), + }, + } +} + +/// Later: hides the notice until a newer version, or Check for Updates. +#[tauri::command] +#[specta::specta] +pub fn put_off_update(app: AppHandle) { + let updates = &app.state::().updates; + let mut inner = updates.inner(); + if let UpdateState::Available { version, .. } = &inner.state { + inner.put_off = Some(version.clone()); + set_state(&app, &mut inner, UpdateState::None); + } +} + +/// Update Now: downloads the update in the background and verifies its +/// signature. Nothing is installed until Restart to Update. +#[tauri::command] +#[specta::specta] +pub fn download_update(app: AppHandle) { + let updates = &app.state::().updates; + let (update, notes_url) = { + let mut inner = updates.inner(); + let UpdateState::Available { notes_url, can_install: true, .. } = inner.state.clone() else { return }; + let Some(update) = inner.update.clone() else { return }; + let state = + UpdateState::Downloading { version: update.version.clone(), notes_url: notes_url.clone(), progress: None }; + set_state(&app, &mut inner, state); + (update, notes_url) + }; + tauri::async_runtime::spawn(async move { + let version = update.version.clone(); + let mut received = 0_u64; + let mut last_progress = Instant::now(); + let progress_app = app.clone(); + let progress_notes = notes_url.clone(); + let downloaded = update + .download( + |chunk, total| { + received += chunk as u64; + if last_progress.elapsed() < PROGRESS_INTERVAL { + return; + } + last_progress = Instant::now(); + let progress = + total.filter(|total| *total > 0).map(|total| (received as f64 / total as f64).min(1.0)); + let updates = &progress_app.state::().updates; + let mut inner = updates.inner(); + if matches!(inner.state, UpdateState::Downloading { .. }) { + let state = UpdateState::Downloading { + version: version.clone(), + notes_url: progress_notes.clone(), + progress, + }; + set_state(&progress_app, &mut inner, state); + } + }, + || {}, + ) + .await; + let updates = &app.state::().updates; + let mut inner = updates.inner(); + let state = match downloaded { + Ok(package) => { + inner.package = Some(package); + UpdateState::Ready { version: update.version.clone(), notes_url } + } + Err(error) => UpdateState::Available { + version: update.version.clone(), + notes_url, + can_install: true, + problem: Some(format!("The download failed: {error}. Try again.")), + }, + }; + set_state(&app, &mut inner, state); + }); +} + +/// Restart to Update: asks first if any Service is active, since the +/// restart stops them, then installs and relaunches. +#[tauri::command] +#[specta::specta] +pub fn install_update(app: AppHandle) { + let state = app.state::(); + let version = match &state.updates.state() { + UpdateState::Ready { version, .. } => version.clone(), + _ => return, + }; + let active = state.core().map_or_else( + |_| Vec::new(), + |core| { + let statuses: HashMap = core + .library + .projects() + .iter() + .flat_map(|project| &project.services) + .map(|service| (service.id, core.targets.status(service.id))) + .collect(); + active_services(&core.library.projects(), &statuses) + }, + ); + if active.is_empty() { + install(&app); + return; + } + let count = active.iter().map(|(_, services)| services.len()).sum::(); + let title = if count == 1 { + "Stop 1 service and update?".to_string() + } else { + format!("Stop {count} services and update?") + }; + let message = format!( + "localhost restarts to install version {version}. These services are stopped first:\n\n{}", + active + .iter() + .map(|(project, services)| format!("{project}: {}", services.join(", "))) + .collect::>() + .join("\n") + ); + let mut dialog = app + .dialog() + .message(message) + .title(title) + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom("Stop and Update".into(), "Cancel".into())); + if let Some(window) = app.get_webview_window(lifecycle::MAIN_WINDOW) { + dialog = dialog.parent(&window); + } + let app = app.clone(); + dialog.show(move |is_confirmed| { + if is_confirmed { + install(&app); + } + }); +} + +/// The active Services' names by Project, in the library's order. +fn active_services(projects: &[Project], statuses: &HashMap) -> Vec<(String, Vec)> { + projects + .iter() + .filter_map(|project| { + let names: Vec = project + .services + .iter() + .filter(|service| statuses.get(&service.id).is_some_and(|status| status.state.is_active())) + .map(|service| service.name.clone()) + .collect(); + (!names.is_empty()).then(|| (project.name.clone(), names)) + }) + .collect() +} + +/// Stops every Run and retires the shared core, then installs: the +/// AppImage is replaced and the app relaunched; on Windows the installer +/// takes over and relaunches it. +fn install(app: &AppHandle) { + let (update, package) = { + let updates = &app.state::().updates; + let mut inner = updates.inner(); + if inner.is_installing { + return; + } + let (Some(update), Some(package)) = (inner.update.clone(), inner.package.clone()) else { return }; + inner.is_installing = true; + (update, package) + }; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Ok(core) = state.core() { + core.targets.stop_every_run(Origin::MainWindow).await; + core.targets.retire_shared_core().await; + } + match update.install(&package) { + Ok(()) => { + lifecycle::allow_exit(&app); + app.restart(); + } + Err(error) => { + let mut inner = state.updates.inner(); + inner.is_installing = false; + let failed = UpdateState::Available { + version: update.version.clone(), + notes_url: notes_url(&update.raw_json, &update.version), + can_install: true, + problem: Some(format!("The update couldn’t be installed: {error}.")), + }; + inner.package = None; + set_state(&app, &mut inner, failed); + } + } + }); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use localhost_core::{Key, MemorySecretStore, NewService, ProjectLibrary, ServiceState}; + use tauri_plugin_updater::RemoteRelease; + + use super::*; + + /// The shape of the `latest.json` that `scripts/updater-manifest.sh` writes. + const MANIFEST: &str = r#"{ + "version": "0.5.0", + "notes": "localhost 0.5.0", + "pub_date": "2026-11-02T10:00:00Z", + "releaseNotesUrl": "https://github.com/marcellosso/localhost/releases/tag/v0.5.0", + "platforms": { + "linux-x86_64-appimage": { "url": "https://example.com/a.AppImage", "signature": "a" }, + "linux-x86_64-deb": { "url": "https://example.com/a.deb", "signature": "d" }, + "linux-x86_64-rpm": { "url": "https://example.com/a.rpm", "signature": "r" }, + "windows-x86_64-nsis": { "url": "https://example.com/a-setup.exe", "signature": "w" } + } + }"#; + + #[test] + fn the_manifest_names_each_package_by_its_bundle() { + let manifest: serde_json::Value = serde_json::from_str(MANIFEST).unwrap(); + let release: RemoteRelease = serde_json::from_value(manifest.clone()).unwrap(); + assert_eq!(release.version.to_string(), "0.5.0"); + assert_eq!(release.download_url("linux-x86_64-appimage").unwrap().as_str(), "https://example.com/a.AppImage"); + assert_eq!(release.signature("windows-x86_64-nsis").unwrap(), "w"); + // No bundle-less entry: a `.deb` must never find the AppImage. + assert!(release.download_url("linux-x86_64").is_err()); + assert_eq!(notes_url(&manifest, "0.5.0"), "https://github.com/marcellosso/localhost/releases/tag/v0.5.0"); + } + + #[test] + fn notes_default_to_the_tag_page() { + assert_eq!(notes_url(&serde_json::json!({}), "1.2.3"), format!("{RELEASES}/tag/v1.2.3")); + let insecure = serde_json::json!({ "releaseNotesUrl": "http://example.com" }); + assert_eq!(notes_url(&insecure, "1.2.3"), format!("{RELEASES}/tag/v1.2.3")); + } + + #[test] + fn only_the_appimage_and_the_windows_installer_install_themselves() { + assert!(installs_itself(Some(BundleType::AppImage))); + assert!(installs_itself(Some(BundleType::Nsis))); + assert!(!installs_itself(Some(BundleType::Deb))); + assert!(!installs_itself(Some(BundleType::Rpm))); + assert!(!installs_itself(None)); + } + + #[test] + fn lists_the_active_services_by_project() { + let root = std::env::temp_dir().join(format!("localhost-updates-{}-{}", std::process::id(), Id::new())); + std::fs::create_dir_all(&root).unwrap(); + let library = ProjectLibrary::open(&root, Arc::new(MemorySecretStore::new())).unwrap(); + let add = |name: &str, services: &[&str]| { + let key = |name: &str| Key::new(name.to_lowercase()).unwrap(); + let services = services.iter().map(|service| NewService::new(*service, key(service), "sleep 1")).collect(); + library.add_project(name, key(name), &root.to_string_lossy(), services, Origin::Cli).unwrap() + }; + let shop = add("Shop", &["Web", "API", "Worker"]); + let blog = add("Blog", &["Site"]); + let status = |service_id: Id, state: ServiceState| { + (service_id, ServiceStatus { state, ..ServiceStatus::never_run(service_id) }) + }; + let statuses = HashMap::from([ + status(shop.services[0].id, ServiceState::Running), + status(shop.services[1].id, ServiceState::Failed), + status(shop.services[2].id, ServiceState::Stopping), + status(blog.services[0].id, ServiceState::Exited), + ]); + + assert_eq!( + active_services(&library.projects(), &statuses), + vec![("Shop".to_string(), vec!["Web".to_string(), "Worker".to_string()])] + ); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 80c7d81..1058443 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -38,5 +38,13 @@ "publisher": "Marcel Losso", "copyright": "Copyright © 2026 Marcel Losso. MIT License.", "license": "MIT" + }, + "plugins": { + "updater": { + "pubkey": "", + "endpoints": ["https://github.com/marcellosso/localhost/releases/latest/download/latest.json"], + "requireSignedVersion": true, + "windows": { "installMode": "passive" } + } } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4cdfe14..e287bd0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -22,6 +22,7 @@ import type { CoreView } from "./core"; import type { Navigator } from "./palette"; import { useWindowShortcuts } from "./shortcuts"; import { startCore, useCore } from "./store"; +import { startUpdates } from "./updates"; import { showSheet, useUi } from "./ui"; let started: Promise | null = null; @@ -31,7 +32,7 @@ let started: Promise | null = null; * A WSL distro's Orphans are asked about when it connects, which may be later. */ function start(): Promise { - started ??= Promise.all([loadSettings(), loadHome(), startCore()]).then(async () => { + started ??= Promise.all([loadSettings(), loadHome(), startCore(), startUpdates()]).then(async () => { void events.orphansFound.listen(() => void askAboutFoundOrphans()); await runLaunchChecks(); await runPerfMeasurement(); diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts index 44398f6..88a36fb 100644 --- a/apps/desktop/src/bindings.ts +++ b/apps/desktop/src/bindings.ts @@ -512,6 +512,39 @@ async quitApp() : Promise { */ async takeReveal() : Promise { return await TAURI_INVOKE("take_reveal"); +}, +/** + * The notice's current state, e.g. for a window that just opened. + */ +async updateState() : Promise { + return await TAURI_INVOKE("update_state"); +}, +/** + * Check for Updates: checks now, and shows the notice again even for a + * version put off with Later. + */ +async checkForUpdates() : Promise { + return await TAURI_INVOKE("check_for_updates"); +}, +/** + * Later: hides the notice until a newer version, or Check for Updates. + */ +async putOffUpdate() : Promise { + await TAURI_INVOKE("put_off_update"); +}, +/** + * Update Now: downloads the update in the background and verifies its + * signature. Nothing is installed until Restart to Update. + */ +async downloadUpdate() : Promise { + await TAURI_INVOKE("download_update"); +}, +/** + * Restart to Update: asks first if any Service is active, since the + * restart stops them, then installs and relaunches. + */ +async installUpdate() : Promise { + await TAURI_INVOKE("install_update"); } } @@ -520,10 +553,12 @@ async takeReveal() : Promise { export const events = __makeEvents__<{ orphansFound: OrphansFound, -reveal: Reveal +reveal: Reveal, +updateState: UpdateState }>({ orphansFound: "orphans-found", -reveal: "reveal" +reveal: "reveal", +updateState: "update-state" }) /** user-defined constants **/ @@ -1194,7 +1229,11 @@ automaticPortMove: boolean; appearance: Appearance; /** * Points, for the log and the commands in Service rows. */ -logFontSize: number } +logFontSize: number; +/** + * Whether localhost checks for a new version at launch and every few hours. + */ +automaticUpdateChecks: boolean } /** * Everything the UI shows, at the moment it subscribed. */ @@ -1244,6 +1283,39 @@ export type Termination = { kind: "exited"; code: number } | { kind: "signaled"; * The text attributes set by ANSI SGR sequences. */ export type TextStyle = { foreground: AnsiColor | null; background: AnsiColor | null; isBold: boolean; isDim: boolean; isItalic: boolean; isUnderlined: boolean; isInverse: boolean; isStrikethrough: boolean } +/** + * What Check for Updates found. + */ +export type UpdateCheck = +/** + * The notice shows it. + */ +{ kind: "available"; version: string } | { kind: "upToDate"; version: string } | +/** + * The check couldn't be made, or this build doesn't update itself. + */ +{ kind: "failed"; message: string } +/** + * What the update notice shows; also the event sent when it changes. + */ +export type UpdateState = +/** + * No newer version known, or the notice was put off with Later. + */ +{ kind: "none" } | +/** + * A newer version. `can_install` is false where a package manager + * installs it (`.deb`, `.rpm`); `problem` says why a download failed. + */ +{ kind: "available"; version: string; notesUrl: string; canInstall: boolean; problem: string | null } | +/** + * `progress` is from 0 to 1, `None` while the size isn't known. + */ +{ kind: "downloading"; version: string; notesUrl: string; progress: number | null } | +/** + * Downloaded and verified: restarting installs it. + */ +{ kind: "ready"; version: string; notesUrl: string } /** * The kind of value a Project file field takes. */ diff --git a/apps/desktop/src/components/Alerts.tsx b/apps/desktop/src/components/Alerts.tsx index cc59ffc..d427ec6 100644 --- a/apps/desktop/src/components/Alerts.tsx +++ b/apps/desktop/src/components/Alerts.tsx @@ -216,6 +216,21 @@ function AlertDialog({ alert, view }: { alert: Alert; view: CoreView }) { ); + case "upToDate": + return ( + + OK + + } + > +

Version {alert.version} is the latest.

+
+ ); + case "settings": return alert.forEditor ? ( diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index 654ad4c..862a37b 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useId, useRef, useState } from "react"; import type { Appearance } from "../bindings"; import { saveSettings, useSettings } from "../settings"; +import { checkForUpdates } from "../updates"; import { Dialog, useModal } from "./Dialog"; import { CircleHalfIcon, GearIcon, GlobeIcon } from "./Icons"; import styles from "./SettingsDialog.module.css"; @@ -114,6 +115,21 @@ function GeneralSettings() { onKeyDown={(event) => event.key === "Enter" && save()} /> + + void saveSettings({ automaticUpdateChecks })} + /> + + + + ); } diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index 66ed730..3ef95de 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -6,11 +6,13 @@ import { importProject } from "../editors"; import { SHORTCUTS, shortcutLabel } from "../keyboard"; import { isActive, projectServicesSummary } from "../status"; import { showAlert, showSheet } from "../ui"; +import { useUpdate } from "../updates"; import { ClearIcon, GearIcon, GlobeIcon, KeyboardIcon, PlusIcon, SearchIcon } from "./Icons"; import { Menu, useContextMenu } from "./Menu"; import { projectMenuItems } from "./ServicesColumn"; import { ShortcutBadge } from "./ShortcutBadge"; import { StateGlyph } from "./StateGlyph"; +import { UpdateNotice } from "./UpdateNotice"; import styles from "./Sidebar.module.css"; type Props = { @@ -19,9 +21,13 @@ type Props = { onSelect: (selection: SidebarSelection) => void; }; -/** The wordmark and project search, Ports and the Projects, and a footer with Settings and how many Services run. */ +/** + * The wordmark and project search, Ports and the Projects, a newer version + * when there is one, and a footer with Settings and how many Services run. + */ export function Sidebar({ view, selection, onSelect }: Props) { const [search, setSearch] = useState(""); + const update = useUpdate(); const query = search.trim().toLowerCase(); const projects = query ? view.projects.filter((p) => p.name.toLowerCase().includes(query) || p.key.includes(query)) @@ -113,6 +119,7 @@ export function Sidebar({ view, selection, onSelect }: Props) { )} +