chore: add KCD client for test (#1230)

* feat(kimi-desktop): add Electron desktop client wrapping kimi-web

New apps/kimi-desktop — a thin Electron shell + process manager around
the existing web UI. It reuses kimi-code's shared daemon: it runs the
bundled SEA's `server run` (the same ensureDaemon reuse-or-spawn flow as
`kimi web`), reads ~/.kimi-code/server/lock for the real origin, and
loads the SEA-served kimi-web same-origin. The daemon is left running on
quit so the CLI / browser / TUI keep sharing it.

- main process: ensure-server (run SEA, read lock, confirm healthz),
  sea-path (dev vs packaged), window + native menu + window-state +
  loading/error screens
- packaging: electron-builder config; before-pack stages the
  matching-platform SEA into <resources>/bin/<target>
- CI: desktop-build workflow builds unsigned mac/win/linux installers,
  each runner building its own SEA
- workspace wiring: register in flake.nix, allow electron postinstall
  (onlyBuiltDependencies), root dev:desktop + typecheck entries

v1 is unsigned, default icon, no auto-update.

* feat(kimi-desktop): sign + notarize macOS builds

Unsigned macOS builds are blocked by Gatekeeper ("app is damaged") once
transferred to another Mac. Add Developer ID signing + Apple notarization,
mirroring the TUI native build:

- build/entitlements.mac.plist: hardened-runtime entitlements (allow-jit,
  disable-library-validation for koffi/clipboard, etc.) applied to the app
  and — via entitlementsInherit — the nested SEA backend
- electron-builder.config.cjs (replaces .yml): hardenedRuntime + entitlements;
  signing and notarization are env-driven (CSC_* + KIMI_DESKTOP_NOTARIZE +
  APPLE_API_* ), so the same config builds unsigned locally or signed+notarized
- desktop-build CI: sign-macos input reuses the existing macos-keychain-setup
  action + APPLE_* secrets, notarizes via the notary API key
- README: document signing, the Developer-ID requirement, and the
  "don't rename the .app" gotcha

Verified locally that electron-builder signs both the app and the nested SEA
with hardened runtime + the entitlements, and the signed app still launches and
serves the web UI. Notarization itself needs a Developer ID cert (CI / a machine
that has one).

* feat(kimi-desktop): rename product to Kimi Code Desktop

productName / window title / menu label / error-screen text all use
"Kimi Code Desktop" so the bundle name matches its executable (a
mismatch from manual renaming is itself reported as "damaged").

* ci(kimi-desktop): build and attach desktop installers in the release pipeline

Make desktop-build.yml reusable (workflow_call) and invoke it from the
release workflow, mirroring the native-build pipeline, so each release
also attaches signed+notarized macOS, Windows and Linux desktop
installers to the GitHub Release.

* feat(kimi-desktop): brand the desktop as an internal testing build

- Add an inline 'internal testing build' tag next to the Kimi Code brand
  in the sidebar header, shown only inside the desktop app.
- Use a hidden native title bar on macOS with the traffic lights folded
  into the sidebar header, and pin the window title to the product name.
- Ship the Kimi app icon for macOS and Linux builds.

Desktop detection is runtime (a query hint from the Electron shell,
persisted in sessionStorage) so the branding appears even when the
window is served by an already-running shared daemon.

* docs(kimi-desktop): update v1 scope now that the app icon ships

* feat(kimi-desktop): add the Kimi app icon for Windows builds

* chore(nix): update pnpmDeps hash after lockfile refresh

* ci(kimi-desktop): build desktop on release but do not attach to GitHub Release

The desktop build is an internal-testing artifact (branded as such), so
keep it as a CI artifact for internal download instead of publishing it
to the public GitHub Release.

* chore(kimi-desktop): mark installers as internal pre-release builds

Rename the packaged artifacts to KCD-Internal-<version>-<arch>.<ext> and
bump the version to the 0.1.1-internal.0 pre-release, so a leaked or
forwarded installer file is not mistaken for an official public release.

* feat(kimi-desktop): strengthen the internal-build tag wording

Change the sidebar tag to 'Internal testing · do not distribute' /
'内部测试 · 禁止外传' so the no-distribution intent is explicit.

