Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 130 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -179,26 +241,55 @@ 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:
name: linux
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
Expand Down Expand Up @@ -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"

Expand All @@ -298,18 +396,25 @@ 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:
name: windows
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
Expand All @@ -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 }}
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
Loading
Loading