mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
ceb27f5e44
commit
210cedb3bf
27 changed files with 3274 additions and 26 deletions
161
.github/workflows/desktop-build.yml
vendored
Normal file
161
.github/workflows/desktop-build.yml
vendored
Normal 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
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -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
3
apps/kimi-desktop/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
out/
|
||||
dist-app/
|
||||
resources-stage/
|
||||
107
apps/kimi-desktop/README.md
Normal file
107
apps/kimi-desktop/README.md
Normal 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.
|
||||
24
apps/kimi-desktop/build/entitlements.mac.plist
Normal file
24
apps/kimi-desktop/build/entitlements.mac.plist
Normal 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>
|
||||
BIN
apps/kimi-desktop/build/icon.icns
Normal file
BIN
apps/kimi-desktop/build/icon.icns
Normal file
Binary file not shown.
BIN
apps/kimi-desktop/build/icon.ico
Normal file
BIN
apps/kimi-desktop/build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/kimi-desktop/build/icon.png
Normal file
BIN
apps/kimi-desktop/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
67
apps/kimi-desktop/electron-builder.config.cjs
Normal file
67
apps/kimi-desktop/electron-builder.config.cjs
Normal 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}',
|
||||
},
|
||||
};
|
||||
23
apps/kimi-desktop/package.json
Normal file
23
apps/kimi-desktop/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
42
apps/kimi-desktop/scripts/before-pack.cjs
Normal file
42
apps/kimi-desktop/scripts/before-pack.cjs
Normal 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}`);
|
||||
};
|
||||
129
apps/kimi-desktop/src/main/ensure-server.ts
Normal file
129
apps/kimi-desktop/src/main/ensure-server.ts
Normal 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.`);
|
||||
}
|
||||
254
apps/kimi-desktop/src/main/index.ts
Normal file
254
apps/kimi-desktop/src/main/index.ts
Normal 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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
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();
|
||||
45
apps/kimi-desktop/src/main/sea-path.ts
Normal file
45
apps/kimi-desktop/src/main/sea-path.ts
Normal 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);
|
||||
}
|
||||
4
apps/kimi-desktop/tsconfig.json
Normal file
4
apps/kimi-desktop/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
16
apps/kimi-desktop/tsdown.config.ts
Normal file
16
apps/kimi-desktop/tsdown.config.ts
Normal 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'] },
|
||||
});
|
||||
56
apps/kimi-web/src/components/InternalBuildBanner.vue
Normal file
56
apps/kimi-web/src/components/InternalBuildBanner.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
4
apps/kimi-web/src/env.d.ts
vendored
4
apps/kimi-web/src/env.d.ts
vendored
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ export default {
|
|||
authPageLogin: 'Sign in',
|
||||
connecting: 'Connecting…',
|
||||
comingSoon: 'Coming soon…',
|
||||
internalBuildBanner: 'Internal testing only',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ export default {
|
|||
authPageLogin: '登录',
|
||||
connecting: '连接中…',
|
||||
comingSoon: '敬请期待',
|
||||
internalBuildBanner: '仅供内部测试',
|
||||
} as const;
|
||||
|
|
|
|||
58
apps/kimi-web/src/lib/desktopFlag.ts
Normal file
58
apps/kimi-web/src/lib/desktopFlag.ts
Normal 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';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
2254
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue