diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml new file mode 100644 index 000000000..8b481d9a6 --- /dev/null +++ b/.github/workflows/desktop-build.yml @@ -0,0 +1,161 @@ +name: desktop-build + +# Builds the Kimi Desktop (Electron) installers for macOS, Windows and Linux. +# Each runner builds the matching-platform SEA backend first, then packages it +# with electron-builder. +# +# macOS is signed with a Developer ID certificate + notarized (so it opens on +# any Mac without the "app is damaged" Gatekeeper block) when `sign-macos` is +# true and the Apple secrets are configured. Windows/Linux ship unsigned in v1. +# +# Triggered two ways: +# - workflow_dispatch: manual ad-hoc builds from the Actions tab. +# - workflow_call: called by release.yml to attach installers to a release. +on: + workflow_dispatch: + inputs: + sign-macos: + description: 'Sign + notarize macOS (needs Apple secrets)' + required: false + type: boolean + default: true + retention-days: + description: 'Artifact retention in days' + required: false + type: number + default: 5 + upload-artifact-prefix: + description: 'Prefix for uploaded artifact name' + required: false + type: string + default: 'kimi-desktop' + workflow_call: + inputs: + sign-macos: + description: 'Sign + notarize macOS (needs Apple secrets)' + required: false + type: boolean + default: false + retention-days: + description: 'Artifact retention in days' + required: false + type: number + default: 7 + upload-artifact-prefix: + description: 'Prefix for uploaded artifact name' + required: false + type: string + default: 'kimi-desktop' + secrets: + APPLE_CERTIFICATE_P12: + required: false + APPLE_CERTIFICATE_PASSWORD: + required: false + APPLE_NOTARIZATION_KEY_P8: + required: false + APPLE_NOTARIZATION_KEY_ID: + required: false + APPLE_NOTARIZATION_ISSUER_ID: + required: false + +permissions: + contents: read + +jobs: + desktop: + name: Desktop installer (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + target: darwin-arm64 + - os: macos-15-intel + target: darwin-x64 + - os: windows-2025-vs2026 + target: win32-x64 + - os: ubuntu-24.04 + target: linux-x64 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Kimi web assets + # The SEA blob embeds apps/kimi-code/dist-web; build the web app and + # stage its assets before producing the native executable. + # KIMI_WEB_DESKTOP=1 bakes the internal-build banner into the web bundle + # (see apps/kimi-web/src/components/InternalBuildBanner.vue); only the + # desktop sets this flag, so the CLI `kimi web` stays banner-free. + env: + KIMI_WEB_DESKTOP: '1' + run: | + pnpm --filter @moonshot-ai/kimi-web run build + node apps/kimi-code/scripts/copy-web-assets.mjs + + - name: Build native executable (local profile) + # The Electron app signs the SEA itself (electron-builder, inside-out), + # so the native build stays unsigned here. + run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea + + - name: Setup macOS keychain (Developer ID) + if: runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-keychain-setup + with: + certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Prepare notarization API key (macOS) + if: runner.os == 'macOS' && inputs.sign-macos + shell: bash + env: + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + run: | + set -euo pipefail + key_path="$RUNNER_TEMP/notary-AuthKey.p8" + printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path" + echo "APPLE_API_KEY=$key_path" >> "$GITHUB_ENV" + + - name: Build & package desktop app + shell: bash + env: + # macOS signing is driven by env: when sign-macos, electron-builder + # signs with the keychain's Developer ID and notarizes via the notary + # API key; otherwise it builds unsigned. + CSC_IDENTITY_AUTO_DISCOVERY: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} + CSC_KEYCHAIN: ${{ env.APPLE_KEYCHAIN_PATH }} + CSC_NAME: ${{ env.APPLE_SIGNING_IDENTITY }} + KIMI_DESKTOP_NOTARIZE: ${{ (runner.os == 'macOS' && inputs.sign-macos) && 'true' || 'false' }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: pnpm --filter @moonshot-ai/kimi-desktop run dist + + - name: Cleanup macOS keychain + if: always() && runner.os == 'macOS' && inputs.sign-macos + uses: ./.github/actions/macos-keychain-cleanup + + - name: Upload installers + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }} + retention-days: ${{ inputs.retention-days }} + path: | + apps/kimi-desktop/dist-app/*.dmg + apps/kimi-desktop/dist-app/*.zip + apps/kimi-desktop/dist-app/*.exe + apps/kimi-desktop/dist-app/*.AppImage + apps/kimi-desktop/dist-app/*.deb + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f74dc9d9..16b9c0f12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,22 @@ jobs: APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + desktop-artifacts: + name: Desktop release artifact + needs: release + if: needs.release.outputs.kimi_native_release == 'true' + uses: ./.github/workflows/desktop-build.yml + with: + upload-artifact-prefix: kimi-desktop + retention-days: 7 + sign-macos: true + secrets: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + publish-native-assets: name: Publish native release assets needs: diff --git a/apps/kimi-desktop/.gitignore b/apps/kimi-desktop/.gitignore new file mode 100644 index 000000000..66de381f3 --- /dev/null +++ b/apps/kimi-desktop/.gitignore @@ -0,0 +1,3 @@ +out/ +dist-app/ +resources-stage/ diff --git a/apps/kimi-desktop/README.md b/apps/kimi-desktop/README.md new file mode 100644 index 000000000..32ca3ee64 --- /dev/null +++ b/apps/kimi-desktop/README.md @@ -0,0 +1,107 @@ +# Kimi Code Desktop + +An Electron desktop client for Kimi Code (product name **Kimi Code Desktop**; +workspace package `@moonshot-ai/kimi-desktop`). It is a thin **shell + process manager** +around the existing web UI (`apps/kimi-web`): it does not reimplement any UI or +backend, it just opens a native window onto the local Kimi server. + +## How it works + +The web UI cannot run on its own — it needs the Kimi Code **server** (REST + WS +under `/api/v1`). That server already ships as a self-contained single-file +executable (SEA) built from `apps/kimi-code`, with the web UI bundled inside it. + +On launch the app: + +1. Runs the bundled SEA's `server run`, which reuses a live shared daemon if one + is already running, or starts one — exactly the same `ensureDaemon` flow the + CLI (`kimi web`) uses. The daemon binds the well-known port (`58627`) and + writes `~/.kimi-code/server/lock`, so the CLI, the browser and the TUI all + share the **same** server. +2. Reads that lock file for the real port and loads the web UI from the daemon's + origin (e.g. `http://127.0.0.1:58627`) — same-origin, no CORS, no preload. + +On quit the daemon is **left running**; it self-exits ~60s after the last client +disconnects, so closing the desktop app never tears down a server another client +is still using. + +Key files: + +- `src/main/ensure-server.ts` — run the SEA, read the lock, confirm `/healthz`. +- `src/main/sea-path.ts` — resolve the bundled SEA path (dev vs packaged). +- `src/main/index.ts` — window, native menu, window-state, loading/error screens. + +## Develop + +The dev build loads the SEA from `apps/kimi-code/dist-native/bin//`, so +build the backend once for your platform first: + +```bash +# one-time (rebuild when kimi-code / kimi-web change): +pnpm --filter @moonshot-ai/kimi-web run build +node apps/kimi-code/scripts/copy-web-assets.mjs +pnpm --filter @moonshot-ai/kimi-code run build:native:sea + +# then run the desktop app (builds the main process, launches Electron): +pnpm -C apps/kimi-desktop run dev # or: pnpm dev:desktop (from repo root) +``` + +Checks: + +```bash +pnpm -C apps/kimi-desktop run typecheck +``` + +## Package + +`dist` builds the main process and runs electron-builder for the **current** +platform. `scripts/before-pack.cjs` stages the matching-platform SEA into the +app's resources (`/bin//`). + +```bash +# unsigned local build (for your own machine): +CSC_IDENTITY_AUTO_DISCOVERY=false pnpm -C apps/kimi-desktop run dist +# -> apps/kimi-desktop/dist-app/ +``` + +> Do **not** rename a built `.app` bundle — renaming invalidates its code +> signature and macOS will report it as "damaged". + +Cross-platform installers are produced in CI (`.github/workflows/desktop-build.yml`), +which builds the SEA on each platform runner and packages there. SEA injection +is per-platform (the blob is injected into the host Node binary), so each OS must +be built on its own runner. + +### macOS signing + notarization + +An **unsigned** macOS build shows *"app is damaged and can't be opened"* once it +has been transferred to another Mac (Gatekeeper quarantine). To distribute it, +the app must be signed with a **Developer ID Application** certificate and +notarized by Apple. The config (`electron-builder.config.cjs`) applies the +hardened runtime + entitlements (`build/entitlements.mac.plist`) to the app and +the nested SEA, and signing/notarization are environment-driven: + +```bash +KIMI_DESKTOP_NOTARIZE=true \ +CSC_NAME="Developer ID Application: … (TEAMID)" \ +APPLE_API_KEY=/path/AuthKey_XXX.p8 APPLE_API_KEY_ID=XXXX APPLE_API_ISSUER=…uuid… \ +pnpm -C apps/kimi-desktop run dist +``` + +In CI, run the **desktop-build** workflow with `sign-macos: true`; it reuses the +same Apple secrets / keychain action as the TUI native build +(`APPLE_CERTIFICATE_P12`, `APPLE_NOTARIZATION_KEY_*`). The resulting `.dmg` opens +on any Mac without warnings. + +> An `Apple Development` certificate is **not** enough — it can sign for your own +> machine but cannot be notarized. You need a `Developer ID Application` cert. + +## v1 scope / not done yet + +- **Auto-update**: not implemented (v2). +- **Windows / Linux signing**: unsigned in v1 (Windows shows a SmartScreen + prompt). Only macOS is signed + notarized. +- **App icon**: builds ship the Kimi logo (sourced from the docs site art) on + macOS, Windows, and Linux. +- **First launch may need network**: the SEA resolves its native sidecars + (clipboard / koffi) the same way the installed CLI does. diff --git a/apps/kimi-desktop/build/entitlements.mac.plist b/apps/kimi-desktop/build/entitlements.mac.plist new file mode 100644 index 000000000..949a28088 --- /dev/null +++ b/apps/kimi-desktop/build/entitlements.mac.plist @@ -0,0 +1,24 @@ + + + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/apps/kimi-desktop/build/icon.icns b/apps/kimi-desktop/build/icon.icns new file mode 100644 index 000000000..2c9de6605 Binary files /dev/null and b/apps/kimi-desktop/build/icon.icns differ diff --git a/apps/kimi-desktop/build/icon.ico b/apps/kimi-desktop/build/icon.ico new file mode 100644 index 000000000..c4c168ba1 Binary files /dev/null and b/apps/kimi-desktop/build/icon.ico differ diff --git a/apps/kimi-desktop/build/icon.png b/apps/kimi-desktop/build/icon.png new file mode 100644 index 000000000..5b41bb609 Binary files /dev/null and b/apps/kimi-desktop/build/icon.png differ diff --git a/apps/kimi-desktop/electron-builder.config.cjs b/apps/kimi-desktop/electron-builder.config.cjs new file mode 100644 index 000000000..e84bff250 --- /dev/null +++ b/apps/kimi-desktop/electron-builder.config.cjs @@ -0,0 +1,67 @@ +'use strict'; + +// electron-builder configuration. +// +// Signing / notarization are environment-driven so the same config produces +// either an unsigned local build or a fully signed + notarized distributable: +// +// unsigned (default / local): +// CSC_IDENTITY_AUTO_DISCOVERY=false -> no signing, no notarization +// +// signed + notarized (CI, with a Developer ID cert in the keychain): +// KIMI_DESKTOP_NOTARIZE=true +// APPLE_API_KEY= APPLE_API_KEY_ID= APPLE_API_ISSUER= +// +// The entitlements (hardened runtime) are applied to the app AND every nested +// Mach-O — including the bundled Kimi SEA backend — via entitlementsInherit, so +// the whole bundle passes notarization. Mirrors the TUI's native entitlements. + +const notarize = process.env.KIMI_DESKTOP_NOTARIZE === 'true'; + +module.exports = { + appId: 'ai.moonshot.kimi.desktop', + productName: 'Kimi Code Desktop', + copyright: 'Copyright © Moonshot AI', + + directories: { + output: 'dist-app', + }, + + // No native node modules in the Electron app itself; the backend is the + // prebuilt SEA staged by before-pack.cjs. + npmRebuild: false, + asar: true, + + files: ['out/**', 'package.json'], + + beforePack: './scripts/before-pack.cjs', + extraResources: [{ from: 'resources-stage/bin', to: 'bin' }], + + mac: { + category: 'public.app-category.developer-tools', + hardenedRuntime: true, + gatekeeperAssess: false, + entitlements: 'build/entitlements.mac.plist', + entitlementsInherit: 'build/entitlements.mac.plist', + target: ['dmg', 'zip'], + artifactName: 'KCD-Internal-${version}-${arch}.${ext}', + notarize, + }, + + win: { + target: ['nsis'], + artifactName: 'KCD-Internal-${version}-${arch}.${ext}', + }, + + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + }, + + linux: { + category: 'Development', + target: ['AppImage', 'deb'], + artifactName: 'KCD-Internal-${version}-${arch}.${ext}', + }, +}; diff --git a/apps/kimi-desktop/package.json b/apps/kimi-desktop/package.json new file mode 100644 index 000000000..4c3e44a7d --- /dev/null +++ b/apps/kimi-desktop/package.json @@ -0,0 +1,23 @@ +{ + "name": "@moonshot-ai/kimi-desktop", + "version": "0.1.1-internal.0", + "private": true, + "license": "MIT", + "description": "Kimi Code desktop client — an Electron shell around the Kimi web UI.", + "author": "Moonshot AI", + "type": "module", + "main": "out/main.cjs", + "scripts": { + "build": "tsdown", + "start": "electron .", + "dev": "tsdown && electron .", + "typecheck": "tsc --noEmit", + "dist": "tsdown && electron-builder --config electron-builder.config.cjs" + }, + "devDependencies": { + "electron": "33.4.11", + "electron-builder": "25.1.8", + "tsdown": "0.22.0", + "typescript": "6.0.2" + } +} diff --git a/apps/kimi-desktop/scripts/before-pack.cjs b/apps/kimi-desktop/scripts/before-pack.cjs new file mode 100644 index 000000000..3b28402ed --- /dev/null +++ b/apps/kimi-desktop/scripts/before-pack.cjs @@ -0,0 +1,42 @@ +'use strict'; + +// electron-builder `beforePack` hook. +// +// Each electron-builder run targets one (platform, arch). We stage the matching +// prebuilt Kimi SEA backend into `resources-stage/bin//` so that the +// `extraResources` rule copies exactly that one binary into the packaged app's +// resources. sea-path.ts resolves `/bin//kimi[.exe]` at +// runtime, where is `${process.platform}-${process.arch}`. + +const { existsSync, rmSync, mkdirSync, cpSync } = require('node:fs'); +const { join, resolve } = require('node:path'); + +// electron-builder Arch enum -> Node `process.arch` name. +const ARCH_NAMES = { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }; + +exports.default = async function beforePack(context) { + const platform = context.electronPlatformName; // 'darwin' | 'win32' | 'linux' + const archName = ARCH_NAMES[context.arch]; + if (archName === undefined) { + throw new Error(`Unsupported arch for packaging: ${String(context.arch)}`); + } + const target = `${platform}-${archName}`; + const exe = platform === 'win32' ? 'kimi.exe' : 'kimi'; + + const desktopRoot = resolve(__dirname, '..'); + const seaDir = resolve(desktopRoot, '..', 'kimi-code', 'dist-native', 'bin', target); + const seaExe = join(seaDir, exe); + if (!existsSync(seaExe)) { + throw new Error( + `Bundled Kimi server not found for ${target} at ${seaExe}. ` + + `Build it for this platform first: \`pnpm -C apps/kimi-code build:native:sea\` ` + + `(CI builds the SEA on each platform runner before packaging).`, + ); + } + + const stageDir = resolve(desktopRoot, 'resources-stage', 'bin', target); + rmSync(resolve(desktopRoot, 'resources-stage'), { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + cpSync(seaDir, stageDir, { recursive: true }); + console.log(`[before-pack] staged Kimi server (${target}) -> ${stageDir}`); +}; diff --git a/apps/kimi-desktop/src/main/ensure-server.ts b/apps/kimi-desktop/src/main/ensure-server.ts new file mode 100644 index 000000000..075ae3606 --- /dev/null +++ b/apps/kimi-desktop/src/main/ensure-server.ts @@ -0,0 +1,129 @@ +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Overall budget for the bundled `kimi server run` to finish ensuring a daemon. */ +const RUN_TIMEOUT_MS = 30_000; +/** How long to keep polling `/healthz` before declaring the daemon unhealthy. */ +const HEALTH_TIMEOUT_MS = 20_000; +const HEALTH_POLL_MS = 200; + +/** Subset of the server lock JSON we read (apps/kimi-code writes the full shape). */ +interface LockContents { + pid: number; + host?: string; + port: number; +} + +/** `` or `~/.kimi-code` — must match the server's `resolveKimiHome`. */ +export function kimiHome(): string { + const override = process.env['KIMI_CODE_HOME']; + if (override !== undefined && override.trim().length > 0) { + return override; + } + return join(homedir(), '.kimi-code'); +} + +function lockPath(): string { + return join(kimiHome(), 'server', 'lock'); +} + +/** Background daemon log written by the SEA — surfaced in the error screen / menu. */ +export function serverLogPath(): string { + return join(kimiHome(), 'server', 'server.log'); +} + +function readLock(): LockContents | null { + try { + const parsed = JSON.parse(readFileSync(lockPath(), 'utf-8')) as Partial; + if (typeof parsed.port === 'number' && typeof parsed.pid === 'number') { + return { + pid: parsed.pid, + port: parsed.port, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + }; + } + return null; + } catch { + return null; + } +} + +function originFromLock(lock: LockContents): string { + const host = lock.host !== undefined && lock.host !== '0.0.0.0' ? lock.host : '127.0.0.1'; + return `http://${host}:${lock.port}`; +} + +async function isHealthy(origin: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + try { + const res = await fetch(`${origin}/api/v1/healthz`, { signal: controller.signal }); + if (!res.ok) { + return false; + } + const body = (await res.json()) as { code?: unknown }; + return body.code === 0; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +/** + * Run the bundled SEA's `server run`, which reuses a live shared daemon or + * spawns one and exits once it is healthy. All discovery / port / lock logic + * lives in apps/kimi-code's `ensureDaemon`; we do not reimplement it. + */ +function runServerRun(seaPath: string): Promise { + return new Promise((resolve, reject) => { + execFile( + seaPath, + ['server', 'run', '--log-level', 'error'], + { timeout: RUN_TIMEOUT_MS }, + (error, _stdout, stderr) => { + if (error) { + reject(new Error(`kimi server run failed: ${error.message}\n${stderr}`.trim())); + return; + } + resolve(); + }, + ); + }); +} + +export interface EnsureServerResult { + origin: string; +} + +/** + * Ensure the shared kimi-code daemon is running and return its origin. + * + * The desktop app participates in the same local-server ecosystem as the CLI, + * the browser and the TUI: it reuses a running daemon or starts one that the + * others can reuse — never a private, app-only server. + */ +export async function ensureServer(seaPath: string): Promise { + await runServerRun(seaPath); + + const lock = readLock(); + if (lock === null) { + throw new Error(`Kimi server lock not found at ${lockPath()} after starting the server.`); + } + const origin = originFromLock(lock); + + const deadline = Date.now() + HEALTH_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await isHealthy(origin, 500)) { + return { origin }; + } + await new Promise((resolve) => { + setTimeout(resolve, HEALTH_POLL_MS); + }); + } + throw new Error(`Kimi server at ${origin} did not become healthy within ${HEALTH_TIMEOUT_MS}ms.`); +} diff --git a/apps/kimi-desktop/src/main/index.ts b/apps/kimi-desktop/src/main/index.ts new file mode 100644 index 000000000..7c440a1ce --- /dev/null +++ b/apps/kimi-desktop/src/main/index.ts @@ -0,0 +1,254 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { app, BrowserWindow, Menu, shell } from 'electron'; +import type { MenuItemConstructorOptions } from 'electron'; + +import { ensureServer, kimiHome, serverLogPath } from './ensure-server'; +import { resolveSeaPath } from './sea-path'; + +let mainWindow: BrowserWindow | null = null; + +// --- window state persistence ------------------------------------------------- + +interface WindowBounds { + width: number; + height: number; + x?: number; + y?: number; +} + +const DEFAULT_BOUNDS: WindowBounds = { width: 1280, height: 860 }; + +function stateFile(): string { + return join(app.getPath('userData'), 'window-state.json'); +} + +function loadBounds(): WindowBounds { + try { + const parsed = JSON.parse(readFileSync(stateFile(), 'utf-8')) as Partial; + if (typeof parsed.width === 'number' && typeof parsed.height === 'number') { + return { + width: parsed.width, + height: parsed.height, + x: typeof parsed.x === 'number' ? parsed.x : undefined, + y: typeof parsed.y === 'number' ? parsed.y : undefined, + }; + } + } catch { + // No saved state yet, or it is unreadable — fall back to defaults. + } + return DEFAULT_BOUNDS; +} + +function saveBounds(win: BrowserWindow): void { + try { + const bounds = win.getBounds(); + mkdirSync(dirname(stateFile()), { recursive: true }); + writeFileSync( + stateFile(), + JSON.stringify({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y }), + ); + } catch { + // Best-effort; losing window position is not worth surfacing an error. + } +} + +// --- startup screens (no separate renderer files; inline data URLs) ----------- + +function dataUrl(html: string): string { + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + +const SCREEN_STYLE = ` + +`; + +function loadingHtml(): string { + return `${SCREEN_STYLE} +
+

