feat(desktop): auto-updates + automated signed releases from GitHub Actions (#12420)

* feat(desktop): auto-update via Tauri updater with restart prompt

The Rust shell now checks the desktop-latest GitHub release feed on launch
and every 2 hours, downloads and stages updates in the background, and
exposes get_update_status/restart_to_apply_update commands. The webview
polls the status and shows a persistent toast with a one-click restart once
an update is staged; ignored updates apply on next launch. Updater
artifacts are only produced with the CI config overlay
(tauri.release.conf.json) so local packaging keeps working without the
updater signing key. Also mounts the previously-unmounted Toaster so
existing toast() calls render.

* ci(desktop): add desktop-publish release workflow and publish-desktop skill

desktop-publish.yml mirrors cli-publish: dispatch with a desktop-vX.Y.Z
tag + confirm gate, validates the tag against package.json and
tauri.conf.json, builds signed+notarized DMGs for aarch64 (native) and
x86_64 (cross-compiled sidecar via bun --target), generates the updater
manifest, publishes the versioned GitHub release, refreshes the rolling
desktop-latest auto-update feed, and posts to Slack. Adds the release
skill, changelog, and README docs for the required GitHub secrets.

* fix(desktop): address review — outlast sidecar shutdown window, dedupe update toast across remounts

stop() now polls for 7s before escalating to kill, past the sidecar's own
5s SHUTDOWN_TIMEOUT_MS graceful-shutdown budget, so clicking Restart now
(or quitting) during session persistence can't SIGKILL the sidecar
mid-write. notifiedVersion moves to module scope so a page remount doesn't
re-toast an update the user already dismissed.

* docs(desktop): move publish-desktop skill to .cline/skills, slim README release section

Match the publish-cli convention: the skill lives in .cline/skills/ and is
symlinked from both .agents/skills/ and .claude/skills/ so all agents pick
it up. The README's release section shrinks to a pointer + the two
never-lose invariants (desktop-latest feed, updater private key); the repo
secrets table moves into the skill, which also fixes its dangling reference
to a 'Release automation' README section and escapes the pipe that broke
the GFM table cell.
This commit is contained in:
Saoud Rizwan
2026-07-21 16:37:18 -07:00
committed by GitHub
parent 85484abf7a
commit f5224abdf5
16 changed files with 932 additions and 4 deletions
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-desktop
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-desktop
+127
View File
@@ -0,0 +1,127 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
## Release contract
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
## Workflow
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
```
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
2. Collect release commits.
```sh
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
4. Decide the version bump.
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
```sh
bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
```
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
7. Commit release changes.
```sh
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"
```
Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on `main` and the tag pushed first.
```sh
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
10. Final response.
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Repo secrets (one-time setup)
The workflow needs these repository secrets. The Apple ones come from the same
Apple Developer account used for manual signing (see the app README's "macOS
signing & notarization" section for how to obtain them):
| Secret | Value |
| --- | --- |
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
OTEL settings) are shared with the CLI publish workflow and already configured.
+310
View File
@@ -0,0 +1,310 @@
name: desktop-publish
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
validate:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if [ "$TAURI_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (${{ matrix.arch }})
needs: validate
runs-on: macos-latest
timeout-minutes: 90
strategy:
fail-fast: true
matrix:
include:
- target: aarch64-apple-darwin
arch: aarch64
- target: x86_64-apple-darwin
arch: x86_64
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust build
uses: swatinem/rust-cache@v2
with:
workspaces: apps/examples/desktop-app/src-tauri
key: ${{ matrix.target }}
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "APPLE_API_KEY_CONTENT secret is not configured"
exit 1
fi
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
env:
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Notarization via App Store Connect API key. Tauri reads the Key ID
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# Updater artifact signing (minisign keypair, independent of Apple)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Collect artifacts
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: |
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.arch }}
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist/desktop
merge-multiple: true
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
--version "$VERSION" \
--tag "$TAG" \
--dir dist/desktop \
--out dist/desktop/latest.json \
--repo "$GITHUB_REPOSITORY" \
--notes-file "$RUNNER_TEMP/release-notes.md"
cat dist/desktop/latest.json
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed (desktop-latest)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh release view desktop-latest >/dev/null 2>&1; then
gh release create desktop-latest \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
fi
gh release upload desktop-latest dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
echo "Published Cline Code desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
+6
View File
@@ -0,0 +1,6 @@
# Cline Code Desktop Changelog
## 0.1.0
- First automated release, published from GitHub Actions with Developer ID signing and notarization.
- The app now keeps itself up to date: it checks for new releases on launch and every 2 hours, downloads updates in the background, and shows a "Restart now" prompt when the new version is ready. Ignored updates apply on the next launch.
+15 -1
View File
@@ -25,7 +25,21 @@ Tailwind adapter and shared base styles without depending on the desktop
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
desktop integration notes.
## Shareable Desktop Packages
## Releases & Auto-Updates
Releases are built, signed, notarized, and published by the `desktop-publish`
GitHub workflow. The step-by-step flow (version bumps, changelog, tag, repo
secrets) lives in the `publish-desktop` skill
(`.cline/skills/publish-desktop/SKILL.md`).
Installed apps auto-update via the Tauri updater: they poll the rolling
`desktop-latest` release's `latest.json` on launch and every 2 hours, install
updates in the background, and prompt for a restart. Two things must never be
lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
shipped apps can't verify new updates).
## Shareable Desktop Packages (manual fallback)
Tauri desktop bundles are OS-specific, so build each package on the target OS:
@@ -17,13 +17,33 @@ const resolveTargetTriple = async (): Promise<string> => {
return host;
};
// Bun cross-compiles --compile binaries, so a CI runner can produce the
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
// bundle from an arm64 runner). Without an explicit --target, bun always
// emits a host-arch binary even when Tauri is building for another triple.
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
if (targetTriple.startsWith("aarch64-apple-darwin"))
return "bun-darwin-arm64";
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
if (targetTriple.startsWith("x86_64-unknown-linux")) return "bun-linux-x64";
if (targetTriple.startsWith("aarch64-unknown-linux"))
return "bun-linux-arm64";
return undefined;
};
const main = async () => {
const targetTriple = await resolveTargetTriple();
const extension = targetTriple.includes("windows") ? ".exe" : "";
const outfile = `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
const bunTarget = resolveBunCompileTarget(targetTriple);
await $`mkdir -p src-tauri/bin`;
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
}
};
main().catch((error: unknown) => {
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildUpdateManifest } from "./generate-update-manifest";
const makeArtifactDir = (): string => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz.sig"),
"sig-aarch64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz.sig"),
"sig-x86_64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
return dir;
};
describe("buildUpdateManifest", () => {
test("maps updater artifacts to darwin platform entries", () => {
const dir = makeArtifactDir();
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(manifest.version).toBe("0.1.0");
expect(manifest.platforms["darwin-aarch64"]).toEqual({
signature: "sig-aarch64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_aarch64.app.tar.gz",
});
expect(manifest.platforms["darwin-x86_64"]).toEqual({
signature: "sig-x86_64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x86_64.app.tar.gz",
});
// The DMG is a first-install artifact, not an updater artifact.
expect(Object.keys(manifest.platforms)).toHaveLength(2);
});
test("throws when a signature file is missing", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
expect(() =>
buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
}),
).toThrow();
});
test("throws when no updater artifacts exist", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
expect(() =>
buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
}),
).toThrow(/no updater artifacts/);
});
});
@@ -0,0 +1,133 @@
// Generates the Tauri updater manifest (latest.json) from the updater
// artifacts produced by the desktop-publish workflow. The manifest is uploaded
// to the rolling `desktop-latest` GitHub release, which is the static endpoint
// configured in src-tauri/tauri.conf.json; its platform URLs point back at the
// immutable per-version release assets.
//
// Usage:
// bun scripts/generate-update-manifest.ts \
// --version 0.1.0 --tag desktop-v0.1.0 --dir dist/desktop \
// --out dist/desktop/latest.json [--repo cline/cline] [--notes-file notes.md]
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
type UpdaterPlatformEntry = {
signature: string;
url: string;
};
export type UpdateManifest = {
version: string;
notes: string;
pub_date: string;
platforms: Record<string, UpdaterPlatformEntry>;
};
// Maps the arch token embedded in artifact file names (see the "Collect
// artifacts" workflow step) to the platform keys the Tauri updater requests.
const PLATFORM_KEY_BY_ARCH_SUFFIX: Record<string, string> = {
aarch64: "darwin-aarch64",
x86_64: "darwin-x86_64",
};
const getArgValue = (args: string[], name: string): string | undefined => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
return args[index + 1];
}
const prefix = `${name}=`;
const inline = args.find((arg) => arg.startsWith(prefix));
return inline?.slice(prefix.length);
};
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
if (!fileName.endsWith(".app.tar.gz")) {
return undefined;
}
return Object.keys(PLATFORM_KEY_BY_ARCH_SUFFIX).find((arch) =>
fileName.includes(`_${arch}`),
);
};
export const buildUpdateManifest = (options: {
version: string;
tag: string;
dir: string;
repo: string;
notes: string;
pubDate: string;
}): UpdateManifest => {
const platforms: Record<string, UpdaterPlatformEntry> = {};
for (const fileName of readdirSync(options.dir).sort()) {
const arch = archOfUpdaterArtifact(fileName);
if (!arch) {
continue;
}
const signaturePath = path.join(options.dir, `${fileName}.sig`);
const signature = readFileSync(signaturePath, "utf8").trim();
if (!signature) {
throw new Error(`empty updater signature at ${signaturePath}`);
}
platforms[PLATFORM_KEY_BY_ARCH_SUFFIX[arch]] = {
signature,
url: `https://github.com/${options.repo}/releases/download/${options.tag}/${encodeURIComponent(fileName)}`,
};
}
if (Object.keys(platforms).length === 0) {
throw new Error(
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
);
}
return {
version: options.version,
notes: options.notes,
pub_date: options.pubDate,
platforms,
};
};
const main = () => {
const args = process.argv.slice(2);
const version = getArgValue(args, "--version");
const tag = getArgValue(args, "--tag");
const dir = getArgValue(args, "--dir");
const out = getArgValue(args, "--out");
const repo =
getArgValue(args, "--repo") ??
process.env.GITHUB_REPOSITORY ??
"cline/cline";
const notesFile = getArgValue(args, "--notes-file");
if (!version || !tag || !dir || !out) {
throw new Error(
"usage: generate-update-manifest.ts --version X.Y.Z --tag desktop-vX.Y.Z --dir <artifact dir> --out <latest.json> [--repo owner/repo] [--notes-file <file>]",
);
}
const notes = notesFile
? readFileSync(notesFile, "utf8").trim()
: `Cline Code v${version}`;
const manifest = buildUpdateManifest({
version,
tag,
dir,
repo,
notes,
pubDate: new Date().toISOString(),
});
writeFileSync(out, `${JSON.stringify(manifest, null, "\t")}\n`);
console.log(`wrote ${out}`);
for (const [platform, entry] of Object.entries(manifest.platforms)) {
console.log(`- ${platform}: ${entry.url}`);
}
};
if (import.meta.main) {
main();
}
@@ -10,6 +10,8 @@ tauri-build = { version = "2.0.0", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.11.1", features = [] }
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["time"] }
rfd = "0.15"
[features]
+141 -2
View File
@@ -9,6 +9,10 @@ use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tauri::{Manager, RunEvent, State};
use tauri_plugin_updater::UpdaterExt;
const UPDATE_INITIAL_DELAY: Duration = Duration::from_secs(10);
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
#[derive(Clone)]
struct AppContext {
@@ -16,6 +20,108 @@ struct AppContext {
workspace_root: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateStatus {
state: String,
version: Option<String>,
error: Option<String>,
}
impl Default for UpdateStatus {
fn default() -> Self {
Self {
state: "idle".to_string(),
version: None,
error: None,
}
}
}
#[derive(Default)]
struct UpdateState {
status: Mutex<UpdateStatus>,
}
impl UpdateState {
fn set(&self, state: &str, version: Option<String>, error: Option<String>) {
if let Ok(mut guard) = self.status.lock() {
*guard = UpdateStatus {
state: state.to_string(),
version,
error,
};
}
}
fn snapshot(&self) -> UpdateStatus {
self.status
.lock()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn ready_version(&self) -> Option<String> {
self.status.lock().ok().and_then(|guard| {
if guard.state == "ready" {
guard.version.clone()
} else {
None
}
})
}
}
async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
// An update that already finished downloading only needs a restart; keep
// reporting "ready" instead of flipping back to transient states unless a
// newer version shows up.
let ready_version = state.ready_version();
if ready_version.is_none() {
state.set("checking", None, None);
}
let updater = match app.updater() {
Ok(updater) => updater,
Err(error) => {
state.set("error", None, Some(error.to_string()));
return;
}
};
match updater.check().await {
Ok(Some(update)) => {
let version = update.version.clone();
if ready_version.as_deref() == Some(version.as_str()) {
return;
}
state.set("downloading", Some(version.clone()), None);
match update.download_and_install(|_, _| {}, || {}).await {
Ok(()) => state.set("ready", Some(version), None),
Err(error) => state.set("error", Some(version), Some(error.to_string())),
}
}
Ok(None) => {
if ready_version.is_none() {
state.set("idle", None, None);
}
}
Err(error) => {
if ready_version.is_none() {
state.set("error", None, Some(error.to_string()));
}
}
}
}
async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
tokio::time::sleep(UPDATE_INITIAL_DELAY).await;
loop {
check_and_install_update(&app, &state).await;
tokio::time::sleep(UPDATE_CHECK_INTERVAL).await;
}
}
#[derive(Default)]
struct DesktopBackendState {
ws_endpoint: Mutex<Option<String>>,
@@ -44,7 +150,11 @@ impl DesktopBackendState {
if let Ok(mut process_guard) = self.process.lock() {
if let Some(child) = process_guard.as_mut() {
for _ in 0..30 {
// The sidecar bounds its own graceful shutdown with
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
// itself; wait past that window before escalating to kill so
// an active session can finish persisting.
for _ in 0..70 {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => thread::sleep(Duration::from_millis(100)),
@@ -452,6 +562,22 @@ fn pick_workspace_directory(initial_path: Option<String>) -> Option<String> {
.map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
fn get_update_status(update_state: State<'_, Arc<UpdateState>>) -> UpdateStatus {
update_state.snapshot()
}
#[tauri::command]
fn restart_to_apply_update(
app: tauri::AppHandle,
backend_state: State<'_, Arc<DesktopBackendState>>,
) {
// restart() never returns, so the run-loop Exit handler does not get a
// chance to stop the sidecar; shut it down explicitly first.
backend_state.stop();
app.restart();
}
#[tauri::command]
fn open_mcp_settings_file() -> Result<String, String> {
let settings_path = resolve_mcp_settings_path()?;
@@ -485,14 +611,25 @@ fn main() {
};
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(desktop_backend)
.manage(app_context)
.manage(Arc::new(UpdateState::default()))
.setup(|app| {
let app_context = app.state::<AppContext>().inner().clone();
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
eprintln!("[desktop-backend] startup failed: {error}");
}
// Dev builds are not installed app bundles, so there is nothing the
// updater could meaningfully check or replace.
if !cfg!(debug_assertions) {
let update_state = app.state::<Arc<UpdateState>>().inner().clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
run_update_loop(app_handle, update_state).await;
});
}
thread::spawn(move || loop {
thread::sleep(Duration::from_secs(5));
if backend_state.is_shutting_down() {
@@ -507,7 +644,9 @@ fn main() {
.invoke_handler(tauri::generate_handler![
get_desktop_backend_endpoint,
pick_workspace_directory,
open_mcp_settings_file
open_mcp_settings_file,
get_update_status,
restart_to_apply_update
])
.build(tauri::generate_context!())
.expect("error while building tauri app")
@@ -9,6 +9,14 @@
"beforeBuildCommand": "bun run build",
"frontendDist": "../webview/out"
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENEMTJDNzk2RUExQUY3RDEKUldUUjl4cnFsc2NTelYxNzlFR1NkWnI0VTM1V0hvQXRyOW0xV2c0bFhkL3dhdkdpNGhNRW1MQXEK",
"endpoints": [
"https://github.com/cline/cline/releases/download/desktop-latest/latest.json"
]
}
},
"app": {
"windows": [
{
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"createUpdaterArtifacts": true
}
}
@@ -32,6 +32,7 @@ import {
import { AccountProvider } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { useAppUpdate } from "@/hooks/use-app-update";
import { useChatSession } from "@/hooks/use-chat-session";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
@@ -84,6 +85,8 @@ export default function Home() {
() => threads[0]?.id,
);
useAppUpdate();
useEffect(() => {
syncHubTheme();
return watchSystemHubTheme();
@@ -0,0 +1,79 @@
"use client";
import { useEffect } from "react";
import { ToastAction } from "@/components/ui/toast";
import { toast } from "@/hooks/use-toast";
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
export type AppUpdateStatus = {
state: "idle" | "checking" | "downloading" | "ready" | "error";
version?: string | null;
error?: string | null;
};
const POLL_INTERVAL_MS = 30_000;
// Module-scoped so a page remount does not re-toast an update the user
// already dismissed while the Rust side still reports it as "ready".
let notifiedVersion: string | null = null;
/**
* Watches the Tauri shell's auto-updater. Updates are checked, downloaded, and
* installed in the background by the Rust side; once one is staged this hook
* surfaces a persistent toast offering a one-click restart into the new
* version. Ignoring the toast is fine too — the staged update takes effect on
* the next launch. No-op in web/sidecar mode where there is no app bundle to
* update.
*/
export function useAppUpdate() {
useEffect(() => {
if (!isTauriAvailable()) {
return;
}
let cancelled = false;
const poll = async () => {
let status: AppUpdateStatus;
try {
status =
await desktopClient.invoke<AppUpdateStatus>("get_update_status");
} catch {
// Update status is best-effort; ignore transient bridge failures.
return;
}
if (cancelled || status.state !== "ready" || !status.version) {
return;
}
if (notifiedVersion === status.version) {
return;
}
notifiedVersion = status.version;
toast({
title: `Update ready: v${status.version}`,
description:
"The new version has been downloaded and will be used the next time the app starts.",
duration: Number.POSITIVE_INFINITY,
action: (
<ToastAction
altText="Restart now"
onClick={() => {
void desktopClient.invoke("restart_to_apply_update");
}}
>
Restart now
</ToastAction>
),
});
};
void poll();
const interval = setInterval(() => {
void poll();
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
}
@@ -106,6 +106,8 @@ export function isTauriAvailable(): boolean {
const NATIVE_COMMANDS = new Set([
"pick_workspace_directory",
"open_mcp_settings_file",
"get_update_status",
"restart_to_apply_update",
]);
class DesktopClient {