feat: add tauri desktop app (#1478)

This commit is contained in:
Carlos Florêncio
2026-06-24 23:02:50 +01:00
committed by GitHub
parent e52a8c55f2
commit 7e92f8da7a
106 changed files with 13504 additions and 32 deletions
+90 -1
View File
@@ -5,18 +5,22 @@ on:
branches: [main]
paths:
- "apps/backend/**"
- "apps/desktop/**"
- "apps/web/**"
- "apps/packages/**"
- "apps/pnpm-lock.yaml"
- "apps/pnpm-workspace.yaml"
- ".github/workflows/e2e-tests.yml"
- "!**/*.md"
pull_request:
branches: [main]
paths:
- "apps/backend/**"
- "apps/desktop/**"
- "apps/web/**"
- "apps/packages/**"
- "apps/pnpm-lock.yaml"
- "apps/pnpm-workspace.yaml"
- ".github/workflows/e2e-tests.yml"
- "!**/*.md"
# Manual trigger for verifying CI on draft PRs and ad-hoc reruns. The
@@ -50,6 +54,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -133,6 +139,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -223,6 +231,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Verify Docker is available
run: docker info
@@ -357,9 +367,65 @@ jobs:
path: apps/web/e2e/test-results/
retention-days: 7
desktop-e2e:
name: Desktop E2E Smoke
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@v5
with:
version: "9.15.9"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: apps/pnpm-lock.yaml
- name: Set up Rust
run: rustup toolchain install stable --profile minimal
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
key: desktop-e2e
- name: Install desktop system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
xvfb
- name: Install dependencies
working-directory: apps
run: pnpm install --frozen-lockfile
- name: Run desktop smoke
working-directory: apps
run: pnpm --filter @kandev/desktop e2e
e2e-report:
name: Merge E2E Reports
needs: [e2e, e2e-containers]
needs: [e2e, e2e-containers, desktop-e2e]
if: always()
runs-on: ubuntu-latest
# Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright CLI.
@@ -374,6 +440,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -413,3 +481,24 @@ jobs:
name: playwright-report
path: apps/web/playwright-report/
retention-days: 14
- name: Verify required E2E jobs passed
if: always()
env:
E2E_RESULT: ${{ needs.e2e.result }}
E2E_CONTAINERS_RESULT: ${{ needs['e2e-containers'].result }}
DESKTOP_E2E_RESULT: ${{ needs['desktop-e2e'].result }}
run: |
set -euo pipefail
if [ "$E2E_RESULT" != "success" ]; then
echo "e2e finished with result: $E2E_RESULT" >&2
exit 1
fi
if [ "$E2E_CONTAINERS_RESULT" != "success" ]; then
echo "e2e-containers finished with result: $E2E_CONTAINERS_RESULT" >&2
exit 1
fi
if [ "$DESKTOP_E2E_RESULT" != "success" ]; then
echo "desktop-e2e finished with result: $DESKTOP_E2E_RESULT" >&2
exit 1
fi
+431 -18
View File
@@ -7,15 +7,16 @@ name: release
# 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z
# 2. build-web — build Vite SPA bundle (shared across arches)
# 3. build-bundles — assemble native bin/ tarballs for 5 platforms
# 4. build-desktop — package Tauri desktop installers from matching runtime bundles
# 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming
# the prebuilt linux bundles from step 4
# the prebuilt linux bundles from step 3
# 6. docker-manifest — stitch the two arch digests into the user-facing
# multi-arch tags via `buildx imagetools create`
# 7. docker-universal-{amd64,arm64} — native universal builds on top of
# docker-manifest's per-arch base
# 8. docker-universal-manifest — size-gate amd64 staging, then promote both
# arches to the user-facing :universal tags
# 9. publish-release — create GH release with .tar.gz + .sha256 assets + auto-generated notes
# 9. publish-release — create GH release with runtime + desktop assets and release notes
# 10. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs
# 11. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev
#
@@ -25,6 +26,9 @@ name: release
#
# Required secrets:
# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key
# APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / KEYCHAIN_PASSWORD — macOS Developer ID signing
# APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID or APPLE_API_KEY / APPLE_API_ISSUER / APPLE_API_KEY_P8 — macOS notarization
# WINDOWS_CERTIFICATE / WINDOWS_CERTIFICATE_PASSWORD — Windows installer signing
on:
workflow_dispatch:
@@ -42,6 +46,10 @@ on:
description: "Dry run (no PR, tag, npm publish, or tap update)"
type: boolean
default: false
allow_unsigned_desktop:
description: "Allow unsigned macOS/Windows desktop artifacts for internal validation only"
type: boolean
default: false
# Default to read-only at the workflow level. Each job opts into the writes
# it actually needs.
@@ -58,6 +66,7 @@ jobs:
outputs:
version: ${{ steps.compute.outputs.version }}
tag: ${{ steps.compute.outputs.tag }}
ref: ${{ steps.compute.outputs.ref }}
steps:
- uses: actions/checkout@v6
with:
@@ -99,6 +108,8 @@ jobs:
id: compute
env:
BUMP: ${{ inputs.bump }}
ALLOW_UNSIGNED_DESKTOP: ${{ inputs.allow_unsigned_desktop }}
CURRENT_REF: ${{ github.sha }}
run: |
set -euo pipefail
PKG_VERSION="$(node -p "require('./apps/cli/package.json').version")"
@@ -148,6 +159,11 @@ jobs:
echo "Next tag: $TAG"
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ "$ALLOW_UNSIGNED_DESKTOP" = "true" ]; then
echo "ref=$CURRENT_REF" >> "$GITHUB_OUTPUT"
else
echo "ref=$TAG" >> "$GITHUB_OUTPUT"
fi
# Sanity: tag must not already exist
if git rev-parse "$TAG" >/dev/null 2>&1; then
@@ -160,7 +176,7 @@ jobs:
fi
- name: Bump version + generate CHANGELOG (in working tree)
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
env:
NEXT: ${{ steps.compute.outputs.version }}
TAG: ${{ steps.compute.outputs.tag }}
@@ -172,11 +188,33 @@ jobs:
# rewrites them to the real version in-flight before publishing,
# once the matching @kdlbs/runtime-* tarballs exist on npm.
(cd apps/cli && npm version --no-git-tag-version "$NEXT")
(cd apps/desktop && npm version --no-git-tag-version "$NEXT")
node <<'NODE'
const fs = require("node:fs");
const next = process.env.NEXT;
const tauriConfigPath = "apps/desktop/src-tauri/tauri.conf.json";
const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, "utf8"));
tauriConfig.version = next;
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`);
const cargoTomlPath = "apps/desktop/src-tauri/Cargo.toml";
const cargoToml = fs.readFileSync(cargoTomlPath, "utf8");
fs.writeFileSync(cargoTomlPath, cargoToml.replace(/^version = ".*"$/m, `version = "${next}"`));
const cargoLockPath = "apps/desktop/src-tauri/Cargo.lock";
const cargoLock = fs.readFileSync(cargoLockPath, "utf8");
const packageBlock = /(\[\[package\]\]\nname = "kandev-desktop"\nversion = ")[^"]+(")/;
if (!packageBlock.test(cargoLock)) {
throw new Error("Could not find kandev-desktop package entry in Cargo.lock");
}
fs.writeFileSync(cargoLockPath, cargoLock.replace(packageBlock, `$1${next}$2`));
NODE
pnpm -C apps install --no-frozen-lockfile
git-cliff --tag "$TAG" -o CHANGELOG.md
- name: Create release PR + squash-merge + tag
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEXT: ${{ steps.compute.outputs.version }}
@@ -189,7 +227,7 @@ jobs:
MSG="release: $NEXT"
git checkout -b "$BRANCH"
git add apps/cli/package.json CHANGELOG.md apps/pnpm-lock.yaml
git add apps/cli/package.json apps/desktop/package.json apps/desktop/src-tauri/Cargo.lock apps/desktop/src-tauri/Cargo.toml apps/desktop/src-tauri/tauri.conf.json CHANGELOG.md apps/pnpm-lock.yaml
git commit -m "$MSG"
# If a previous run pushed this branch but failed before the merge
# landed, the remote branch still exists. Delete it so the push
@@ -231,6 +269,17 @@ jobs:
}
}
- name: Unsigned desktop validation summary
if: ${{ !inputs.dry_run && inputs.allow_unsigned_desktop }}
env:
NEXT: ${{ steps.compute.outputs.version }}
TAG: ${{ steps.compute.outputs.tag }}
REF: ${{ steps.compute.outputs.ref }}
run: |
echo "Unsigned desktop validation for $TAG / $NEXT."
echo "No release PR, tag, GitHub release, public container tags, npm publish, or Homebrew update will be created."
echo "Build jobs will check out $REF."
- name: Dry-run summary
if: ${{ inputs.dry_run }}
env:
@@ -257,7 +306,8 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
ref: ${{ needs.prepare.outputs.ref }}
persist-credentials: false
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
@@ -287,7 +337,7 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-latest
- os: ubuntu-22.04
platform: linux-x64
goos: linux
goarch: amd64
@@ -316,7 +366,8 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
ref: ${{ needs.prepare.outputs.ref }}
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: apps/backend/go.mod
@@ -389,6 +440,360 @@ jobs:
dist/kandev-${{ matrix.platform }}.tar.gz
dist/kandev-${{ matrix.platform }}.tar.gz.sha256
build-desktop:
name: Build desktop app (${{ matrix.platform }})
needs: [prepare, build-bundles]
if: ${{ !inputs.dry_run }}
strategy:
matrix:
include:
- platform: macos-arm64
os: macos-15
rust_target: aarch64-apple-darwin
tauri_bundles: dmg
- platform: macos-x64
os: macos-15-intel
rust_target: x86_64-apple-darwin
tauri_bundles: dmg
- platform: linux-x64
os: ubuntu-22.04
rust_target: x86_64-unknown-linux-gnu
tauri_bundles: deb,rpm
- platform: linux-arm64
os: ubuntu-24.04-arm
rust_target: aarch64-unknown-linux-gnu
tauri_bundles: deb,rpm
- platform: windows-x64
os: windows-latest
rust_target: x86_64-pc-windows-msvc
tauri_bundles: nsis
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}
persist-credentials: false
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: apps/pnpm-lock.yaml
- name: Set up Rust
run: rustup toolchain install stable --profile minimal --target "${{ matrix.rust_target }}"
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
key: desktop-${{ matrix.platform }}-${{ matrix.rust_target }}
- name: Install Linux desktop dependencies
if: startsWith(matrix.platform, 'linux-')
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Install dependencies
run: pnpm -C apps install --frozen-lockfile
- uses: actions/download-artifact@v5
with:
name: bundle-${{ matrix.platform }}
path: dist/desktop-runtime
- name: Prepare desktop runtime resources
run: |
set -euo pipefail
mkdir -p dist/desktop-runtime-src
tar -xzf "dist/desktop-runtime/kandev-${{ matrix.platform }}.tar.gz" -C dist/desktop-runtime-src
scripts/release/prepare-desktop-runtime.sh \
--bundle-dir "dist/desktop-runtime-src/kandev" \
--platform "${{ matrix.platform }}" \
--output-dir "apps/desktop/src-tauri/resources/kandev"
- name: Configure macOS signing and notarization
if: startsWith(matrix.platform, 'macos-')
env:
ALLOW_UNSIGNED_DESKTOP: ${{ inputs.allow_unsigned_desktop }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
set -euo pipefail
missing=()
for key in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD KEYCHAIN_PASSWORD; do
if [ -z "${!key:-}" ]; then
missing+=("$key")
fi
done
apple_id_notarization=0
if [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
apple_id_notarization=1
fi
api_key_notarization=0
if [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ] && [ -n "${APPLE_API_KEY_P8:-}" ]; then
api_key_notarization=1
fi
if [ "$apple_id_notarization" -eq 0 ] && [ "$api_key_notarization" -eq 0 ]; then
missing+=("APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID or APPLE_API_KEY/APPLE_API_ISSUER/APPLE_API_KEY_P8")
fi
if [ "${#missing[@]}" -gt 0 ]; then
if [ "$ALLOW_UNSIGNED_DESKTOP" = "true" ]; then
echo "::warning::Unsigned desktop artifacts are internal validation only; missing macOS signing inputs: ${missing[*]}"
exit 0
fi
echo "::error::Public macOS desktop releases require signing and notarization inputs: ${missing[*]}" >&2
exit 1
fi
certificate_path="$RUNNER_TEMP/kandev-developer-id.p12"
python3 - <<'PY'
import base64
import os
from pathlib import Path
Path(os.environ["RUNNER_TEMP"], "kandev-developer-id.p12").write_bytes(
base64.b64decode(os.environ["APPLE_CERTIFICATE"])
)
PY
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import "$certificate_path" -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
cert_info="$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -n 1 || true)"
if [ -z "$cert_info" ]; then
cert_info="$(security find-identity -v -p codesigning build.keychain | head -n 1 || true)"
fi
if [ -z "$cert_info" ]; then
echo "::error::No code signing identity found in build.keychain" >&2
exit 1
fi
cert_id="$(echo "$cert_info" | awk -F'"' '{print $2}')"
echo "APPLE_SIGNING_IDENTITY=$cert_id" >> "$GITHUB_ENV"
if [ "$api_key_notarization" -eq 1 ]; then
private_keys_dir="$RUNNER_TEMP/private_keys"
mkdir -p "$private_keys_dir"
printf '%s\n' "$APPLE_API_KEY_P8" > "$private_keys_dir/AuthKey_${APPLE_API_KEY}.p8"
echo "APPLE_API_KEY_PATH=$private_keys_dir/AuthKey_${APPLE_API_KEY}.p8" >> "$GITHUB_ENV"
fi
- name: Sign macOS desktop runtime binaries
if: startsWith(matrix.platform, 'macos-') && !inputs.allow_unsigned_desktop
run: |
set -euo pipefail
runtime_bin="apps/desktop/src-tauri/resources/kandev/bin"
for binary in kandev agentctl; do
path="$runtime_bin/$binary"
if [ ! -f "$path" ]; then
echo "::error::Missing macOS runtime binary at $path" >&2
exit 1
fi
codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$path"
codesign --verify --strict --verbose=2 "$path"
done
- name: Sign Windows desktop runtime binaries
if: matrix.platform == 'windows-x64' && !inputs.allow_unsigned_desktop
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }}
run: |
$runtimeBin = Join-Path $env:GITHUB_WORKSPACE "apps/desktop/src-tauri/resources/kandev/bin"
foreach ($binary in @("kandev.exe", "agentctl.exe")) {
$path = Join-Path $runtimeBin $binary
if (!(Test-Path -LiteralPath $path)) {
Write-Error "Missing Windows runtime binary at $path"
exit 1
}
& "$env:GITHUB_WORKSPACE/apps/desktop/src-tauri/windows-sign.ps1" -Path $path
}
- name: Build Tauri desktop app
env:
CI: "true"
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
ALLOW_UNSIGNED_DESKTOP: ${{ inputs.allow_unsigned_desktop }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }}
run: |
set -euo pipefail
pnpm -C apps --filter @kandev/desktop build:vite
cd apps/desktop
pnpm tauri build \
--features desktop-runtime \
--target "${{ matrix.rust_target }}" \
--bundles "${{ matrix.tauri_bundles }}"
- name: Sign Windows desktop artifacts
if: matrix.platform == 'windows-x64'
shell: pwsh
env:
ALLOW_UNSIGNED_DESKTOP: ${{ inputs.allow_unsigned_desktop }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }}
run: |
$missing = @()
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE)) { $missing += "WINDOWS_CERTIFICATE" }
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)) { $missing += "WINDOWS_CERTIFICATE_PASSWORD" }
if ($missing.Count -gt 0) {
if ($env:ALLOW_UNSIGNED_DESKTOP -eq "true") {
Write-Warning "Unsigned desktop artifacts are internal validation only; missing Windows signing inputs: $($missing -join ', ')"
exit 0
}
Write-Error "Public Windows desktop releases require signing inputs: $($missing -join ', ')"
exit 1
}
$certificatePath = Join-Path $env:RUNNER_TEMP "kandev-code-signing.pfx"
[IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
$bundleDir = Join-Path $env:GITHUB_WORKSPACE "apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
if (!(Test-Path $bundleDir)) {
$bundleDir = Join-Path $env:GITHUB_WORKSPACE "apps/desktop/src-tauri/target/release/bundle"
}
if (!(Test-Path $bundleDir)) {
Write-Error "Missing Tauri bundle directory"
exit 1
}
$timestampUrl = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_TIMESTAMP_URL)) { "https://timestamp.digicert.com" } else { $env:WINDOWS_TIMESTAMP_URL }
$signTool = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNTOOL_PATH)) { "signtool" } else { $env:WINDOWS_SIGNTOOL_PATH }
$artifacts = @(Get-ChildItem -Path $bundleDir -Recurse -File -Include *.exe, *.msi)
if ($artifacts.Count -eq 0) {
Write-Error "No Windows installer artifacts found under $bundleDir"
exit 1
}
function Invoke-SignTool {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[Parameter(Mandatory = $true)]
[string]$Description
)
& $signTool @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Description failed with exit code $LASTEXITCODE"
}
}
# signtool sign is the default Windows signing mechanism for CI artifacts.
try {
foreach ($artifact in $artifacts) {
$signArguments = @(
"sign", "/fd", "SHA256", "/td", "SHA256", "/tr", $timestampUrl,
"/f", $certificatePath,
"/p", $env:WINDOWS_CERTIFICATE_PASSWORD,
$artifact.FullName
)
$verifyArguments = @("verify", "/pa", $artifact.FullName)
Invoke-SignTool `
-Description "signtool sign $($artifact.Name)" `
-Arguments $signArguments
Invoke-SignTool `
-Description "signtool verify $($artifact.Name)" `
-Arguments $verifyArguments
}
} finally {
if (Test-Path -LiteralPath $certificatePath) {
Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue
}
}
- name: Collect desktop artifacts
run: |
set -euo pipefail
out_dir="$GITHUB_WORKSPACE/dist/desktop-artifacts"
mkdir -p "$out_dir"
bundle_dir="apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
if [ ! -d "$bundle_dir" ]; then
bundle_dir="apps/desktop/src-tauri/target/release/bundle"
fi
if [ ! -d "$bundle_dir" ]; then
echo "Missing Tauri bundle directory" >&2
exit 1
fi
found=0
while IFS= read -r -d '' artifact; do
base="$(basename "$artifact")"
dest="$out_dir/kandev-desktop-${{ matrix.platform }}-$base"
cp "$artifact" "$dest"
(cd "$out_dir" && shasum -a 256 "$(basename "$dest")" > "$(basename "$dest").sha256")
found=$((found + 1))
done < <(find "$bundle_dir" -type f \( \
-name '*.dmg' -o \
-name '*.deb' -o \
-name '*.rpm' -o \
-name '*.msi' -o \
-name '*.exe' -o \
-name '*.AppImage' \
\) -print0)
if [ "$found" -eq 0 ]; then
echo "No desktop artifacts found under $bundle_dir" >&2
exit 1
fi
scripts/release/verify-desktop-assets.sh "$out_dir" "${{ matrix.platform }}"
ls -lh "$out_dir"
- uses: actions/upload-artifact@v5
with:
name: desktop-${{ matrix.platform }}
path: dist/desktop-artifacts/*
# ---------------------------------------------------------------------------
# Docker base image — split per arch onto native runners, then stitched into
# a multi-arch manifest list. Each arch consumes the matching linux bundle
@@ -398,7 +803,7 @@ jobs:
docker-amd64:
name: Build & push container image (amd64)
needs: [prepare, build-bundles]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -469,7 +874,7 @@ jobs:
docker-arm64:
name: Build & push container image (arm64)
needs: [prepare, build-bundles]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-24.04-arm
permissions:
contents: read
@@ -528,7 +933,7 @@ jobs:
docker-manifest:
name: Assemble multi-arch base manifest
needs: [prepare, docker-amd64, docker-arm64]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -582,7 +987,7 @@ jobs:
docker-universal-amd64:
name: Build & push universal image (amd64)
needs: [prepare, docker-manifest]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -634,7 +1039,7 @@ jobs:
docker-universal-arm64:
name: Build & push universal image (arm64)
needs: [prepare, docker-manifest]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-24.04-arm
permissions:
contents: read
@@ -685,7 +1090,7 @@ jobs:
# docker-universal-manifest is included in publish-release's `needs` so
# its size gate (and any per-arch build failure) blocks the release.
needs: [prepare, docker-universal-amd64, docker-universal-arm64]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -741,8 +1146,8 @@ jobs:
publish-release:
name: Publish GitHub release
needs: [prepare, build-bundles, docker-universal-manifest]
if: ${{ !inputs.dry_run }}
needs: [prepare, build-bundles, build-desktop, docker-universal-manifest]
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-latest
permissions:
contents: write # softprops/action-gh-release creates the release
@@ -767,6 +1172,12 @@ jobs:
path: dist/release-assets
merge-multiple: true
- uses: actions/download-artifact@v5
with:
pattern: desktop-*
path: dist/release-assets
merge-multiple: true
- name: Verify release assets
run: |
set -euo pipefail
@@ -783,6 +1194,7 @@ jobs:
exit 1
fi
done
scripts/release/verify-desktop-assets.sh dist/release-assets
ls -lh dist/release-assets
- name: Publish release
@@ -794,11 +1206,12 @@ jobs:
files: |
dist/release-assets/kandev-*.tar.gz
dist/release-assets/kandev-*.tar.gz.sha256
dist/release-assets/kandev-desktop-*
publish-npm:
name: Publish npm packages
needs: [prepare, publish-release]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC trusted publishing
@@ -833,7 +1246,7 @@ jobs:
update-homebrew-tap:
name: Update Homebrew tap
needs: [prepare, publish-release]
if: ${{ !inputs.dry_run }}
if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
+4 -1
View File
@@ -8,6 +8,7 @@
apps/
├── backend/ # Go backend (orchestrator, lifecycle, agentctl, WS gateway)
├── web/ # Vite/React SPA frontend (Go boot payload + WS + Zustand)
├── desktop/ # Tauri desktop shell around the native runtime
├── cli/ # CLI tool (TypeScript)
├── landing/ # Landing page
└── packages/ # Shared packages/types
@@ -18,6 +19,7 @@ apps/
- **Package manager**: `pnpm` workspace (run from `apps/`, not repo root)
- **Backend**: Go with Make (`make -C apps/backend test|lint|build`)
- **Frontend**: Vite/React SPA (`cd apps && pnpm --filter @kandev/web dev|build:vite|lint|typecheck`)
- **Desktop**: Tauri shell (`cd apps && pnpm --filter @kandev/desktop build|e2e`; Rust tests from `apps/desktop/src-tauri`)
- **UI**: Shadcn components via `@kandev/ui`
- **E2E**: Playwright (`cd apps/web && pnpm e2e`). The `containers` project (gated on `KANDEV_E2E_CONTAINERS=1`, formerly `docker`) covers both the Docker executor and the SSH executor — anything that needs a real Docker daemon on the host lives there. See `apps/web/e2e/README.md`.
- **GitHub repo**: `https://github.com/kdlbs/kandev`
@@ -37,6 +39,7 @@ Architecture notes and per-area conventions live alongside the code they describ
- `apps/backend/internal/agentctl/AGENTS.md` — agentctl HTTP server: route groups, adapter model, ACP protocol.
- `apps/backend/internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping.
- `apps/backend/internal/integrations/AGENTS.md` — adding a new third-party integration (Jira/Linear pattern, both backend and frontend halves). The `/add-integration` skill mirrors this for scaffolding new integrations.
- `apps/desktop/AGENTS.md` — Tauri desktop app: runtime resources, Rust process lifecycle, packaging, signing, and smoke tests.
- `apps/web/AGENTS.md` — Vite/React SPA frontend: shadcn imports, Go boot-payload hydration, store slice structure (incl. `office`), WS format, component conventions, TS lint limits.
---
@@ -114,4 +117,4 @@ For developing in ephemeral cloud VMs (Cursor Cloud, Codex, GitHub Codespaces, e
---
**Last Updated**: 2026-05-24
**Last Updated**: 2026-06-23
+1
View File
@@ -388,6 +388,7 @@ test-scripts:
@bash scripts/pr-state.test.sh
@bash scripts/opencode-code-review.test.sh
@python3 scripts/opencode-code-review.test.py
@bash scripts/release-desktop.test.sh
.PHONY: test-e2e
test-e2e: build-backend build-web
+2 -2
View File
@@ -8,7 +8,7 @@ Manage and run tasks in parallel. Orchestrate agents. Review changes. Ship value
<img src="docs/screenshots/readme-intro.gif" alt="Kandev Demo">
</p>
[See desktop and mobile screenshots](docs/screenshots.md)
[See screenshots](docs/screenshots.md)
## What
@@ -246,7 +246,7 @@ make doctor
There are a few similar tools in this space, and new ones appearing everyday. Here's what sets this one apart:
- **Server-first architecture** - Not a desktop app. Runs as a server you can access from any device, including your phone. Start a task away from your computer and check in on it later.
- **Server-first architecture** - The core app runs as a server you can access from any device, including your phone.
- **Remote runtimes** - Run agents on remote servers via SSH, Docker hosts, and cloud environments, not just your local machine.
- **Multi-provider** - Use Claude Code, Codex, Copilot, Gemini, Amp, Auggie, OpenCode, Cursor, Qwen, Droid, iFlow, Kilocode, Pi, Kimi, Kiro, Qoder, Trae, and Oh My Pi side by side. Not locked to one vendor.
- **CLI passthrough and chat** - Interact with agents through structured chat messages or drop into raw CLI mode for full agent TUI capabilities.
@@ -83,6 +83,11 @@ import (
ws "github.com/kandev/kandev/pkg/websocket"
)
const (
desktopHealthTokenEnv = "KANDEV_DESKTOP_HEALTH_TOKEN"
desktopHealthTokenHeader = "X-Kandev-Desktop-Health-Token"
)
// buildSessionDataProvider constructs the session data provider function used by the WebSocket hub
// to send initial data (git status, context window, available commands) when a client subscribes.
func buildSessionDataProvider(taskRepo *sqliterepo.Repository, lifecycleMgr *lifecycle.Manager, log *logger.Logger) func(context.Context, string) ([]*ws.Message, error) {
@@ -551,6 +556,9 @@ func registerRoutes(p routeParams) {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "starting", "service": "kandev"})
return
}
if token := desktopHealthToken(); token != "" {
c.Header(desktopHealthTokenHeader, token)
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"})
})
@@ -614,6 +622,10 @@ func registerRoutes(p routeParams) {
}
}
func desktopHealthToken() string {
return strings.TrimSpace(os.Getenv(desktopHealthTokenEnv))
}
func newWebAppHandler(p routeParams) (*webapp.Handler, string, bool) {
assets, source, ok := webAssetsFS()
if !ok {
+33 -8
View File
@@ -12,6 +12,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"time"
@@ -740,21 +741,25 @@ func startGatewayAndServe(
// after we bind the socket above; probe the local listener with a short
// retry loop — once a single connect succeeds, the kernel queue is up and
// any subsequent /health call will land on a wired route.
go waitListenerThenMarkReady(port, log)
go waitListenerThenMarkReady(server.Addr, log)
awaitShutdown(server, orchestratorSvc, lifecycleMgr, runCleanups, log)
return true
}
func startHTTPServer(server *http.Server, port int, log *logger.Logger) bool {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
addr := strings.TrimSpace(server.Addr)
if addr == "" {
addr = serverListenAddr("", port)
}
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Error("Server listen error", zap.Error(err))
return false
}
go func() {
log.Info("WebSocket server listening", zap.Int("port", port))
log.Info("WebSocket server listening", zap.String("addr", ln.Addr().String()), zap.Int("port", port))
if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Error("Server serve error", zap.Error(err))
}
@@ -763,6 +768,26 @@ func startHTTPServer(server *http.Server, port int, log *logger.Logger) bool {
return true
}
func serverListenAddr(host string, port int) string {
host = strings.TrimSpace(host)
if host == "" {
return fmt.Sprintf(":%d", port)
}
return net.JoinHostPort(host, fmt.Sprint(port))
}
func serverProbeAddr(listenAddr string) string {
host, port, err := net.SplitHostPort(listenAddr)
if err != nil {
return listenAddr
}
switch host {
case "", "0.0.0.0", "::":
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// waitListenerThenMarkReady probes the local HTTP listener until a
// connect succeeds, then flips the package-level `ready` flag so the
// /health handler stops returning 503. Runs in its own goroutine so
@@ -773,8 +798,8 @@ func startHTTPServer(server *http.Server, port int, log *logger.Logger) bool {
// of seconds. If the budget expires the flag still flips, so the
// backend doesn't permanently advertise "not ready" if probing fails
// for some unrelated reason (e.g. an iptables hiccup on a dev box).
func waitListenerThenMarkReady(port int, log *logger.Logger) {
addr := fmt.Sprintf("127.0.0.1:%d", port)
func waitListenerThenMarkReady(listenAddr string, log *logger.Logger) {
addr := serverProbeAddr(listenAddr)
deadline := time.Now().Add(30 * time.Second)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
@@ -784,12 +809,12 @@ func waitListenerThenMarkReady(port int, log *logger.Logger) {
_ = conn.Close()
// A successful TCP dial proves the listener is bound.
ready.Store(true)
log.Info("backend ready", zap.Int("port", port))
log.Info("backend ready", zap.String("addr", addr))
return
}
if time.Now().After(deadline) {
log.Warn("backend readiness probe never connected; flipping ready anyway",
zap.Int("port", port))
zap.String("addr", addr))
ready.Store(true)
return
}
@@ -1630,7 +1655,7 @@ func buildHTTPServer(
})
return &http.Server{
Addr: fmt.Sprintf(":%d", port),
Addr: serverListenAddr(cfg.Server.Host, port),
Handler: router,
ReadTimeout: cfg.Server.ReadTimeoutDuration(),
WriteTimeout: cfg.Server.WriteTimeoutDuration(),
@@ -0,0 +1,107 @@
package backendapp
import (
"context"
"net"
"net/http"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
func TestStartHTTPServerUsesServerAddr(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("NewFromZap: %v", err)
}
blocked := listenOnFreePort(t)
t.Cleanup(func() {
if err := blocked.Close(); err != nil {
t.Errorf("close blocked listener: %v", err)
}
})
server := &http.Server{
Addr: "127.0.0.1:0",
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }),
}
defer func() {
_ = server.Shutdown(context.Background())
}()
if !startHTTPServer(server, listenerPort(t, blocked), log) {
t.Fatalf("expected server to listen on configured Addr %q", server.Addr)
}
}
func TestServerListenAddr(t *testing.T) {
tests := []struct {
name string
host string
port int
want string
}{
{name: "blank host keeps port-only address", host: "", port: 38429, want: ":38429"},
{name: "wildcard host", host: "0.0.0.0", port: 38429, want: "0.0.0.0:38429"},
{name: "loopback host", host: "127.0.0.1", port: 38429, want: "127.0.0.1:38429"},
{name: "ipv6 host", host: "::1", port: 38429, want: "[::1]:38429"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverListenAddr(tt.host, tt.port); got != tt.want {
t.Fatalf("serverListenAddr(%q, %d) = %q, want %q", tt.host, tt.port, got, tt.want)
}
})
}
}
func TestDesktopHealthTokenTrimsEnv(t *testing.T) {
t.Setenv(desktopHealthTokenEnv, " token-value ")
if got := desktopHealthToken(); got != "token-value" {
t.Fatalf("desktopHealthToken() = %q, want token-value", got)
}
}
func TestServerProbeAddr(t *testing.T) {
tests := []struct {
name string
listenAddr string
want string
}{
{name: "port-only address probes loopback", listenAddr: ":38429", want: "127.0.0.1:38429"},
{name: "wildcard ipv4 probes loopback", listenAddr: "0.0.0.0:38429", want: "127.0.0.1:38429"},
{name: "wildcard ipv6 probes loopback", listenAddr: "[::]:38429", want: "127.0.0.1:38429"},
{name: "loopback address probes itself", listenAddr: "127.0.0.1:38429", want: "127.0.0.1:38429"},
{name: "ipv6 loopback probes itself", listenAddr: "[::1]:38429", want: "[::1]:38429"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverProbeAddr(tt.listenAddr); got != tt.want {
t.Fatalf("serverProbeAddr(%q) = %q, want %q", tt.listenAddr, got, tt.want)
}
})
}
}
func listenOnFreePort(t *testing.T) net.Listener {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen on free port: %v", err)
}
return ln
}
func listenerPort(t *testing.T, ln net.Listener) int {
t.Helper()
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("expected TCP listener, got %T", ln.Addr())
}
return tcpAddr.Port
}
@@ -187,6 +187,18 @@ func TestFeatures_OfficeEnabledByEnv(t *testing.T) {
}
}
func TestServerHostFromEnv(t *testing.T) {
t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1")
cfg, err := LoadWithPath(t.TempDir())
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
if cfg.Server.Host != "127.0.0.1" {
t.Fatalf("Server.Host = %q, want 127.0.0.1", cfg.Server.Host)
}
}
// TestFeaturesConfig_JSONShape pins the wire format of GET /api/v1/features.
// The handler in helpers.go serializes FeaturesConfig directly so new
// fields flow through without an extra edit; this test guarantees the
+7 -2
View File
@@ -47,9 +47,14 @@ kandev/
# npm runtime package (@kdlbs/runtime-{platform})
@kdlbs/runtime-{platform}/
└── bin/{kandev,agentctl,agentctl-linux-amd64}
# Tauri desktop resource directory
apps/desktop/src-tauri/resources/kandev/
└── bin/{kandev[.exe],agentctl[.exe],agentctl-linux-amd64}
```
For npm installs, the main `kandev` package provides only a tiny Node bin shim that execs `bin/kandev` from the platform runtime package.
For desktop release builds, `scripts/release/prepare-desktop-runtime.sh` extracts the matching GitHub release bundle into the Tauri resource directory before `tauri build` runs.
## Commands
@@ -121,9 +126,9 @@ Releases run entirely in GitHub Actions. From the GHA UI:
4. Optionally tick `dry_run` to validate without publishing.
5. Click **Run workflow**.
The workflow does everything: version bump, CHANGELOG, PR, merge, tag, build, npm publish, Homebrew tap update. See [/.github/workflows/release.yml](../../.github/workflows/release.yml).
The workflow does everything: version bump, CHANGELOG, PR, merge, tag, runtime bundles, desktop artifacts, npm publish, Homebrew tap update. See [/.github/workflows/release.yml](../../.github/workflows/release.yml).
Single SemVer flow: `apps/cli/package.json` version, git tag, npm packages, and Homebrew formula are all bumped to the same `X.Y.Z`.
Single SemVer flow: `apps/cli/package.json` version, git tag, npm packages, Homebrew formula, GitHub runtime tarballs, and desktop artifacts are all bumped to the same `X.Y.Z`.
Versioning:
+130
View File
@@ -181,3 +181,133 @@ describe("release npm publishing", () => {
expect(script).toContain("treated as idempotent success");
});
});
describe("release desktop artifacts", () => {
const desktopPlatforms = ["macos-arm64", "macos-x64", "linux-x64", "linux-arm64", "windows-x64"];
function releaseWorkflow(): string {
return readRepoFile(".github/workflows/release.yml");
}
it("builds desktop artifacts for every supported runtime platform", () => {
const workflow = releaseWorkflow();
expect(workflow).toMatch(/\n build-desktop:\n/);
expect(workflow).toContain("needs: [prepare, build-bundles]");
expect(workflow).toContain("scripts/release/prepare-desktop-runtime.sh");
expect(workflow).toContain('--platform "${{ matrix.platform }}"');
expect(workflow).toContain(
'rustup toolchain install stable --profile minimal --target "${{ matrix.rust_target }}"',
);
expect(workflow).toContain("Swatinem/rust-cache@v2");
for (const platform of desktopPlatforms) {
expect(workflow, `release.yml must include desktop platform ${platform}`).toContain(
`platform: ${platform}`,
);
}
});
it("publishes desktop artifacts while leaving npm and Homebrew tied to runtime tarballs", () => {
const workflow = releaseWorkflow();
const publishNpmScript = readRepoFile("scripts/release/publish-npm.sh");
const homebrewScript = readRepoFile("scripts/release/update-homebrew-tap.sh");
expect(workflow).toContain(
"needs: [prepare, build-bundles, build-desktop, docker-universal-manifest]",
);
expect(workflow).toContain("pattern: desktop-*");
expect(workflow).toContain("scripts/release/verify-desktop-assets.sh");
expect(workflow).toContain("dist/release-assets/kandev-desktop-*");
expect(workflow).toContain('shasum -a 256 "$(basename "$dest")"');
expect(publishNpmScript).toContain('asset="kandev-${platform}.tar.gz"');
expect(publishNpmScript).not.toContain("kandev-desktop-");
expect(homebrewScript).toContain('local sha_file="kandev-${platform}.tar.gz.sha256"');
expect(homebrewScript).not.toContain("kandev-desktop-");
});
it("bumps desktop package and Tauri versions during release preparation", () => {
const workflow = releaseWorkflow();
expect(workflow).toContain('(cd apps/desktop && npm version --no-git-tag-version "$NEXT")');
expect(workflow).toContain("tauriConfig.version = next");
expect(workflow).toContain('cargoToml.replace(/^version = ".*"$/m, `version = "${next}"`)');
expect(workflow).toContain('name = "kandev-desktop"');
expect(workflow).toContain("apps/desktop/src-tauri/tauri.conf.json");
expect(workflow).toContain("apps/desktop/src-tauri/Cargo.toml");
expect(workflow).toContain("apps/desktop/src-tauri/Cargo.lock");
});
it("pins linux x64 desktop release builds to Ubuntu 22.04", () => {
const workflow = releaseWorkflow();
expect(workflow).toContain(`- os: ubuntu-22.04
platform: linux-x64
goos: linux
goarch: amd64`);
expect(workflow).toContain(`- platform: linux-x64
os: ubuntu-22.04
rust_target: x86_64-unknown-linux-gnu
tauri_bundles: deb,rpm`);
});
it("fails public macOS and Windows desktop builds closed when signing secrets are absent", () => {
const workflow = releaseWorkflow();
const signingDocs = readRepoFile("docs/desktop-tauri-signing.md");
const tauriConfig = readRepoFile("apps/desktop/src-tauri/tauri.conf.json");
const windowsSignScript = readRepoFile("apps/desktop/src-tauri/windows-sign.ps1");
expect(workflow).toContain("allow_unsigned_desktop");
expect(workflow).toContain("Unsigned desktop artifacts are internal validation only");
expect(workflow).toContain("ref: ${{ needs.prepare.outputs.ref }}");
expect(workflow).toContain("persist-credentials: false");
expect(workflow).toContain("Unsigned desktop validation summary");
expect(workflow).toContain("No release PR, tag, GitHub release, public container tags");
expect(workflow).toContain("if: ${{ !inputs.dry_run && !inputs.allow_unsigned_desktop }}");
expect(workflow).toContain("docker-amd64:");
expect(workflow).toContain("docker-universal-manifest:");
for (const key of [
"APPLE_CERTIFICATE",
"APPLE_CERTIFICATE_PASSWORD",
"KEYCHAIN_PASSWORD",
"APPLE_SIGNING_IDENTITY",
"APPLE_API_KEY_P8",
"APPLE_API_KEY_PATH",
"APPLE_TEAM_ID",
]) {
expect(workflow, `release.yml must wire ${key}`).toContain(key);
}
for (const key of ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD", "signtool sign"]) {
expect(workflow, `release.yml must wire ${key}`).toContain(key);
}
expect(workflow).toContain("Invoke-SignTool");
expect(workflow).toContain("$LASTEXITCODE -ne 0");
expect(workflow).toContain("Sign macOS desktop runtime binaries");
expect(workflow).toContain("resources/kandev/bin");
expect(workflow).toContain("for binary in kandev agentctl; do");
expect(workflow).toContain(
'codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY"',
);
expect(workflow).toContain("codesign --verify --strict");
expect(workflow).toContain("Sign Windows desktop runtime binaries");
expect(workflow).toContain('foreach ($binary in @("kandev.exe", "agentctl.exe"))');
expect(workflow).toContain("Remove-Item -LiteralPath $certificatePath");
expect(tauriConfig).toContain('"publisher": "Kandev"');
expect(tauriConfig).toContain('"timestampUrl"');
expect(tauriConfig).toContain('"timestampUrl": "https://timestamp.digicert.com"');
expect(tauriConfig).toContain('"signCommand"');
expect(tauriConfig).toContain("windows-sign.ps1");
expect(tauriConfig).not.toContain('"csp": null');
expect(windowsSignScript).toContain('"https://timestamp.digicert.com"');
expect(windowsSignScript).toContain("Remove-Item -LiteralPath $certificatePath");
expect(signingDocs).toContain("Public recommended desktop releases require signing");
expect(signingDocs).toContain("allow_unsigned_desktop");
expect(signingDocs).toContain("does not publish a GitHub release");
expect(signingDocs).toContain("public container tags");
expect(signingDocs).toContain("Ubuntu 22.04");
});
});
+35
View File
@@ -0,0 +1,35 @@
# Desktop App Guidance
`apps/desktop` is the Tauri v2 shell for Kandev. It should stay a thin desktop wrapper over the existing native runtime and Go-served SPA.
## Commands
Run from `apps/` unless noted:
- `pnpm --filter @kandev/desktop build:vite` builds the startup surface.
- `pnpm --filter @kandev/desktop build` builds the Linux desktop bundle locally.
- `pnpm --filter @kandev/desktop e2e` builds the app and runs the Linux smoke harness under Xvfb when needed.
- From `apps/desktop/src-tauri`, `cargo test` runs Rust unit tests without requiring the Tauri runtime feature by default.
## Runtime Resources
Release builds prepare `src-tauri/resources/kandev/` with:
```text
bin/kandev[.exe]
bin/agentctl[.exe]
bin/agentctl-linux-amd64
```
Use `scripts/release/prepare-desktop-runtime.sh` and `scripts/release/verify-desktop-runtime.sh`; do not commit runtime binaries. The tracked `.gitignore` files only keep the resource directory present for Tauri config validation.
## Architecture
- Frontend code in `src/` is only the startup/error surface. The real product UI is still served by the Go backend after `/health` succeeds.
- Rust code owns backend process spawning and cleanup. Do not expose broad shell or filesystem permissions to frontend JavaScript.
- Desktop launches force `KANDEV_SERVER_HOST=127.0.0.1`, prefer a stable desktop port with random fallback, and pass `KANDEV_BUNDLE_DIR` to the native launcher.
- `KANDEV_DESKTOP_RUNTIME_DIR` is a test/development override for runtime resources.
## Release
Desktop artifacts are built in `.github/workflows/release.yml` after the platform runtime bundles. Public macOS and Windows artifacts require signing unless `allow_unsigned_desktop=true` is explicitly selected for internal validation.
+5
View File
@@ -0,0 +1,5 @@
# Desktop E2E
`pnpm --filter @kandev/desktop e2e` builds the Linux desktop bundle, launches the compiled Tauri app under `xvfb-run` when no display is available, and points it at a fake native runtime.
The fake runtime exposes `/health` and `/`. The smoke test passes only after the WebView requests `/`, which proves the startup screen transitioned to the backend UI after health succeeded.
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
import { createServer } from "node:http";
import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(__dirname, "..");
const repoRoot = resolve(desktopRoot, "../..");
if (process.argv[2] === "--fake-runtime") {
await runFakeRuntime(process.argv[3], process.argv.slice(4));
} else {
await runSmoke();
}
async function runSmoke() {
const appBinary = resolve(desktopRoot, "src-tauri/target/release/kandev-desktop");
if (!existsSync(appBinary)) {
throw new Error(`Missing desktop binary at ${appBinary}; run pnpm build first`);
}
const tmp = await mkdtemp(join(tmpdir(), "kandev-desktop-e2e-"));
const runtimeDir = join(tmp, "runtime");
const stateDir = join(tmp, "state");
await mkdir(join(runtimeDir, "bin"), { recursive: true });
await mkdir(stateDir, { recursive: true });
await writeFakeRuntime(runtimeDir, stateDir);
const launchedViaXvfb = !process.env.DISPLAY && commandExists("xvfb-run");
const command = launchedViaXvfb ? "xvfb-run" : appBinary;
const args = launchedViaXvfb ? ["-a", appBinary] : [];
const child = spawn(command, args, {
cwd: repoRoot,
detached: process.platform !== "win32",
env: {
...process.env,
KANDEV_DESKTOP_RUNTIME_DIR: runtimeDir,
WEBKIT_DISABLE_COMPOSITING_MODE: "1",
NO_AT_BRIDGE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
const failIfExited = () => {
if (child.exitCode !== null) {
throw new Error(`desktop app exited early with code ${child.exitCode}\n${stdout}\n${stderr}`);
}
};
try {
await waitForFile(join(stateDir, "health-requested"), 45_000, failIfExited);
await waitForFile(join(stateDir, "root-requested"), 45_000, failIfExited);
} finally {
await stopProcess(child);
}
console.log("Desktop smoke passed: WebView requested / after backend health succeeded.");
}
async function writeFakeRuntime(runtimeDir, stateDir) {
const fakeRuntime = join(runtimeDir, "bin", process.platform === "win32" ? "kandev.cmd" : "kandev");
const agentctl = join(runtimeDir, "bin", process.platform === "win32" ? "agentctl.cmd" : "agentctl");
const linuxHelper = join(runtimeDir, "bin", "agentctl-linux-amd64");
if (process.platform === "win32") {
await writeFile(
fakeRuntime,
`@echo off\r\nnode "${fileURLToPath(import.meta.url)}" --fake-runtime "${stateDir}" %*\r\n`,
);
await writeFile(agentctl, "@echo off\r\necho fake agentctl\r\n");
} else {
await writeFile(
fakeRuntime,
`#!/usr/bin/env bash\nexec node "${fileURLToPath(import.meta.url)}" --fake-runtime "${stateDir}" "$@"\n`,
);
await writeFile(agentctl, "#!/usr/bin/env bash\necho fake agentctl\n");
await chmod(fakeRuntime, 0o755);
await chmod(agentctl, 0o755);
}
await writeFile(linuxHelper, "#!/usr/bin/env bash\necho fake agentctl linux helper\n");
if (process.platform !== "win32") {
await chmod(linuxHelper, 0o755);
}
}
async function runFakeRuntime(stateDir, args) {
const portIndex = args.indexOf("--port");
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 0;
const isHeadless = args.includes("--headless");
if (!isHeadless || !Number.isInteger(port) || port <= 0) {
await writeFile(join(stateDir, "invalid-args"), JSON.stringify(args));
process.exit(2);
}
await writeFile(join(stateDir, "launched"), JSON.stringify({ args, port }));
const server = createServer(async (req, res) => {
if (req.url === "/health") {
await writeFile(join(stateDir, "health-requested"), "1");
const headers = { "content-type": "application/json" };
if (process.env.KANDEV_DESKTOP_HEALTH_TOKEN) {
headers["x-kandev-desktop-health-token"] = process.env.KANDEV_DESKTOP_HEALTH_TOKEN;
}
res.writeHead(200, headers);
res.end('{"status":"ok"}');
return;
}
if (req.url === "/") {
await writeFile(join(stateDir, "root-requested"), "1");
res.writeHead(200, { "content-type": "text/html" });
res.end("<!doctype html><title>Kandev</title><main>Kandev desktop smoke</main>");
return;
}
res.writeHead(404);
res.end("not found");
});
await new Promise((resolveListen) => server.listen(port, "127.0.0.1", resolveListen));
const stop = async () => {
await writeFile(join(stateDir, "terminated"), "1");
server.close(() => process.exit(0));
};
process.on("SIGTERM", stop);
process.on("SIGINT", stop);
}
async function waitForFile(path, timeoutMs, tick) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
tick?.();
if (existsSync(path)) {
return;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
}
throw new Error(`Timed out waiting for ${path}`);
}
async function stopProcess(child) {
if (child.exitCode !== null) {
return;
}
if (process.platform === "win32") {
child.kill();
} else {
process.kill(-child.pid, "SIGTERM");
}
await Promise.race([
new Promise((resolveExit) => child.once("exit", resolveExit)),
new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5_000)),
]);
if (child.exitCode === null) {
if (process.platform === "win32") {
child.kill("SIGKILL");
} else {
process.kill(-child.pid, "SIGKILL");
}
}
}
function commandExists(command) {
const path = process.env.PATH ?? "";
return path
.split(delimiter)
.filter(Boolean)
.some((entry) => existsSync(join(entry, command)));
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kandev</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<main class="startup-shell" aria-live="polite">
<section class="startup-panel">
<div class="status-mark" aria-hidden="true"></div>
<div class="startup-copy">
<p class="eyebrow">Kandev</p>
<h1 id="status-title">Starting backend</h1>
<p id="status-detail">Preparing the local runtime and opening your workspace.</p>
</div>
</section>
</main>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@kandev/desktop",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev --features desktop-runtime",
"dev:vite": "vite --host 127.0.0.1 --port 1420",
"build": "tauri build --features desktop-runtime --bundles deb,rpm",
"build:vite": "vite build",
"e2e": "pnpm build && node e2e/desktop-launch-smoke.mjs",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@tauri-apps/cli": "2.8.4",
"typescript": "^5",
"vite": "^8.0.16"
}
}
+1
View File
@@ -0,0 +1 @@
/target/
+5147
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "kandev-desktop"
version = "0.1.0"
description = "Kandev desktop shell"
authors = ["Kandev"]
license = "AGPL-3.0-only"
repository = "https://github.com/kdlbs/kandev"
edition = "2021"
rust-version = "1.85"
[[bin]]
name = "kandev-desktop"
path = "src/main.rs"
required-features = ["desktop-runtime"]
test = false
[features]
default = []
desktop-runtime = [
"dep:tauri",
"dep:tauri-plugin-single-instance",
"dep:tauri-runtime",
]
[build-dependencies]
tauri-build = { version = "=2.4.1", features = [] }
[dependencies]
darling = "=0.21.3"
getrandom = "0.3"
idna_adapter = "=1.0.0"
libc = "0.2"
plist = "=1.7.1"
serde_json = "1"
serde_with = "=3.15.0"
tauri = { version = "=2.8.4", features = [], optional = true }
tauri-runtime = { version = "=2.9.0", optional = true }
tauri-plugin-single-instance = { version = "=2.2.1", optional = true }
+5
View File
@@ -0,0 +1,5 @@
fn main() {
if std::env::var_os("CARGO_FEATURE_DESKTOP_RUNTIME").is_some() {
tauri_build::build();
}
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main Kandev desktop window permissions",
"windows": ["main"],
"permissions": ["core:app:default", "core:event:default", "core:window:default"]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Main Kandev desktop window permissions","local":true,"windows":["main"],"permissions":["core:app:default","core:event:default","core:window:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
*
!.gitignore
!kandev/
!kandev/.gitignore
@@ -0,0 +1,2 @@
*
!.gitignore
+989
View File
@@ -0,0 +1,989 @@
use std::{
collections::BTreeMap,
env,
ffi::{OsStr, OsString},
io::{ErrorKind, Read, Write},
net::{TcpListener, TcpStream, ToSocketAddrs},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
thread,
time::{Duration, Instant},
};
#[cfg(feature = "desktop-runtime")]
use tauri::{AppHandle, Manager, WebviewWindow};
const HEALTH_TIMEOUT: Duration = Duration::from_secs(60);
const LOOPBACK_HOST: &str = "127.0.0.1";
const DEFAULT_DESKTOP_PORT: u16 = 38430;
const DESKTOP_PORT_ENV: &str = "KANDEV_DESKTOP_PORT";
const DESKTOP_HEALTH_TOKEN_ENV: &str = "KANDEV_DESKTOP_HEALTH_TOKEN";
const DESKTOP_HEALTH_TOKEN_HEADER: &str = "x-kandev-desktop-health-token";
const STARTUP_OUTPUT_LIMIT: usize = 12 * 1024;
const HEALTH_READY_SETTLE: Duration = Duration::from_millis(100);
#[derive(Clone)]
pub struct BackendState {
child: Arc<Mutex<Option<Child>>>,
startup_output: Arc<Mutex<StartupOutput>>,
shutdown_started: Arc<AtomicBool>,
}
impl Default for BackendState {
fn default() -> Self {
Self {
child: Arc::new(Mutex::new(None)),
startup_output: Arc::new(Mutex::new(StartupOutput::default())),
shutdown_started: Arc::new(AtomicBool::new(false)),
}
}
}
impl BackendState {
pub fn begin_shutdown(&self) -> bool {
!self.shutdown_started.swap(true, Ordering::SeqCst)
}
pub fn stop(&self) {
self.shutdown_started.store(true, Ordering::SeqCst);
let child = self.child.lock().expect("backend child mutex poisoned").take();
if let Some(mut child) = child {
terminate_child(&mut child);
}
}
fn is_shutdown_started(&self) -> bool {
self.shutdown_started.load(Ordering::SeqCst)
}
fn set_child(&self, mut child: Child) -> bool {
let mut guard = self.child.lock().expect("backend child mutex poisoned");
if self.is_shutdown_started() {
drop(guard);
terminate_child(&mut child);
return false;
}
*guard = Some(child);
true
}
fn reset_startup_output(&self) {
self.startup_output
.lock()
.expect("startup output mutex poisoned")
.clear();
}
fn recent_startup_output(&self) -> Option<String> {
self.startup_output
.lock()
.expect("startup output mutex poisoned")
.text()
}
fn child_exit_message(&self) -> Result<Option<String>, String> {
let mut guard = self.child.lock().expect("backend child mutex poisoned");
if let Some(child) = guard.as_mut() {
if let Some(status) = child.try_wait().map_err(|err| err.to_string())? {
thread::sleep(Duration::from_millis(50));
return Ok(Some(launcher_exit_message(
&status.to_string(),
self.recent_startup_output(),
)));
}
}
Ok(None)
}
}
#[derive(Default)]
struct StartupOutput {
bytes: Vec<u8>,
}
impl StartupOutput {
fn clear(&mut self) {
self.bytes.clear();
}
fn push(&mut self, stream: &str, chunk: &[u8]) {
if chunk.is_empty() {
return;
}
self.bytes
.extend_from_slice(format!("\n[{stream}] ").as_bytes());
self.bytes.extend_from_slice(chunk);
if self.bytes.len() > STARTUP_OUTPUT_LIMIT {
let overflow = self.bytes.len() - STARTUP_OUTPUT_LIMIT;
self.bytes.drain(0..overflow);
}
}
fn text(&self) -> Option<String> {
let text = String::from_utf8_lossy(&self.bytes).trim().to_string();
if text.is_empty() {
None
} else {
Some(text)
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct BackendCommandSpec {
pub program: PathBuf,
pub args: Vec<OsString>,
pub cwd: PathBuf,
pub env: BTreeMap<OsString, OsString>,
}
#[cfg(feature = "desktop-runtime")]
pub fn start_desktop_backend(app: AppHandle, window: WebviewWindow) {
let state = app.state::<BackendState>().inner().clone();
thread::spawn(move || {
set_status(
&window,
"Starting backend",
"Preparing the local runtime and opening your workspace.",
false,
);
match launch_and_wait(&app, &state) {
Ok(url) => {
if let Err(err) = navigate_to_backend(&window, &url) {
state.stop();
set_status(
&window,
"Desktop startup failed",
&format!("Backend started, but the window could not navigate: {err}"),
true,
);
}
}
Err(err) => {
state.stop();
set_status(&window, "Desktop startup failed", &err, true);
}
}
});
}
#[cfg(feature = "desktop-runtime")]
fn launch_and_wait(app: &AppHandle, state: &BackendState) -> Result<String, String> {
let runtime_dir = resolve_runtime_dir(app)?;
let port = pick_desktop_port()?;
let health_token = desktop_health_token()?;
let mut inherited_env: BTreeMap<OsString, OsString> = env::vars_os().collect();
inherited_env.insert(
OsString::from(DESKTOP_HEALTH_TOKEN_ENV),
OsString::from(&health_token),
);
let spec = build_backend_command(
&runtime_dir,
port,
inherited_env,
current_home_dir().as_deref(),
)?;
state.reset_startup_output();
if state.is_shutdown_started() {
return Err("Desktop startup cancelled".to_string());
}
let mut child = spawn_backend_command(&spec)?;
capture_child_output(&mut child, state.startup_output.clone());
if !state.set_child(child) {
return Err("Desktop startup cancelled".to_string());
}
wait_for_backend(port, state, HEALTH_TIMEOUT, &health_token)?;
Ok(format!("http://{LOOPBACK_HOST}:{port}/"))
}
#[cfg(feature = "desktop-runtime")]
fn resolve_runtime_dir(app: &AppHandle) -> Result<PathBuf, String> {
if let Some(dir) = env::var_os("KANDEV_DESKTOP_RUNTIME_DIR") {
return Ok(PathBuf::from(dir));
}
app.path()
.resource_dir()
.map(|dir| dir.join("kandev"))
.map_err(|err| err.to_string())
}
pub fn build_backend_command(
runtime_dir: &Path,
port: u16,
inherited_env: BTreeMap<OsString, OsString>,
home_dir: Option<&Path>,
) -> Result<BackendCommandSpec, String> {
validate_runtime_dir(runtime_dir)?;
let program = runtime_dir.join("bin").join(executable_name("kandev"));
let cwd = program
.parent()
.ok_or_else(|| format!("Kandev launcher has no parent directory: {}", program.display()))?
.to_path_buf();
Ok(BackendCommandSpec {
program,
args: vec![
OsString::from("--headless"),
OsString::from("--port"),
OsString::from(port.to_string()),
],
cwd,
env: desktop_environment(runtime_dir, inherited_env, home_dir),
})
}
pub fn validate_runtime_dir(runtime_dir: &Path) -> Result<(), String> {
let bin_dir = runtime_dir.join("bin");
require_runtime_file(&bin_dir.join(executable_name("kandev")), "Kandev launcher binary")?;
require_runtime_file(&bin_dir.join(executable_name("agentctl")), "agentctl binary")?;
require_runtime_file(&bin_dir.join("agentctl-linux-amd64"), "agentctl linux/amd64 helper")?;
Ok(())
}
fn require_runtime_file(path: &Path, label: &str) -> Result<(), String> {
if path.is_file() {
Ok(())
} else {
Err(format!("{label} is missing at {}", path.display()))
}
}
pub fn desktop_environment(
runtime_dir: &Path,
mut env: BTreeMap<OsString, OsString>,
home_dir: Option<&Path>,
) -> BTreeMap<OsString, OsString> {
let path = normalized_path(env.get(OsStr::new("PATH")), home_dir);
env.insert(
OsString::from("KANDEV_SERVER_HOST"),
OsString::from(LOOPBACK_HOST),
);
env.insert(
OsString::from("KANDEV_BUNDLE_DIR"),
runtime_dir.as_os_str().to_os_string(),
);
env.insert(OsString::from("PATH"), path);
env
}
pub fn pick_loopback_port() -> Result<u16, String> {
let listener = TcpListener::bind((LOOPBACK_HOST, 0)).map_err(|err| err.to_string())?;
listener
.local_addr()
.map(|addr| addr.port())
.map_err(|err| err.to_string())
}
pub fn pick_desktop_port() -> Result<u16, String> {
let preferred = preferred_desktop_port(env::var_os(DESKTOP_PORT_ENV))?;
pick_available_loopback_port(preferred)
}
fn desktop_health_token() -> Result<String, String> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes)
.map_err(|err| format!("Could not generate desktop health token: {err}"))?;
Ok(hex_encode(&bytes))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
fn preferred_desktop_port(value: Option<OsString>) -> Result<u16, String> {
let Some(value) = value else {
return Ok(DEFAULT_DESKTOP_PORT);
};
let value = value
.into_string()
.map_err(|_| format!("{DESKTOP_PORT_ENV} must be valid UTF-8"))?;
let port = value
.parse::<u16>()
.map_err(|_| format!("{DESKTOP_PORT_ENV} must be a TCP port between 1 and 65535"))?;
if port == 0 {
Err(format!("{DESKTOP_PORT_ENV} must be a TCP port between 1 and 65535"))
} else {
Ok(port)
}
}
fn pick_available_loopback_port(preferred: u16) -> Result<u16, String> {
match TcpListener::bind((LOOPBACK_HOST, preferred)) {
Ok(listener) => listener
.local_addr()
.map(|addr| addr.port())
.map_err(|err| err.to_string()),
Err(err) if err.kind() == ErrorKind::AddrInUse => pick_loopback_port(),
Err(err) => Err(format!("Could not reserve {LOOPBACK_HOST}:{preferred}: {err}")),
}
}
fn spawn_backend_command(spec: &BackendCommandSpec) -> Result<Child, String> {
let mut command = Command::new(&spec.program);
command
.args(&spec.args)
.current_dir(&spec.cwd)
.env_clear()
.envs(&spec.env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command.spawn().map_err(|err| {
format!(
"Failed to start Kandev launcher at {}: {err}",
spec.program.display()
)
})
}
fn capture_child_output(child: &mut Child, output: Arc<Mutex<StartupOutput>>) {
if let Some(stdout) = child.stdout.take() {
capture_stream("stdout", stdout, output.clone());
}
if let Some(stderr) = child.stderr.take() {
capture_stream("stderr", stderr, output);
}
}
fn capture_stream<R>(stream: &'static str, mut reader: R, output: Arc<Mutex<StartupOutput>>)
where
R: Read + Send + 'static,
{
thread::spawn(move || {
let mut buffer = [0_u8; 1024];
loop {
match reader.read(&mut buffer) {
Ok(0) => return,
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(_) => return,
Ok(n) => output
.lock()
.expect("startup output mutex poisoned")
.push(stream, &buffer[..n]),
}
}
});
}
fn wait_for_backend(
port: u16,
state: &BackendState,
timeout: Duration,
expected_health_token: &str,
) -> Result<(), String> {
let deadline = Instant::now() + timeout;
loop {
if state.is_shutdown_started() {
return Err("Desktop startup cancelled".to_string());
}
if let Some(message) = state.child_exit_message()? {
return Err(format!("Kandev launcher exited before /health became ready ({message})"));
}
if health_ready(port, expected_health_token) {
thread::sleep(HEALTH_READY_SETTLE);
if let Some(message) = state.child_exit_message()? {
return Err(format!(
"Kandev launcher exited before /health became ready ({message})"
));
}
return Ok(());
}
if Instant::now() >= deadline {
let mut message = format!("Timed out waiting for http://{LOOPBACK_HOST}:{port}/health");
append_recent_output(&mut message, state.recent_startup_output());
return Err(message);
}
thread::sleep(Duration::from_millis(250));
}
}
fn launcher_exit_message(status: &str, output: Option<String>) -> String {
let mut message = status.to_string();
append_recent_output(&mut message, output);
message
}
fn append_recent_output(message: &mut String, output: Option<String>) {
if let Some(output) = output {
message.push_str("\n\nRecent backend output:\n");
message.push_str(&output);
}
}
fn health_ready(port: u16, expected_health_token: &str) -> bool {
request_health(port, expected_health_token).unwrap_or(false)
}
fn request_health(port: u16, expected_health_token: &str) -> Result<bool, String> {
let addr = (LOOPBACK_HOST, port)
.to_socket_addrs()
.map_err(|err| err.to_string())?
.next()
.ok_or_else(|| format!("Could not resolve {LOOPBACK_HOST}:{port}"))?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250))
.map_err(|err| err.to_string())?;
stream
.set_read_timeout(Some(Duration::from_millis(250)))
.map_err(|err| err.to_string())?;
stream
.write_all(
format!(
"GET /health HTTP/1.1\r\nHost: {LOOPBACK_HOST}:{port}\r\nConnection: close\r\n\r\n"
)
.as_bytes(),
)
.map_err(|err| err.to_string())?;
let response = read_http_response_head(&mut stream)?;
if !(response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200")) {
return Ok(false);
}
Ok(response_has_header(
&response,
DESKTOP_HEALTH_TOKEN_HEADER,
expected_health_token,
))
}
fn read_http_response_head<R: Read>(reader: &mut R) -> Result<Vec<u8>, String> {
const MAX_HEADER_BYTES: usize = 8 * 1024;
let mut response = Vec::with_capacity(512);
let mut buffer = [0_u8; 16];
while response.len() < MAX_HEADER_BYTES {
let read_len = buffer.len().min(MAX_HEADER_BYTES - response.len());
let n = reader
.read(&mut buffer[..read_len])
.map_err(|err| err.to_string())?;
if n == 0 {
break;
}
response.extend_from_slice(&buffer[..n]);
if response.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
Ok(response)
}
fn response_has_header(response: &[u8], expected_name: &str, expected_value: &str) -> bool {
let response = String::from_utf8_lossy(response);
for line in response.lines().skip(1) {
let Some((name, value)) = line.split_once(':') else {
continue;
};
if name.eq_ignore_ascii_case(expected_name) && value.trim() == expected_value {
return true;
}
}
false
}
fn normalized_path(existing: Option<&OsString>, home_dir: Option<&Path>) -> OsString {
let mut entries: Vec<PathBuf> = existing
.map(env::split_paths)
.into_iter()
.flatten()
.collect();
for entry in common_path_entries(home_dir) {
if !entries.iter().any(|existing| existing == &entry) {
entries.push(entry);
}
}
env::join_paths(entries).unwrap_or_else(|_| existing.cloned().unwrap_or_default())
}
fn common_path_entries(home_dir: Option<&Path>) -> Vec<PathBuf> {
let mut entries = if cfg!(windows) {
Vec::new()
} else if cfg!(target_os = "macos") {
vec![
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/bin"),
]
} else {
vec![
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/bin"),
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
]
};
if let Some(home) = home_dir {
entries.push(home.join(".local/bin"));
if cfg!(windows) {
entries.push(home.join("AppData/Roaming/npm"));
entries.push(home.join("scoop/shims"));
} else {
entries.push(home.join(".bun/bin"));
entries.push(home.join(".opencode/bin"));
}
}
entries
}
fn current_home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn executable_name(name: &str) -> OsString {
if cfg!(windows) {
OsString::from(format!("{name}.exe"))
} else {
OsString::from(name)
}
}
#[cfg(feature = "desktop-runtime")]
fn set_status(window: &WebviewWindow, title: &str, detail: &str, failed: bool) {
let payload = serde_json::json!({
"title": title,
"detail": detail,
"failed": failed,
});
let script = format!(
"window.__KANDEV_DESKTOP_PENDING_STATUS={payload};window.__KANDEV_DESKTOP_SET_STATUS?.({payload});"
);
let _ = window.eval(&script);
}
#[cfg(feature = "desktop-runtime")]
fn navigate_to_backend(window: &WebviewWindow, url: &str) -> Result<(), String> {
let url = serde_json::to_string(url).map_err(|err| err.to_string())?;
window
.eval(&format!("window.location.replace({url});"))
.map_err(|err| err.to_string())
}
#[cfg(unix)]
fn terminate_child(child: &mut Child) {
let _ = unsafe { libc::kill(child.id() as i32, libc::SIGTERM) };
wait_or_kill(child, Duration::from_secs(5));
}
#[cfg(windows)]
fn terminate_child(child: &mut Child) {
wait_or_kill(child, Duration::from_secs(0));
}
#[cfg(not(any(unix, windows)))]
fn terminate_child(child: &mut Child) {
wait_or_kill(child, Duration::from_secs(0));
}
fn wait_or_kill(child: &mut Child, graceful_timeout: Duration) {
let deadline = Instant::now() + graceful_timeout;
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(100)),
Ok(None) | Err(_) => break,
}
}
force_kill_child(child);
let _ = child.wait();
}
#[cfg(windows)]
fn force_kill_child(child: &mut Child) {
let pid = child.id().to_string();
let _ = Command::new("taskkill")
.args(["/PID", &pid, "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let _ = child.kill();
}
#[cfg(not(windows))]
fn force_kill_child(child: &mut Child) {
let _ = child.kill();
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn command_spec_uses_headless_launcher_and_loopback_env() {
let runtime_dir = temp_runtime_dir("command-spec");
let mut inherited = BTreeMap::new();
inherited.insert(OsString::from("CUSTOM_ENV"), OsString::from("kept"));
inherited.insert(
OsString::from(DESKTOP_HEALTH_TOKEN_ENV),
OsString::from("health-token"),
);
inherited.insert(OsString::from("PATH"), OsString::from("/existing/bin"));
let spec = build_backend_command(
&runtime_dir,
48123,
inherited,
Some(Path::new("/home/example")),
)
.expect("command spec");
assert_eq!(spec.program, runtime_dir.join("bin").join(executable_name("kandev")));
assert_eq!(
spec.args,
vec![
OsString::from("--headless"),
OsString::from("--port"),
OsString::from("48123"),
]
);
assert_eq!(spec.env.get(OsStr::new("CUSTOM_ENV")), Some(&OsString::from("kept")));
assert_eq!(
spec.env.get(OsStr::new("KANDEV_SERVER_HOST")),
Some(&OsString::from(LOOPBACK_HOST))
);
assert_eq!(
spec.env.get(OsStr::new("KANDEV_BUNDLE_DIR")),
Some(&runtime_dir.as_os_str().to_os_string())
);
assert_eq!(
spec.env.get(OsStr::new(DESKTOP_HEALTH_TOKEN_ENV)),
Some(&OsString::from("health-token"))
);
}
#[test]
fn desktop_environment_preserves_path_and_adds_gui_paths() {
let runtime_dir = Path::new("/opt/kandev");
let home_dir = Path::new("/home/example");
let mut inherited = BTreeMap::new();
inherited.insert(OsString::from("PATH"), OsString::from("/existing/bin"));
let env = desktop_environment(runtime_dir, inherited, Some(home_dir));
let path = env.get(OsStr::new("PATH")).expect("PATH");
let entries: Vec<PathBuf> = env::split_paths(path).collect();
assert_eq!(entries.first(), Some(&PathBuf::from("/existing/bin")));
assert!(entries.contains(&PathBuf::from("/usr/local/bin")) || cfg!(windows));
assert!(entries.contains(&home_dir.join(".local/bin")));
}
#[test]
fn missing_launcher_returns_readable_error() {
let dir = temp_root("missing-launcher");
let err =
build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing launcher");
assert!(err.contains("Kandev launcher binary is missing"), "{err}");
}
#[test]
fn missing_agentctl_returns_readable_error() {
let dir = temp_root("missing-agentctl");
let bin = dir.join("bin");
fs::create_dir_all(&bin).expect("create bin");
fs::write(bin.join(executable_name("kandev")), b"stub").expect("write launcher");
let err =
build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing agentctl");
assert!(err.contains("agentctl binary is missing"), "{err}");
}
#[test]
fn missing_linux_helper_returns_readable_error() {
let dir = temp_root("missing-linux-helper");
let bin = dir.join("bin");
fs::create_dir_all(&bin).expect("create bin");
fs::write(bin.join(executable_name("kandev")), b"stub").expect("write launcher");
fs::write(bin.join(executable_name("agentctl")), b"stub").expect("write agentctl");
let err =
build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing helper");
assert!(err.contains("agentctl linux/amd64 helper is missing"), "{err}");
}
#[test]
fn pick_loopback_port_returns_valid_port() {
let port = pick_loopback_port().expect("loopback port");
assert_ne!(port, 0);
}
#[test]
fn preferred_desktop_port_defaults_to_stable_origin() {
assert_eq!(
preferred_desktop_port(None).expect("default desktop port"),
DEFAULT_DESKTOP_PORT
);
}
#[test]
fn preferred_desktop_port_accepts_env_override() {
assert_eq!(
preferred_desktop_port(Some(OsString::from("49152"))).expect("env desktop port"),
49152
);
}
#[test]
fn preferred_desktop_port_rejects_invalid_env_override() {
let err = preferred_desktop_port(Some(OsString::from("0"))).expect_err("zero port");
assert!(err.contains(DESKTOP_PORT_ENV), "{err}");
}
#[test]
fn desktop_health_token_is_hex_encoded() {
let token = desktop_health_token().expect("desktop health token");
assert_eq!(token.len(), 64);
assert!(token.chars().all(|ch| ch.is_ascii_hexdigit()), "{token}");
}
#[test]
fn pick_available_loopback_port_returns_preferred_port() {
let listener = TcpListener::bind((LOOPBACK_HOST, 0)).expect("reserve candidate");
let port = listener.local_addr().expect("candidate address").port();
drop(listener);
assert_eq!(
pick_available_loopback_port(port).expect("preferred port"),
port
);
}
#[test]
fn pick_available_loopback_port_falls_back_when_preferred_port_is_taken() {
let listener = TcpListener::bind((LOOPBACK_HOST, 0)).expect("reserve occupied port");
let occupied = listener.local_addr().expect("occupied address").port();
let picked = pick_available_loopback_port(occupied).expect("fallback port");
assert_ne!(picked, occupied);
assert_ne!(picked, 0);
}
#[test]
fn shutdown_request_is_one_shot() {
let state = BackendState::default();
assert!(state.begin_shutdown());
assert!(!state.begin_shutdown());
}
#[test]
fn stop_marks_shutdown_started() {
let state = BackendState::default();
state.stop();
assert!(state.is_shutdown_started());
}
#[test]
fn launcher_exit_message_includes_recent_output() {
let message = launcher_exit_message("exit status: 1", Some("database failed".to_string()));
assert!(message.contains("exit status: 1"));
assert!(message.contains("Recent backend output"));
assert!(message.contains("database failed"));
}
#[test]
fn startup_output_is_bounded() {
let mut output = StartupOutput::default();
let chunk = vec![b'x'; STARTUP_OUTPUT_LIMIT + 256];
output.push("stderr", &chunk);
assert!(output.bytes.len() <= STARTUP_OUTPUT_LIMIT);
}
#[test]
fn capture_stream_retries_interrupted_reads() {
let output = Arc::new(Mutex::new(StartupOutput::default()));
capture_stream("stdout", InterruptedThenData::new(b"backend ready"), output.clone());
let deadline = Instant::now() + Duration::from_secs(1);
while Instant::now() < deadline {
if output
.lock()
.expect("startup output mutex poisoned")
.text()
.is_some_and(|text| text.contains("backend ready"))
{
return;
}
thread::sleep(Duration::from_millis(10));
}
panic!("capture_stream did not retry interrupted read");
}
#[test]
fn read_http_response_head_reads_past_short_first_chunk() {
let mut reader = ShortReader::new(
b"HTTP/1.1 200 OK\r\nx-kandev-desktop-health-token: token\r\n\r\nignored",
4,
);
let prefix = read_http_response_head(&mut reader).expect("response head");
assert!(prefix.starts_with(b"HTTP/1.1 200"));
assert!(
prefix.windows(4).any(|window| window == b"\r\n\r\n"),
"response head should include the header terminator"
);
}
#[test]
fn response_header_check_requires_matching_desktop_health_token() {
let response = b"HTTP/1.1 200 OK\r\nX-Kandev-Desktop-Health-Token: token\r\n\r\n";
assert!(response_has_header(response, DESKTOP_HEALTH_TOKEN_HEADER, "token"));
assert!(!response_has_header(response, DESKTOP_HEALTH_TOKEN_HEADER, "other-token"));
assert!(!response_has_header(
b"HTTP/1.1 200 OK\r\n\r\n",
DESKTOP_HEALTH_TOKEN_HEADER,
"token"
));
}
#[cfg(unix)]
#[test]
fn stop_terminates_tracked_child() {
let state = BackendState::default();
let child = Command::new("sh")
.arg("-c")
.arg("trap 'exit 0' TERM; while true; do sleep 1; done")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn long-running child");
let pid = child.id();
assert!(state.set_child(child));
state.stop();
assert!(!process_exists(pid), "backend child {pid} should be terminated");
}
#[cfg(unix)]
#[test]
fn set_child_terminates_child_when_shutdown_already_started() {
let state = BackendState::default();
let child = Command::new("sh")
.arg("-c")
.arg("trap 'exit 0' TERM; while true; do sleep 1; done")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn long-running child");
let pid = child.id();
state.stop();
assert!(!state.set_child(child));
assert!(!process_exists(pid), "backend child {pid} should be terminated");
}
fn temp_runtime_dir(name: &str) -> PathBuf {
let dir = temp_root(name);
let bin = dir.join("bin");
fs::create_dir_all(&bin).expect("create bin");
fs::write(bin.join(executable_name("kandev")), b"stub").expect("write launcher");
fs::write(bin.join(executable_name("agentctl")), b"stub").expect("write agentctl");
fs::write(bin.join("agentctl-linux-amd64"), b"stub").expect("write linux helper");
dir
}
fn temp_root(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!(
"kandev-desktop-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp root");
dir
}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
struct ShortReader {
data: &'static [u8],
position: usize,
chunk_size: usize,
}
impl ShortReader {
fn new(data: &'static [u8], chunk_size: usize) -> Self {
Self {
data,
position: 0,
chunk_size,
}
}
}
impl Read for ShortReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if self.position >= self.data.len() {
return Ok(0);
}
let len = buffer
.len()
.min(self.chunk_size)
.min(self.data.len() - self.position);
buffer[..len].copy_from_slice(&self.data[self.position..self.position + len]);
self.position += len;
Ok(len)
}
}
struct InterruptedThenData {
data: &'static [u8],
interrupted: bool,
drained: bool,
}
impl InterruptedThenData {
fn new(data: &'static [u8]) -> Self {
Self {
data,
interrupted: false,
drained: false,
}
}
}
impl Read for InterruptedThenData {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if !self.interrupted {
self.interrupted = true;
return Err(std::io::Error::from(ErrorKind::Interrupted));
}
if self.drained {
return Ok(0);
}
let len = buffer.len().min(self.data.len());
buffer[..len].copy_from_slice(&self.data[..len]);
self.drained = true;
Ok(len)
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod backend;
+50
View File
@@ -0,0 +1,50 @@
use kandev_desktop::backend;
use std::thread;
use tauri::{Manager, RunEvent, WindowEvent};
fn main() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}))
.manage(backend::BackendState::default())
.setup(|app| {
let window = app
.get_webview_window("main")
.expect("main window should exist");
backend::start_desktop_backend(app.handle().clone(), window);
Ok(())
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
let state = window.app_handle().state::<backend::BackendState>().inner().clone();
if state.begin_shutdown() {
api.prevent_close();
let window = window.clone();
thread::spawn(move || {
state.stop();
let _ = window.close();
});
}
}
})
.build(tauri::generate_context!())
.expect("error while building Kandev desktop app");
app.run(|app_handle, event| {
if let RunEvent::ExitRequested { api, .. } = event {
let state = app_handle.state::<backend::BackendState>().inner().clone();
if state.begin_shutdown() {
api.prevent_exit();
let app_handle = app_handle.clone();
thread::spawn(move || {
state.stop();
app_handle.exit(0);
});
}
}
});
}
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Kandev",
"version": "0.1.0",
"identifier": "dev.kandev.desktop",
"build": {
"beforeDevCommand": "pnpm dev:vite",
"beforeBuildCommand": "pnpm build:vite",
"devUrl": "http://127.0.0.1:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Kandev",
"width": 1280,
"height": 900,
"minWidth": 960,
"minHeight": 640,
"resizable": true,
"fullscreen": false
}
],
"security": {
"capabilities": ["default"],
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; frame-ancestors 'none'"
}
},
"bundle": {
"active": true,
"targets": "all",
"publisher": "Kandev",
"homepage": "https://github.com/kdlbs/kandev",
"resources": {
"resources/kandev": "kandev"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": {
"digestAlgorithm": "sha256",
"timestampUrl": "https://timestamp.digicert.com",
"signCommand": "powershell -NoProfile -ExecutionPolicy Bypass -File ./windows-sign.ps1 %1"
}
}
}
+72
View File
@@ -0,0 +1,72 @@
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$ErrorActionPreference = "Stop"
$missing = @()
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE)) { $missing += "WINDOWS_CERTIFICATE" }
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)) { $missing += "WINDOWS_CERTIFICATE_PASSWORD" }
if ($missing.Count -gt 0) {
if ($env:ALLOW_UNSIGNED_DESKTOP -eq "true") {
Write-Warning "Skipping Windows signing for internal unsigned desktop validation; missing: $($missing -join ', ')"
exit 0
}
throw "Public Windows desktop releases require signing inputs: $($missing -join ', ')"
}
if (!(Test-Path $Path)) {
throw "Cannot sign missing Windows artifact: $Path"
}
$certificatePath = Join-Path $env:RUNNER_TEMP "kandev-code-signing.pfx"
[IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
$timestampUrl = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_TIMESTAMP_URL)) {
"https://timestamp.digicert.com"
} else {
$env:WINDOWS_TIMESTAMP_URL
}
$signTool = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNTOOL_PATH)) {
"signtool"
} else {
$env:WINDOWS_SIGNTOOL_PATH
}
function Invoke-SignTool {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[Parameter(Mandatory = $true)]
[string]$Description
)
& $signTool @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Description failed with exit code $LASTEXITCODE"
}
}
try {
Invoke-SignTool `
-Description "signtool sign" `
-Arguments @(
"sign",
"/fd", "SHA256",
"/td", "SHA256",
"/tr", $timestampUrl,
"/f", $certificatePath,
"/p", $env:WINDOWS_CERTIFICATE_PASSWORD,
$Path
)
Invoke-SignTool `
-Description "signtool verify" `
-Arguments @("verify", "/pa", $Path)
} finally {
if (Test-Path -LiteralPath $certificatePath) {
Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue
}
}
+33
View File
@@ -0,0 +1,33 @@
import "./styles.css";
type DesktopStatus = {
title: string;
detail: string;
failed?: boolean;
};
declare global {
interface Window {
__KANDEV_DESKTOP_SET_STATUS?: (status: DesktopStatus) => void;
__KANDEV_DESKTOP_PENDING_STATUS?: DesktopStatus;
}
}
const title = document.querySelector<HTMLHeadingElement>("#status-title");
const detail = document.querySelector<HTMLParagraphElement>("#status-detail");
const shell = document.querySelector<HTMLElement>(".startup-shell");
const applyStatus = (status: DesktopStatus) => {
if (title) {
title.textContent = status.title;
}
if (detail) {
detail.textContent = status.detail;
}
shell?.classList.toggle("is-failed", status.failed === true);
};
window.__KANDEV_DESKTOP_SET_STATUS = applyStatus;
if (window.__KANDEV_DESKTOP_PENDING_STATUS) {
applyStatus(window.__KANDEV_DESKTOP_PENDING_STATUS);
}
+116
View File
@@ -0,0 +1,116 @@
:root {
color: #18201f;
background: #f5f7f6;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
.startup-shell {
align-items: center;
background:
linear-gradient(135deg, rgba(18, 117, 82, 0.08), transparent 34%),
linear-gradient(315deg, rgba(117, 85, 18, 0.08), transparent 38%), #f5f7f6;
display: flex;
min-height: 100vh;
padding: 32px;
}
.startup-panel {
align-items: center;
background: #ffffff;
border: 1px solid #d8dfdc;
border-radius: 8px;
box-shadow: 0 24px 70px rgba(24, 32, 31, 0.14);
display: grid;
gap: 20px;
grid-template-columns: 56px minmax(0, 1fr);
margin: 0 auto;
max-width: 560px;
min-height: 168px;
padding: 28px;
width: min(100%, 560px);
}
.status-mark {
align-self: start;
background: conic-gradient(from 90deg, #16875f, #c48a2a, #16875f);
border-radius: 999px;
height: 44px;
position: relative;
width: 44px;
}
.status-mark::after {
background: #ffffff;
border-radius: inherit;
content: "";
inset: 6px;
position: absolute;
}
.startup-copy {
min-width: 0;
}
.eyebrow {
color: #53625e;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0;
line-height: 1.4;
margin: 0 0 6px;
text-transform: uppercase;
}
h1 {
color: #18201f;
font-size: 1.55rem;
line-height: 1.2;
margin: 0;
overflow-wrap: anywhere;
}
#status-detail {
color: #53625e;
font-size: 0.96rem;
line-height: 1.55;
margin: 10px 0 0;
overflow-wrap: anywhere;
}
.startup-shell.is-failed .status-mark {
background: conic-gradient(from 90deg, #b42318, #c48a2a, #b42318);
}
.startup-shell.is-failed h1 {
color: #9d241b;
}
@media (max-width: 520px) {
.startup-shell {
align-items: stretch;
padding: 16px;
}
.startup-panel {
align-content: center;
grid-template-columns: 1fr;
justify-items: start;
min-height: 100%;
padding: 22px;
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["vite/client"]
},
"include": ["src/**/*.ts"]
}
+130
View File
@@ -68,6 +68,18 @@ importers:
specifier: ^1.6.0
version: 1.6.1(@types/node@20.19.28)(happy-dom@20.8.9)(lightningcss@1.32.0)
desktop:
devDependencies:
'@tauri-apps/cli':
specifier: 2.8.4
version: 2.8.4
typescript:
specifier: ^5
version: 5.9.3
vite:
specifier: ^8.0.16
version: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)
packages/theme:
dependencies:
shadcn:
@@ -2937,6 +2949,77 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tauri-apps/cli-darwin-arm64@2.8.4':
resolution: {integrity: sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.8.4':
resolution: {integrity: sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.8.4':
resolution: {integrity: sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.8.4':
resolution: {integrity: sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-arm64-musl@2.8.4':
resolution: {integrity: sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-riscv64-gnu@2.8.4':
resolution: {integrity: sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@tauri-apps/cli-linux-x64-gnu@2.8.4':
resolution: {integrity: sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-linux-x64-musl@2.8.4':
resolution: {integrity: sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-win32-arm64-msvc@2.8.4':
resolution: {integrity: sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.8.4':
resolution: {integrity: sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.8.4':
resolution: {integrity: sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.8.4':
resolution: {integrity: sha512-ejUZBzuQRcjFV+v/gdj/DcbyX/6T4unZQjMSBZwLzP/CymEjKcc2+Fc8xTORThebHDUvqoXMdsCZt8r+hyN15g==}
engines: {node: '>= 10'}
hasBin: true
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -9020,6 +9103,53 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
'@tauri-apps/cli-darwin-arm64@2.8.4':
optional: true
'@tauri-apps/cli-darwin-x64@2.8.4':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.8.4':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.8.4':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.8.4':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.8.4':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.8.4':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.8.4':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.8.4':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.8.4':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.8.4':
optional: true
'@tauri-apps/cli@2.8.4':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.8.4
'@tauri-apps/cli-darwin-x64': 2.8.4
'@tauri-apps/cli-linux-arm-gnueabihf': 2.8.4
'@tauri-apps/cli-linux-arm64-gnu': 2.8.4
'@tauri-apps/cli-linux-arm64-musl': 2.8.4
'@tauri-apps/cli-linux-riscv64-gnu': 2.8.4
'@tauri-apps/cli-linux-x64-gnu': 2.8.4
'@tauri-apps/cli-linux-x64-musl': 2.8.4
'@tauri-apps/cli-win32-arm64-msvc': 2.8.4
'@tauri-apps/cli-win32-ia32-msvc': 2.8.4
'@tauri-apps/cli-win32-x64-msvc': 2.8.4
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.28.6
+1
View File
@@ -1,4 +1,5 @@
packages:
- 'cli'
- 'desktop'
- 'web'
- 'packages/*'
@@ -0,0 +1,30 @@
# 0026: Tauri Desktop Shell Over Native Runtime
**Status:** accepted
**Date:** 2026-06-23
**Area:** frontend, backend, cli, infra
## Context
Kandev now has a native Go launcher/backend path and embeds the Vite SPA into the backend binary. Users can run Kandev from CLI-oriented channels, but a desktop install should open from the OS launcher, manage a local backend process, and avoid a production Node.js runtime. The desktop packaging choice needs to preserve the existing local-first backend, agent executors, and release versioning model.
## Decision
Add a Tauri v2 desktop app as a thin native shell over the existing Kandev runtime. The shell lives under `apps/desktop`, packages the platform runtime binaries as resources, launches the native Kandev launcher in headless mode, waits for the backend `/health` endpoint, and loads the existing Go-served SPA in a native WebView.
The installed desktop app will not include a Node.js runtime. Node.js and pnpm remain build-time tooling for Vite, TypeScript, Tauri CLI invocation, tests, and release automation.
Desktop launches set the backend to a loopback-only bind address and an app-selected local port. Public desktop releases require macOS Developer ID signing/notarization and Windows code signing before the artifacts are presented as trusted downloads. The first desktop implementation does not include Tauri's in-app updater; updates continue through GitHub release downloads/manual reinstall until a separate updater spec is approved.
## Consequences
The desktop app can reuse the existing backend, embedded web UI, data directory, agent runtime, and supervisor behavior instead of introducing a parallel application stack. Release artifacts grow by the Tauri shell and installer overhead, but avoid Electron's bundled Chromium and avoid a runtime Node.js dependency.
CI becomes more complex because desktop installers must be built on OS-specific runners and signing requires protected secrets. The desktop app also adds a new security boundary: the WebView must not get broad shell/filesystem permissions, and backend process spawning should stay in Rust-side Tauri code.
## Alternatives Considered
1. **Electron desktop app.** Rejected because it would add a bundled Chromium and Node.js runtime, increasing artifact size and conflicting with the no-production-Node direction established by ADR-0021 and ADR-0022.
2. **Rewrite the UI in a native toolkit.** Rejected because it would duplicate the existing React product surface and slow delivery without improving the backend/runtime model.
3. **Browser-only PWA.** Rejected because it does not solve OS-level desktop installation, app launcher integration, or process lifecycle ownership for the local backend.
4. **Tauri shell that embeds all UI assets separately from the Go backend.** Rejected for the first implementation because the backend already embeds and serves the release-quality Vite SPA. Duplicating the assets would increase packaging complexity and risk frontend/backend version drift.
+1
View File
@@ -31,3 +31,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0023 | [Active workspace cookie for boot state](0023-active-workspace-cookie.md) | accepted | backend, frontend | 2026-06-18 |
| 0024 | [Go-fronted Vite dev mode](0024-go-fronted-vite-dev-mode.md) | accepted | backend, frontend, cli | 2026-06-18 |
| 0025 | [Runtime cleanup uses `executors_running`](0025-runtime-cleanup-uses-executors-running.md) | accepted | backend | 2026-06-22 |
| 0026 | [Tauri desktop shell over native runtime](0026-tauri-desktop-shell.md) | accepted | frontend, backend, cli, infra | 2026-06-23 |
+67
View File
@@ -0,0 +1,67 @@
# Kandev Desktop App
Kandev desktop is a Tauri app that starts the native Kandev runtime locally and shows the existing Kandev UI in a system WebView. It does not require Node.js at runtime. Node.js, pnpm, Rust, and the Tauri CLI are build-time tools only.
## Install Artifacts
GitHub releases publish desktop artifacts alongside the existing runtime tarballs. Desktop artifact names start with `kandev-desktop-<platform>-` and each artifact has a `.sha256` checksum.
Supported desktop platforms:
- `macos-arm64`
- `macos-x64`
- `linux-x64`
- `linux-arm64`
- `windows-x64`
Public recommended macOS artifacts must be Developer ID signed and notarized. Public recommended Windows artifacts must be code signed. Linux artifacts are checksum-gated; package-manager signatures can be added later.
Unsigned macOS or Windows desktop artifacts are for internal validation only. They may require manual OS security bypasses and should not be presented as trusted public downloads.
## Runtime Requirements
The desktop app packages the native Kandev runtime binaries and starts:
```text
kandev --headless --port <local-port>
```
The backend binds to `127.0.0.1` and serves the same embedded Vite UI used by the CLI/Homebrew/npm runtime.
Platform WebView requirements:
- macOS: system WebKit.
- Windows: Microsoft WebView2 runtime.
- Linux: WebKitGTK and package dependencies installed by the `.deb`/`.rpm` package.
Kandev data still lives in the existing Kandev home directory, `~/.kandev` by default, unless overridden by existing environment/config settings.
## Updates
The first desktop release does not include an in-app Tauri updater. Update by downloading and installing a newer desktop artifact from GitHub Releases. Existing worktrees, settings, tasks, and the SQLite database remain in the Kandev data directory.
The npm and Homebrew channels continue to update through:
```bash
brew upgrade kandev
npm install -g kandev@latest
npx kandev@latest
```
Those channels are separate from desktop installers, even though all release artifacts share the same SemVer.
## Agent CLI Discovery
Desktop apps launched from an OS app launcher may not inherit the same shell initialization as a terminal. Kandev preserves available environment variables and adds common user binary directories such as `/usr/local/bin`, `/opt/homebrew/bin`, `/usr/bin`, `/bin`, `~/.local/bin`, and common agent install directories.
If an agent CLI is not discoverable from the desktop app:
- Set the agent profile command to the full executable path.
- Confirm the executable works from a normal terminal.
- Put custom install directories in a location visible to GUI apps, or configure them through OS-level environment settings rather than only shell startup files.
Existing explicit command paths in Kandev settings remain authoritative over `PATH` lookup.
## Release Trust
See [desktop-tauri-signing.md](desktop-tauri-signing.md) for the CI secrets and signing expectations used for trusted desktop releases.
+36
View File
@@ -0,0 +1,36 @@
# Tauri Desktop Signing
Public recommended desktop releases require signing on macOS and Windows. The release workflow fails closed by default when those secrets are missing.
Use `allow_unsigned_desktop=true` only for internal validation builds. That mode may upload workflow artifacts for maintainer inspection, but it does not publish a GitHub release, npm packages, Homebrew updates, or public container tags. Unsigned desktop artifacts are internal validation only and must not be presented as trusted public downloads.
## macOS
Required signing secrets:
- `APPLE_CERTIFICATE`: base64 `.p12` Developer ID Application certificate.
- `APPLE_CERTIFICATE_PASSWORD`: export password for the `.p12`.
- `KEYCHAIN_PASSWORD`: temporary CI keychain password.
Required notarization secrets, choose one path:
- Apple ID path: `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`.
- App Store Connect API path: `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_P8`.
Optional:
- `APPLE_PROVIDER_SHORT_NAME` when the Apple ID belongs to multiple provider teams.
## Windows
Required signing secrets:
- `WINDOWS_CERTIFICATE`: base64 `.pfx` code signing certificate.
- `WINDOWS_CERTIFICATE_PASSWORD`: export password for the `.pfx`.
Optional:
- `WINDOWS_TIMESTAMP_URL`: timestamp server, defaults to `http://timestamp.digicert.com`.
- `WINDOWS_SIGNTOOL_PATH`: custom `signtool.exe` path.
Linux desktop artifacts are checksum-gated. The x64 `.deb`/`.rpm` artifacts are built on Ubuntu 22.04 for an older glibc baseline. The arm64 artifacts use GitHub's Ubuntu 24.04 arm64 runner baseline. GPG/RPM signing can be added later without changing the macOS and Windows trust gate.
+1
View File
@@ -25,6 +25,7 @@ This page expands the short feature list in the README without turning the READM
## Executors And Runtime
- **Desktop app:** install Kandev as a Tauri desktop app that starts the local backend and shows the existing Kandev UI without requiring Node.js at runtime. See [desktop-app.md](desktop-app.md).
- **Executor types:** run agents as local host processes, git worktrees, Docker containers, remote SSH sessions, or Sprites cloud environments.
- **Executor profiles:** save per-runtime configuration, including prepare scripts, environment variables, credentials, and profile-specific settings.
- **Worktree isolation:** concurrent agents work in isolated git worktrees so their changes do not collide.
+259
View File
@@ -0,0 +1,259 @@
---
spec: docs/specs/desktop-tauri-app/spec.md
created: 2026-06-23
status: done
---
# Implementation Plan: Tauri Desktop App
## Overview
Add `apps/desktop` as a Tauri v2 shell that packages the existing native Kandev runtime, starts it in headless mode, waits for backend health, and loads the Go-served SPA in a desktop WebView. The implementation starts by tightening the backend/launcher contract for loopback desktop launches, then scaffolds the app, wires release packaging, adds signing gates, and finishes with desktop smoke coverage and docs.
---
## Architecture Records
- Spec: `docs/specs/desktop-tauri-app/spec.md`
- Decision: `docs/decisions/0026-tauri-desktop-shell.md`
- Existing dependencies: ADR-0021 Go-served SPA with boot state, ADR-0022 embedded Vite assets, and the native launcher spec at `docs/specs/native-kandev-cli/spec.md`.
---
## Backend
### Loopback bind contract
Files:
- `apps/backend/internal/common/config/config.go`
- `apps/backend/internal/backendapp/main.go`
- `apps/backend/internal/backendapp/helpers_test.go` or a focused backend server test
- `apps/backend/internal/launcher/env.go`
- `apps/backend/internal/launcher/start_test.go` or `apps/backend/internal/launcher/run_test.go`
Use the existing `ServerConfig.Host` field when constructing the HTTP server address and listener address. Preserve the current default unless a caller sets `server.host` / `KANDEV_SERVER_HOST`. The desktop app will set `KANDEV_SERVER_HOST=127.0.0.1`.
Add tests proving:
- `buildHTTPServer` uses the configured host in `http.Server.Addr`.
- `startHTTPServer` listens on the configured host/port.
- launcher env construction can pass a backend host override for desktop/headless launches without changing normal CLI defaults.
### Headless launcher lifecycle contract
Files:
- `apps/backend/internal/launcher/start.go`
- `apps/backend/internal/launcher/health.go`
- `apps/backend/internal/launcher/process.go`
- Existing launcher tests under `apps/backend/internal/launcher/`
Keep `kandev --headless --port <port>` as the desktop process contract. It must supervise `kandev __backend`, wait for `/health`, skip opening a browser, and remain alive until the backend exits or the parent process terminates. Desktop-specific process termination should use the same process-group/job-object cleanup behavior already covered by the native launcher.
---
## Desktop App
### Workspace package
Files:
- `apps/pnpm-workspace.yaml`
- `apps/package.json`
- New `apps/desktop/package.json`
- New `apps/desktop/index.html`
- New `apps/desktop/src/main.ts`
- New `apps/desktop/src/styles.css`
- New `apps/desktop/src-tauri/Cargo.toml`
- New `apps/desktop/src-tauri/build.rs`
- New `apps/desktop/src-tauri/tauri.conf.json`
- New `apps/desktop/src-tauri/src/main.rs`
- New `apps/desktop/src-tauri/src/backend.rs`
- New `apps/desktop/src-tauri/capabilities/default.json`
Add a minimal Tauri/Vite desktop package named `@kandev/desktop`. The app's first screen is a compact startup surface, not a marketing page. Rust-side Tauri code locates the packaged runtime resources, picks an available loopback port, spawns the native launcher in headless mode, polls `/health`, and navigates the main window to the backend URL.
Do not expose shell execution to frontend JavaScript. Backend process control should live in Rust-side Tauri code.
### Runtime resources
Files:
- New `scripts/release/prepare-desktop-runtime.sh`
- New `scripts/release/verify-desktop-runtime.sh`
- `apps/desktop/src-tauri/tauri.conf.json`
- `apps/desktop/src-tauri/src/backend.rs`
Create a deterministic resource layout for the platform runtime extracted from the existing release bundle:
```text
apps/desktop/src-tauri/resources/kandev/
└── bin/
├── kandev[.exe]
├── agentctl[.exe]
└── agentctl-linux-amd64
```
The helper validates the exact binaries required by `internal/launcher/bundle.go` so the desktop app and existing runtime tarballs cannot drift.
### Desktop process environment
Files:
- `apps/desktop/src-tauri/src/backend.rs`
- `apps/backend/internal/launcher/env.go`
- `apps/backend/internal/launcher/service.go` for existing PATH precedent
- `docs/desktop-tauri-signing.md` or desktop install docs for troubleshooting notes
Build the backend process environment explicitly. Preserve inherited variables, force `KANDEV_SERVER_HOST=127.0.0.1`, and normalize `PATH` for GUI app launches so agent CLIs installed under common user locations are visible. Existing explicit agent command paths in settings remain authoritative; this task only makes default command lookup reliable.
### Desktop UX
Files:
- `apps/desktop/src/main.ts`
- `apps/desktop/src/styles.css`
- `apps/desktop/src-tauri/src/main.rs`
- `apps/desktop/src-tauri/src/backend.rs`
The startup surface shows only launch status and errors. Once the backend is ready, the WebView loads the existing Kandev UI. The app should support standard OS close/quit behavior and single-instance focus.
---
## Release Pipeline
### Build matrix
Files:
- `.github/workflows/release.yml`
- `scripts/release/verify-desktop-assets.sh`
- `apps/cli/src/release-config.test.ts`
Add a `build-desktop` job that depends on `build-bundles`, downloads the matching `bundle-<platform>` artifact, prepares desktop runtime resources, runs the Tauri build on the appropriate OS runner, checks artifact sizes/checksums, and uploads `desktop-<platform>` artifacts.
The initial matrix should mirror current runtime support:
- `macos-arm64`
- `macos-x64`
- `linux-x64`
- `linux-arm64`
- `windows-x64`
### GitHub release publishing
Files:
- `.github/workflows/release.yml`
- `scripts/release/verify-desktop-assets.sh`
- `scripts/release/update-homebrew-tap.sh` only if release asset assumptions need to ignore desktop artifacts
Update `publish-release` to download and publish desktop artifacts alongside the existing `kandev-*.tar.gz` runtime bundles. Keep existing npm and Homebrew publishing tied to runtime tarballs only.
### Signing
Files:
- `.github/workflows/release.yml`
- `apps/desktop/src-tauri/tauri.conf.json`
- New `docs/desktop-tauri-signing.md`
- GitHub Actions release docs/comments in `.github/workflows/release.yml`
Add signing inputs/secrets behind conditional release steps:
- macOS: Developer ID Application certificate, Apple team/provider metadata, notarization credentials.
- Windows: certificate/key-vault or custom signing command wiring.
- Linux: checksum required; GPG/AppImage signing optional.
Unsigned desktop builds remain allowed for dry runs and internal validation, but public recommended artifacts should require signing success on macOS and Windows.
---
## Frontend
The existing `apps/web` product UI should not need route or state changes. Desktop-specific frontend work is limited to the Tauri startup surface under `apps/desktop`. If implementation discovers that the existing SPA needs to distinguish desktop WebView mode, add the smallest runtime flag necessary and document it in the spec before broadening scope.
---
## Tests
- **What:** backend honors configured host when constructing server addresses.
**File:** `apps/backend/internal/backendapp/helpers_test.go` or a new focused test file.
**How:** table-driven Go test over host/port config.
- **What:** launcher/headless env includes desktop host override only when requested.
**File:** `apps/backend/internal/launcher/start_test.go` or `run_test.go`.
**How:** Go unit test around env/config helpers.
- **What:** missing runtime resource binaries are detected before desktop launch.
**File:** `apps/desktop/src-tauri/src/backend.rs` tests.
**How:** Rust unit tests with temporary resource directories.
- **What:** backend command builder passes `--headless`, `--port`, and loopback env.
**File:** `apps/desktop/src-tauri/src/backend.rs` tests.
**How:** Rust unit tests over pure command-building helpers.
- **What:** desktop process environment preserves user env and adds common GUI-missing binary paths.
**File:** `apps/desktop/src-tauri/src/backend.rs` tests.
**How:** Rust unit tests around environment construction.
- **What:** desktop startup handles health success, timeout, and child-process exit.
**File:** `apps/desktop/src-tauri/src/backend.rs` tests.
**How:** Rust tests using a local fake HTTP server or injectable health client.
- **What:** release workflow requires desktop artifacts and leaves npm/Homebrew runtime publishing unchanged.
**File:** `apps/cli/src/release-config.test.ts` or a shell test under `scripts/`.
**How:** parse workflow/scripts and assert expected job names, artifacts, and publish filters.
- **What:** desktop runtime resource preparation validates the existing bundle shape.
**File:** script test under `scripts/` or `apps/cli/src/release-config.test.ts`.
**How:** create fake bundle directories and assert success/failure paths.
---
## E2E Tests
- **Scenario:** GIVEN a prepared Linux desktop runtime bundle, WHEN the desktop app is launched in CI, THEN the Tauri window loads the Kandev UI without Node.js at runtime.
**File:** `apps/desktop/e2e/desktop-launch.spec.ts` or an equivalent Tauri WebDriver test.
**What to verify:** startup screen transitions to the Kandev app shell and `/health` succeeds.
- **Scenario:** GIVEN the backend child fails during startup, WHEN the desktop app is launched, THEN the startup surface shows a failure and no child process remains running.
**File:** `apps/desktop/e2e/desktop-launch.spec.ts` or Rust integration test if WebDriver cannot observe process state reliably.
**What to verify:** visible error state plus process cleanup.
- **Scenario:** GIVEN the desktop app is already running, WHEN a second instance is opened, THEN the first window is focused and only one backend launcher process exists.
**File:** `apps/desktop/e2e/desktop-single-instance.spec.ts` or Rust integration test.
**What to verify:** single-instance behavior and no duplicate backend process.
Tauri desktop E2E should run on Linux with a fake display in CI first. macOS and Windows desktop smoke tests can be added after the Linux harness is stable.
---
## Implementation Waves
Wave 1:
- [x] [task-01-backend-desktop-launch-contract](task-01-backend-desktop-launch-contract.md)
Wave 2:
- [x] [task-02-tauri-desktop-scaffold](task-02-tauri-desktop-scaffold.md)
- [x] [task-03-desktop-runtime-resources](task-03-desktop-runtime-resources.md)
Wave 3:
- [x] [task-04-release-desktop-artifacts](task-04-release-desktop-artifacts.md)
- [x] [task-05-signing-and-release-trust](task-05-signing-and-release-trust.md)
Wave 4:
- [x] [task-06-desktop-e2e-smoke](task-06-desktop-e2e-smoke.md)
- [x] [task-07-docs-and-release-notes](task-07-docs-and-release-notes.md)
---
## Open Questions
- Which Windows installer target should be the public default: NSIS `.exe` or MSI? Recommendation: start with the Tauri default/recommended installer for direct GitHub release downloads, then add MSI only if enterprise deployment requires it.
- Which Linux artifact set should be public by default: AppImage only, `.deb` plus AppImage, or `.deb`/`.rpm` plus AppImage? Recommendation: start with AppImage plus the package type Tauri produces reliably in CI, then broaden after QA.
@@ -0,0 +1,47 @@
---
id: "01-backend-desktop-launch-contract"
title: "Backend desktop launch contract"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/desktop-tauri-app/spec.md"
---
# Task 01: Backend Desktop Launch Contract
## Acceptance
- The backend honors `server.host` / `KANDEV_SERVER_HOST` when binding the HTTP listener.
- Desktop/headless launch can force `127.0.0.1` without changing normal CLI/service defaults.
- `kandev --headless --port <port>` remains a stable process contract for a parent desktop app to supervise.
## Verification
```bash
rtk bash -lc 'cd apps/backend && go test -tags fts5 ./internal/backendapp ./internal/launcher ./internal/common/config'
```
## Files Likely Touched
- `apps/backend/internal/common/config/config.go`
- `apps/backend/internal/backendapp/main.go`
- `apps/backend/internal/backendapp/helpers_test.go`
- `apps/backend/internal/launcher/env.go`
- `apps/backend/internal/launcher/start.go`
- `apps/backend/internal/launcher/start_test.go`
- `apps/backend/internal/launcher/run_test.go`
## Dependencies
None.
## Inputs
- Spec sections: API surface > Desktop launch contract; API surface > Backend bind contract; Failure modes.
- Existing patterns: native launcher in `apps/backend/internal/launcher/`, backend HTTP server setup in `apps/backend/internal/backendapp/main.go`.
- ADR: `docs/decisions/0026-tauri-desktop-shell.md`.
## Output Contract
When complete, update this file's `status` to `done`, update the Wave 1 checkbox in `plan.md`, and report changed files, tests run, blockers, and residual risks.
@@ -0,0 +1,55 @@
---
id: "02-tauri-desktop-scaffold"
title: "Tauri desktop scaffold"
status: done
wave: 2
depends_on: ["01-backend-desktop-launch-contract"]
plan: "plan.md"
spec: "../../specs/desktop-tauri-app/spec.md"
---
# Task 02: Tauri Desktop Scaffold
## Acceptance
- `apps/desktop` builds as a Tauri v2 desktop package in the pnpm workspace.
- The Tauri Rust side starts the native Kandev launcher in headless mode, polls `/health`, and navigates the main window to the backend URL.
- The backend process environment preserves inherited variables, forces loopback backend binding, and adds common GUI-missing user binary paths.
- Frontend JavaScript is not granted broad shell/filesystem permissions.
## Verification
```bash
rtk bash -lc 'cd apps && pnpm --filter @kandev/desktop build'
rtk bash -lc 'cd apps/desktop/src-tauri && cargo test'
```
## Files Likely Touched
- `apps/pnpm-workspace.yaml`
- `apps/package.json`
- `apps/desktop/package.json`
- `apps/desktop/index.html`
- `apps/desktop/src/main.ts`
- `apps/desktop/src/styles.css`
- `apps/desktop/src-tauri/Cargo.toml`
- `apps/desktop/src-tauri/build.rs`
- `apps/desktop/src-tauri/tauri.conf.json`
- `apps/desktop/src-tauri/src/main.rs`
- `apps/desktop/src-tauri/src/backend.rs`
- `apps/desktop/src-tauri/capabilities/default.json`
- `apps/backend/internal/launcher/service.go` only as a reference for existing service PATH conventions
## Dependencies
- Task 01.
## Inputs
- Spec sections: What, State machine, Permissions, Failure modes.
- Plan sections: Desktop App > Workspace package; Desktop App > Desktop UX.
- Existing runtime contract from Task 01.
## Output Contract
When complete, update this file's `status` to `done`, update the Wave 2 checkbox in `plan.md`, and report changed files, tests run, blockers, and residual risks.
@@ -0,0 +1,48 @@
---
id: "03-desktop-runtime-resources"
title: "Desktop runtime resources"
status: done
wave: 2
depends_on: ["01-backend-desktop-launch-contract"]
plan: "plan.md"
spec: "../../specs/desktop-tauri-app/spec.md"
---
# Task 03: Desktop Runtime Resources
## Acceptance
- A release helper extracts the existing platform runtime bundle into the deterministic Tauri resource layout.
- The helper validates `kandev`, `agentctl`, and `agentctl-linux-amd64` requirements consistently with the native launcher.
- The Tauri app resolves packaged resources without relying on `KANDEV_BUNDLE_DIR` or Node.js at runtime.
## Verification
```bash
rtk bash -lc 'scripts/release/prepare-desktop-runtime.sh --help'
rtk bash -lc 'cd apps/desktop/src-tauri && cargo test'
rtk bash -lc 'cd apps && pnpm --filter @kandev/desktop build'
```
## Files Likely Touched
- `scripts/release/prepare-desktop-runtime.sh`
- `scripts/release/verify-desktop-runtime.sh`
- `apps/desktop/src-tauri/tauri.conf.json`
- `apps/desktop/src-tauri/src/backend.rs`
- `apps/desktop/src-tauri/resources/.gitignore`
- `apps/cli/src/release-config.test.ts` or a new script test if script behavior is tested from Node/Vitest
## Dependencies
- Task 01.
## Inputs
- Spec sections: API surface > Desktop artifact contract; Failure modes.
- Plan sections: Desktop App > Runtime resources.
- Existing bundle shape: `apps/cli/README_internal.md` and `scripts/release/package-bundle.sh`.
## Output Contract
When complete, update this file's `status` to `done`, update the Wave 2 checkbox in `plan.md`, and report changed files, tests run, blockers, and residual risks.
@@ -0,0 +1,47 @@
---
id: "04-release-desktop-artifacts"
title: "Release desktop artifacts"
status: done
wave: 3
depends_on: ["02-tauri-desktop-scaffold", "03-desktop-runtime-resources"]
plan: "plan.md"
spec: "../../specs/desktop-tauri-app/spec.md"
---
# Task 04: Release Desktop Artifacts
## Acceptance
- The release workflow builds desktop artifacts for macOS arm64, macOS x64, Linux x64, Linux arm64, and Windows x64.
- `publish-release` attaches desktop artifacts and checksums without changing npm/Homebrew runtime publishing behavior.
- Release verification fails when a required desktop artifact is missing.
## Verification
```bash
rtk bash -lc 'cd apps && pnpm --filter kandev test -- release-config'
rtk git diff --check
```
## Files Likely Touched
- `.github/workflows/release.yml`
- `scripts/release/verify-desktop-assets.sh`
- `scripts/release/prepare-desktop-runtime.sh`
- `apps/cli/src/release-config.test.ts`
- `apps/desktop/package.json`
## Dependencies
- Task 02.
- Task 03.
## Inputs
- Spec sections: API surface > Desktop artifact contract; Scenarios.
- Plan sections: Release Pipeline > Build matrix; Release Pipeline > GitHub release publishing.
- Existing workflow: `.github/workflows/release.yml`.
## Output Contract
When complete, update this file's `status` to `done`, update the Wave 3 checkbox in `plan.md`, and report changed files, tests run, blockers, and residual risks.
@@ -0,0 +1,51 @@
---
id: "05-signing-and-release-trust"
title: "Signing and release trust"
status: done
wave: 3
depends_on: ["04-release-desktop-artifacts"]
plan: "plan.md"
spec: "../../specs/desktop-tauri-app/spec.md"
---
# Task 05: Signing and Release Trust
## Acceptance
- macOS desktop release builds support Developer ID signing and notarization through GitHub Actions secrets.
- Windows desktop release builds support code signing through the selected signing mechanism.
- Unsigned desktop artifacts are clearly limited to dry runs/internal validation and are not treated as trusted public artifacts.
## Verification
```bash
rtk bash -lc 'cd apps && pnpm --filter kandev test -- release-config'
rtk git diff --check
```
Manual verification for maintainers:
```bash
rtk gh workflow run release.yml -f dry_run=true -f bump=patch
```
## Files Likely Touched
- `.github/workflows/release.yml`
- `apps/desktop/src-tauri/tauri.conf.json`
- `docs/desktop-tauri-signing.md`
- `apps/cli/src/release-config.test.ts`
## Dependencies
- Task 04.
## Inputs
- Spec sections: What; Permissions; Failure modes; Scenarios.
- Plan sections: Release Pipeline > Signing.
- Tauri signing docs for macOS, Windows, Linux, and updater signing constraints.
## Output Contract
When complete, update this file's `status` to `done`, update the Wave 3 checkbox in `plan.md`, and report changed files, tests run, blockers, and residual risks.
@@ -0,0 +1,48 @@
---
id: "06-desktop-e2e-smoke"
title: "Desktop E2E smoke"
status: done
wave: 4
depends_on: ["02-tauri-desktop-scaffold", "03-desktop-runtime-resources"]
plan: "plan.md"
spec: "../../specs/desktop-tauri-app/spec.md"
---
# Task 06: Desktop E2E Smoke
## Acceptance
- CI has at least one Linux desktop smoke test that launches the Tauri app with a prepared runtime bundle.
- The smoke test verifies the startup screen transitions to the Kandev UI after backend health succeeds.
- A failure-path test or Rust integration test verifies child process cleanup on startup failure.
## Verification
```bash
rtk bash -lc 'cd apps && pnpm --filter @kandev/desktop e2e'
rtk bash -lc 'cd apps/desktop/src-tauri && cargo test'
```
## Files Likely Touched
- `apps/desktop/e2e/desktop-launch.spec.ts`
- `apps/desktop/e2e/desktop-single-instance.spec.ts`
- `apps/desktop/e2e/README.md`
- `apps/desktop/package.json`
- `apps/desktop/src-tauri/src/backend.rs`
- `.github/workflows/release.yml` or a dedicated desktop test workflow
## Dependencies
- Task 02.
- Task 03.
## Inputs
- Spec sections: State machine; Failure modes; Scenarios.
- Plan sections: E2E Tests.
- Existing web E2E patterns in `apps/web/e2e/README.md`; Tauri WebDriver guidance for desktop smoke coverage.
## Output Contract
When complete, update this file's `status` to `done`, update the Wave 4 checkbox in `plan.md`, and report changed files, tests run, blockers, and residual risks.
@@ -0,0 +1,53 @@
---
id: "07-docs-and-release-notes"
title: "Docs and release notes"
status: done
wave: 4
depends_on: ["04-release-desktop-artifacts", "05-signing-and-release-trust", "06-desktop-e2e-smoke"]
plan: "plan.md"
spec: "../../specs/desktop-tauri-app/spec.md"
---
# Task 07: Docs and Release Notes
## Acceptance
- User docs explain desktop install artifacts, runtime requirements, update expectations, and signing/trust status.
- User docs explain GUI-launch environment differences and what to do when an agent CLI is not discoverable from the desktop app.
- Engineering docs mention the `apps/desktop` package and release workflow ownership.
- The spec status and plan task statuses are reconciled with the implementation state.
## Verification
```bash
rtk git diff --check
rtk bash -lc 'cd apps && pnpm format:check'
```
## Files Likely Touched
- `docs/specs/desktop-tauri-app/spec.md`
- `docs/plans/desktop-tauri-app/plan.md`
- `docs/desktop-tauri-signing.md`
- `docs/cli.md`
- `docs/features.md`
- `docs/remote-cloud-environment.md` if desktop build dependencies affect cloud dev setup
- `AGENTS.md`
- `apps/desktop/AGENTS.md`
- `apps/cli/README_internal.md`
## Dependencies
- Task 04.
- Task 05.
- Task 06.
## Inputs
- Spec sections: What; Out of scope.
- Plan sections: Release Pipeline; E2E Tests.
- Root `AGENTS.md` maintenance rule for changed package layout/release conventions.
## Output Contract
When complete, update this file's `status` to `done`, update the Wave 4 checkbox in `plan.md`, and report changed files, tests run, blockers, and residual risks.

Some files were not shown because too many files have changed in this diff Show More