正在启动 Kimi 本地服务…

+

首次启动可能需要几秒。

`; +} + +function errorHtml(message: string): string { + const safe = message.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + return `${SCREEN_STYLE} +

无法启动本地服务

+

${safe}

+

查看日志:${serverLogPath()}

+

菜单 → Kimi Code Desktop → 重试连接,或先检查日志。

`; +} + +// --- server auth token -------------------------------------------------------- + +/** On-disk filename of the daemon's persistent bearer token (under KIMI_CODE_HOME). */ +const SERVER_TOKEN_FILE = 'server.token'; + +/** + * Read the daemon's bearer token so the web UI can authenticate without showing + * the manual token dialog on a fresh launch. Returns undefined when the token + * cannot be read (the web UI then falls back to the dialog). + */ +function readServerToken(): string | undefined { + try { + const token = readFileSync(join(kimiHome(), SERVER_TOKEN_FILE), 'utf-8').trim(); + return token.length > 0 ? token : undefined; + } catch { + return undefined; + } +} + +// --- connect flow ------------------------------------------------------------- + +async function connect(win: BrowserWindow): Promise { + await win.loadURL(dataUrl(loadingHtml())); + try { + const { origin } = await ensureServer(resolveSeaPath()); + process.stdout.write(`[kimi-desktop] connected to ${origin}\n`); + if (!win.isDestroyed()) { + // Append a desktop marker so the web UI shows the internal-build banner + // even when it is served by an already-running shared daemon (the desktop + // reuses the local daemon rather than starting a private one). Carry the + // server token in the `#token=` fragment — like `kimi web` does — so the + // web UI can authenticate without falling into the manual token dialog on + // a fresh launch. + const token = readServerToken(); + const fragment = token === undefined ? '' : `#token=${encodeURIComponent(token)}`; + await win.loadURL( + `${origin}/?kimi_desktop=1&platform=${process.platform}${fragment}`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[kimi-desktop] ensureServer failed: ${message}\n`); + if (!win.isDestroyed()) { + await win.loadURL(dataUrl(errorHtml(message))); + } + } +} + +function createWindow(): void { + const win = new BrowserWindow({ + ...loadBounds(), + minWidth: 720, + minHeight: 480, + backgroundColor: '#0b0b0c', + title: 'Kimi Code Desktop', + // macOS: hide the native title bar and float the traffic lights over the + // content; the web UI reserves a draggable strip at the top to clear them. + // 'default' on other platforms (they keep their native title bar). + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + mainWindow = win; + // Keep the window title as the product name. The web page sets document.title + // ("Kimi Code Web"), which would otherwise replace it. + win.webContents.on('page-title-updated', (event) => { + event.preventDefault(); + }); + win.on('close', () => { + saveBounds(win); + }); + win.on('closed', () => { + if (mainWindow === win) { + mainWindow = null; + } + }); + void connect(win); +} + +// --- native menu -------------------------------------------------------------- + +function buildMenu(): void { + const isMac = process.platform === 'darwin'; + const appMenu: MenuItemConstructorOptions = { + label: 'Kimi Code Desktop', + submenu: [ + ...(isMac ? [{ role: 'about' as const }, { type: 'separator' as const }] : []), + { + label: '重试连接', + click: () => { + if (mainWindow !== null) { + void connect(mainWindow); + } else { + createWindow(); + } + }, + }, + { + label: '打开服务日志', + click: () => { + void shell.openPath(serverLogPath()); + }, + }, + { type: 'separator' }, + isMac ? { role: 'quit' } : { role: 'close' }, + ], + }; + + const template: MenuItemConstructorOptions[] = [ + appMenu, + { role: 'editMenu' }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { role: 'windowMenu' }, + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +// --- app lifecycle ------------------------------------------------------------ + +function main(): void { + // The shared daemon is deliberately left running on quit — it self-exits ~60s + // after the last client disconnects, so we never tear down a server another + // client (CLI / browser / TUI) may still be using. + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + void app.whenReady().then(() => { + buildMenu(); + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); + }); +} + +main(); diff --git a/apps/kimi-desktop/src/main/sea-path.ts b/apps/kimi-desktop/src/main/sea-path.ts new file mode 100644 index 000000000..8703d72b1 --- /dev/null +++ b/apps/kimi-desktop/src/main/sea-path.ts @@ -0,0 +1,45 @@ +import { join } from 'node:path'; + +import { app } from 'electron'; + +// The bundled backend targets the same 6 platform/arch pairs the kimi-code +// native SEA build supports (apps/kimi-code/scripts/native/native-deps.mjs). +const SUPPORTED_TARGETS = new Set([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +]); + +/** `-` triple for the current process, validated against the SEA targets. */ +export function currentTarget(): string { + const target = `${process.platform}-${process.arch}`; + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`No bundled Kimi server for this platform: ${target}`); + } + return target; +} + +function executableName(): string { + return process.platform === 'win32' ? 'kimi.exe' : 'kimi'; +} + +/** + * Absolute path to the bundled SEA backend executable. + * + * - packaged: `/bin//kimi[.exe]` — placed there by + * electron-builder `extraResources`. + * - dev: `apps/kimi-code/dist-native/bin//kimi[.exe]` — produced by + * `pnpm -C apps/kimi-code build:native:sea`. In dev `app.getAppPath()` is + * `apps/kimi-desktop`, so the sibling app is one level up. + */ +export function resolveSeaPath(): string { + const target = currentTarget(); + const exe = executableName(); + if (app.isPackaged) { + return join(process.resourcesPath, 'bin', target, exe); + } + return join(app.getAppPath(), '..', 'kimi-code', 'dist-native', 'bin', target, exe); +} diff --git a/apps/kimi-desktop/tsconfig.json b/apps/kimi-desktop/tsconfig.json new file mode 100644 index 000000000..596e2cf72 --- /dev/null +++ b/apps/kimi-desktop/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/apps/kimi-desktop/tsdown.config.ts b/apps/kimi-desktop/tsdown.config.ts new file mode 100644 index 000000000..c58e8866d --- /dev/null +++ b/apps/kimi-desktop/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown'; + +// The Electron main process is loaded as CommonJS (`out/main.cjs`). All sources +// under src/main are bundled into a single file; `electron` stays external +// (provided by the Electron runtime) and Node built-ins are external by default. +export default defineConfig({ + entry: { main: 'src/main/index.ts' }, + format: ['cjs'], + platform: 'node', + target: 'node20', + outDir: 'out', + clean: true, + dts: false, + fixedExtension: true, + deps: { neverBundle: ['electron'] }, +}); diff --git a/apps/kimi-web/src/components/InternalBuildBanner.vue b/apps/kimi-web/src/components/InternalBuildBanner.vue new file mode 100644 index 000000000..e2638aaad --- /dev/null +++ b/apps/kimi-web/src/components/InternalBuildBanner.vue @@ -0,0 +1,56 @@ + + + + + + diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index adf31d559..25a6bdf78 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -12,6 +12,8 @@ import { moveInOrder, type DropPosition } from '../lib/workspaceOrder'; import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types'; import SessionRow from './SessionRow.vue'; import WorkspaceGroup from './WorkspaceGroup.vue'; +import InternalBuildBanner from './InternalBuildBanner.vue'; +import { isMacosDesktop } from '../lib/desktopFlag'; const { t } = useI18n(); @@ -419,7 +421,7 @@ function blinkOnce(): void {