* feat(kimi-desktop): tweak internal-build tag to '仅供内部测试'

* fix(kimi-desktop): pass the server token to the web UI on launch

Read the daemon's persistent bearer token from <KIMI_CODE_HOME>/server.token
and carry it in the URL fragment (#token=), matching how 'kimi web' opens
the Web UI. Without this, a fresh launch (no saved credential) boots the
web UI without a token, hits 401, and falls into the manual token dialog
even though the desktop started the daemon itself.

Addresses review feedback on the desktop URL.
This commit is contained in:
qer 2026-06-30 21:46:51 +08:00 committed by GitHub
parent ceb27f5e44
commit 210cedb3bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 3274 additions and 26 deletions

161
.github/workflows/desktop-build.yml vendored Normal file
View file

@ -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

View file

@ -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:

3
apps/kimi-desktop/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
out/
dist-app/
resources-stage/

107
apps/kimi-desktop/README.md Normal file
View file

@ -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/<target>/`, 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 (`<resources>/bin/<target>/`).
```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.

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Electron/V8 and the bundled Node SEA both need JIT pages. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- V8 writes to executable memory during codegen. -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- The bundled server dlopen's koffi.node / clipboard.node at runtime;
they are third-party and not signed by our Team. Without this the
hardened runtime rejects them. (Same as the TUI's native build.) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Some users set NODE_OPTIONS / DYLD_* envs; allow them. -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View file

@ -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=<path to .p8> APPLE_API_KEY_ID=<id> APPLE_API_ISSUER=<id>
//
// 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}',
},
};

View file

@ -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"
}
}

View file

@ -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/<target>/` so that the
// `extraResources` rule copies exactly that one binary into the packaged app's
// resources. sea-path.ts resolves `<resources>/bin/<target>/kimi[.exe]` at
// runtime, where <target> 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}`);
};

View file

@ -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;
}
/** `<KIMI_CODE_HOME>` 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<LockContents>;
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<boolean> {
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<void> {
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<EnsureServerResult> {
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.`);
}

View file

@ -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<WindowBounds>;
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 = `
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px; background: #0b0b0c; color: #e7e7ea; font: 14px/1.5 system-ui, sans-serif;
-webkit-user-select: none; user-select: none; text-align: center; padding: 0 32px;
}
.spinner {
width: 34px; height: 34px; border-radius: 50%;
border: 3px solid #2a2a2e; border-top-color: #7c8cff; animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
h1 { font-size: 15px; font-weight: 600; margin: 0; }
p { margin: 0; color: #9a9aa2; max-width: 560px; }
code { color: #c8c8d0; word-break: break-all; }
</style>
`;
function loadingHtml(): string {
return `<!doctype html><meta charset="utf-8">${SCREEN_STYLE}
<div class="spinner"></div>
<h1> Kimi </h1>
<p></p>`;
}
function errorHtml(message: string): string {
const safe = message.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
return `<!doctype html><meta charset="utf-8">${SCREEN_STYLE}
<h1></h1>
<p>${safe}</p>
<p><code>${serverLogPath()}</code></p>
<p> Kimi Code Desktop </p>`;
}
// --- 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<void> {
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();

View file

@ -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',
]);
/** `<platform>-<arch>` 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: `<resources>/bin/<target>/kimi[.exe]` placed there by
* electron-builder `extraResources`.
* - dev: `apps/kimi-code/dist-native/bin/<target>/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);
}

View file

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}

View file

@ -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'] },
});

View file

@ -0,0 +1,56 @@
<!-- apps/kimi-web/src/components/InternalBuildBanner.vue -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { isDesktop } from '../lib/desktopFlag';
const { t } = useI18n();
// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders an inline
// tag meant to sit next to the "Kimi Code" brand in the sidebar header.
const show = isDesktop;
</script>
<template>
<span
v-if="show"
class="internal-build-tag"
role="note"
:aria-label="t('app.internalBuildBanner')"
>
<svg
viewBox="0 0 16 16"
width="11"
height="11"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 2 14 13H2L8 2Z" />
<path d="M8 6v3.5" />
<path d="M8 11.5h.01" />
</svg>
<span>{{ t('app.internalBuildBanner') }}</span>
</span>
</template>
<style scoped>
.internal-build-tag {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: 999px;
background: #f5a623;
color: #3a2a00;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1.4;
white-space: nowrap;
user-select: none;
}
</style>

View file

@ -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 {
</script>
<template>
<aside class="side">
<aside class="side" :class="{ 'macos-desktop': isMacosDesktop }">
<!-- Session column -->
<div class="col" :style="{ width: colWidth + 'px' }">
<!-- Header: logo + settings (no hard border flows into workspace list) -->
@ -438,6 +440,7 @@ function blinkOnce(): void {
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#kimiEyes)" />
</svg>
<span class="ch-name">Kimi Code<span v-if="isDev" class="ch-endpoint"> · {{ endpoint }}</span></span>
<InternalBuildBanner />
</div>
<button
type="button"
@ -674,6 +677,19 @@ function blinkOnce(): void {
width: 100%;
box-sizing: border-box;
}
/* macOS desktop: the window uses a hidden title bar, so the traffic lights float
over the top-left of the sidebar. Push the header content right to clear them,
and turn the empty header area into the window-drag region; interactive
controls opt out with no-drag. */
.side.macos-desktop .ch {
padding-left: 80px;
-webkit-app-region: drag;
}
.side.macos-desktop .ch-logo,
.side.macos-desktop .collapse-btn,
.side.macos-desktop .settings-btn {
-webkit-app-region: no-drag;
}
.ch-logo {
height: 22px;
width: 32px;

View file

@ -8,6 +8,10 @@ declare const __KIMI_DEV_PROXY_TARGET__: string;
// Injected by Vite `define` from apps/kimi-web/package.json.
declare const __KIMI_WEB_VERSION__: string;
// Injected by Vite `define`: true only in the web bundle embedded in the Kimi
// Desktop app. Gates the internal-build banner (see InternalBuildBanner.vue).
declare const __KIMI_WEB_DESKTOP__: boolean;
declare module '*.vue' {
import type { DefineComponent } from 'vue';

View file

@ -6,4 +6,5 @@ export default {
authPageLogin: 'Sign in',
connecting: 'Connecting…',
comingSoon: 'Coming soon…',
internalBuildBanner: 'Internal testing only',
} as const;

View file

@ -6,4 +6,5 @@ export default {
authPageLogin: '登录',
connecting: '连接中…',
comingSoon: '敬请期待',
internalBuildBanner: '仅供内部测试',
} as const;

View file

@ -0,0 +1,58 @@
// apps/kimi-web/src/lib/desktopFlag.ts
//
// Detects whether the web UI is running inside the Kimi Desktop app, and on
// which platform.
//
// The desktop app shares the local kimi daemon with the CLI / browser / TUI, so
// the web bundle it displays may be served by an already-running external daemon
// (not the one bundled inside the app). A purely build-time flag is therefore
// unreliable. Instead, the desktop app appends `?kimi_desktop=1&platform=<os>`
// to the URL it loads (see apps/kimi-desktop/src/main/index.ts); we persist
// those values in sessionStorage so they survive any in-app navigation or
// redirect that drops the query string. The compile-time __KIMI_WEB_DESKTOP__
// is kept as an additional signal for the case where the web bundle itself was
// built for the desktop.
const QUERY_KEY = 'kimi_desktop';
const PLATFORM_KEY = 'platform';
const STORAGE_KEY = 'kimi-desktop';
const PLATFORM_STORAGE_KEY = 'kimi-desktop-platform';
interface DesktopEnv {
isDesktop: boolean;
platform: string | null;
}
function detect(): DesktopEnv {
let desktop = __KIMI_WEB_DESKTOP__;
let platform: string | null = null;
try {
const params = new URLSearchParams(window.location.search);
if (params.has(QUERY_KEY)) {
sessionStorage.setItem(STORAGE_KEY, '1');
desktop = true;
} else {
desktop = desktop || sessionStorage.getItem(STORAGE_KEY) === '1';
}
const qPlatform = params.get(PLATFORM_KEY);
if (qPlatform) {
sessionStorage.setItem(PLATFORM_STORAGE_KEY, qPlatform);
platform = qPlatform;
} else {
platform = sessionStorage.getItem(PLATFORM_STORAGE_KEY);
}
} catch {
// sessionStorage may be unavailable (e.g. private mode) — fall back to the
// compile-time flag only.
}
return { isDesktop: desktop, platform };
}
const env = detect();
/** True when running inside the Kimi Desktop app (any platform). */
export const isDesktop = env.isDesktop;
/** True only on macOS desktop used to reserve space for the floating traffic
* lights when the window uses `titleBarStyle: 'hiddenInset'`. */
export const isMacosDesktop = env.isDesktop && env.platform === 'darwin';

View file

@ -19,6 +19,10 @@ export default defineConfig({
define: {
__KIMI_DEV_PROXY_TARGET__: JSON.stringify(serverTarget),
__KIMI_WEB_VERSION__: JSON.stringify(pkg.version),
// True only for the web bundle embedded in the Kimi Desktop app (set by the
// desktop-build workflow). Gates an "internal testing build" banner. When
// false (default) the banner is tree-shaken out of the production bundle.
__KIMI_WEB_DESKTOP__: JSON.stringify(process.env.KIMI_WEB_DESKTOP === '1'),
},
server: {
port: webPort,

View file

@ -75,6 +75,7 @@
./packages/protocol
./packages/telemetry
./apps/kimi-code
./apps/kimi-desktop
./apps/kimi-web
./apps/vis
./apps/vis/server
@ -95,6 +96,7 @@
"@moonshot-ai/protocol"
"@moonshot-ai/kimi-telemetry"
"@moonshot-ai/kimi-code"
"@moonshot-ai/kimi-desktop"
"@moonshot-ai/kimi-web"
"@moonshot-ai/vis"
"@moonshot-ai/vis-server"
@ -150,7 +152,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-w/mEQrb5gNn4S5jZ95vO9uy4u/JB3wFbUfIZDcWqTXU=";
hash = "sha256-oratz8x67ZEJGTiNy+s4XaKe0TtpRKh63aIqkV79vvM=";
};
nativeBuildInputs = [

View file

@ -11,11 +11,12 @@
"dev:cli": "pnpm -C apps/kimi-code run dev",
"dev:cli:marketplace": "KIMI_CODE_DEV_MARKETPLACE_URL=https://code.kimi.com/kimi-code/plugins/marketplace.json pnpm -C apps/kimi-code run dev",
"dev:web": "pnpm -C apps/kimi-web run dev",
"dev:desktop": "pnpm -C apps/kimi-desktop run dev",
"dev:server": "pnpm -C apps/kimi-code run dev:server",
"build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace",
"vis": "pnpm -C apps/vis run dev",
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",
"typecheck": "pnpm run build:packages && pnpm -r --filter './packages/*' run typecheck && pnpm --filter @moonshot-ai/kimi-code run typecheck && pnpm --filter @moonshot-ai/kimi-web run typecheck && pnpm --filter @moonshot-ai/vis-server run typecheck && pnpm --filter @moonshot-ai/vis-web run typecheck",
"typecheck": "pnpm run build:packages && pnpm -r --filter './packages/*' run typecheck && pnpm --filter @moonshot-ai/kimi-code run typecheck && pnpm --filter @moonshot-ai/kimi-web run typecheck && pnpm --filter @moonshot-ai/vis-server run typecheck && pnpm --filter @moonshot-ai/vis-web run typecheck && pnpm --filter @moonshot-ai/kimi-desktop run typecheck",
"lint": "oxlint --type-aware",
"lint:fix": "pnpm run lint --fix",
"lint:pkg": "pnpm --filter @moonshot-ai/kimi-code exec publint && npm_config_cache=${TMPDIR:-/tmp}/kimi-code-npm-cache pnpm --filter @moonshot-ai/kimi-code exec attw --pack . --profile node16",

2254
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -11,3 +11,9 @@ catalog:
overrides:
"ssh2@1.17.0>cpu-features": "-"
"ssh2@1.17.0>nan": "-"
# Allow Electron's postinstall to extract its prebuilt binary (needed by
# apps/kimi-desktop). Other native deps stay unbuilt and rely on their own
# prebuilds, matching the prior default.
onlyBuiltDependencies:
- electron