mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-27 09:34:43 +00:00
feat(auth-login): add Remote Control auth login page (#224)
* feat(auth-login): add Remote Control auth login page * chore: bump kimi-code submodule * chore: bump kimi-code submodule * feat(auth-login): make redirect_uri optional and show a signed-in state without it * feat(auth-login): rename the token cookie to kimi-auth * feat(auth-login): move the copy-link button to a second row on mobile * feat(auth-login): make the mobile copy-link button full-width * feat(auth-login): detect locale from the shared KIMI_LOCALE cookie * fix(auth-login): address review findings on cookie handling and polling * ci: include auth-login in root typecheck and changeset ignore * feat(auth-login): drop the oauth_host override * feat(app-ui): split self-hosted fonts into a separate fonts.css entry * fix(auth-login): fall back from crypto.randomUUID for insecure contexts
This commit is contained in:
parent
db79803a9c
commit
35746f75ce
27 changed files with 1245 additions and 30 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"kimi-code-web",
|
||||
"kimi-code-auth-login",
|
||||
"@moonshot-ai/vite-preset",
|
||||
"@moonshot-ai/app-core",
|
||||
"@moonshot-ai/app-i18n",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
|
||||
- `apps/desktop`:Electron 壳(`kimi-code-app`);简介见 `apps/desktop/README.md`,原生功能分叉清单见 `apps/desktop/docs/native-todos.md`。新测试:主进程进 `tests/main/`,renderer 进 `tests/renderer/`。
|
||||
- `apps/web`:浏览器 Web UI(`kimi-code-web`,Vue 3 + Vite + vue-i18n)。dev 时 Vite 把 `/api/v1`(REST + WS)代理到 `KIMI_SERVER_URL`(默认 `http://127.0.0.1:58627`)。
|
||||
- `apps/auth-login`:Remote Control 鉴权中间页(`kimi-code-auth-login`,单页、移动端优先):OAuth device flow 经 `@moonshot-ai/kimi-code-oauth/device` 直连 auth.kimi.com(CORS 直连,无 dev proxy),token 写 `kimi-auth` cookie;`redirect_uri` 可选,有则登录后回跳。方案见 `docs/plans/2026-08-13-rc-auth-login.md`。
|
||||
- `packages/*`:`@moonshot-ai/{app-core,app-i18n,app-markdown,app-ui,app-client}` + `vite-preset`(exports→src,被 apps/web 与 desktop renderer 复用)——app-core 是无 Vue 依赖的纯层(api 客户端 / lib 纯函数 / client 渲染类型与热路径纯模块),app-client 是 Vue composables 层(注入 api / t / tracker);共享字体产物在 `app-ui/src/assets/fonts`(gitignored),由 `scripts/prepare-fonts.mjs` 自动准备。
|
||||
- `kimi-code/`:git submodule(核心仓)。`kimi-code/packages/*` 提供 `kap-server`、`agent-core-v2`、`kimi-code-sdk` 等源码。
|
||||
- `scripts/sync-web-to-kimi-code.mjs`:`apps/web/dist` → `<kimi-code checkout>/apps/kimi-code/dist-web`(`KIMI_CODE_REPO` 必传,指定目标 checkout)。
|
||||
|
|
|
|||
18
apps/auth-login/index.html
Normal file
18
apps/auth-login/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-color-scheme="system">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="64x64" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)" />
|
||||
<!-- Auth interstitial for RC tunnels: never indexable. -->
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Kimi Code</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
apps/auth-login/package.json
Normal file
33
apps/auth-login/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "kimi-code-auth-login",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare:fonts": "node ../../scripts/prepare-fonts.mjs",
|
||||
"predev": "npm run prepare:fonts",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run prepare:fonts",
|
||||
"build": "vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moonshot-ai/app-client": "workspace:*",
|
||||
"@moonshot-ai/app-core": "workspace:*",
|
||||
"@moonshot-ai/app-i18n": "workspace:*",
|
||||
"@moonshot-ai/app-ui": "workspace:*",
|
||||
"@moonshot-ai/kimi-code-oauth": "workspace:*",
|
||||
"vue": "^3.5.35",
|
||||
"vue-i18n": "^11.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@moonshot-ai/vite-preset": "workspace:*",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"typescript": "6.0.2",
|
||||
"unplugin-icons": "^23.0.0",
|
||||
"vite": "^6.3.3",
|
||||
"vitest": "4.1.4",
|
||||
"vue-tsc": "~3.2.0"
|
||||
}
|
||||
}
|
||||
BIN
apps/auth-login/public/favicon.ico
Normal file
BIN
apps/auth-login/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
428
apps/auth-login/src/App.vue
Normal file
428
apps/auth-login/src/App.vue
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
<!-- apps/auth-login/src/App.vue -->
|
||||
<!-- Remote Control auth interstitial: a single mobile-first page that runs the
|
||||
Kimi Code OAuth device flow directly against the OAuth host and stores the
|
||||
access token in the `kimi-auth` cookie. With `?redirect_uri=` (the tunnel
|
||||
entry case) it redirects back after sign-in; without one it simply lands
|
||||
on a "signed in" state. `?force_relogin=1` drops any existing cookie and
|
||||
starts over (the tunnel uses it after rejecting a token). Desktop widths
|
||||
get a centered card. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, useId } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from '@moonshot-ai/app-core/lib';
|
||||
import { useOAuthLoginFlow } from '@moonshot-ai/app-client/composables';
|
||||
import { AuthStateIcon, Button, Icon, Spinner } from '@moonshot-ai/app-ui';
|
||||
import {
|
||||
buildTokenCookie,
|
||||
clearTokenCookie,
|
||||
parseRedirectUri,
|
||||
readTokenCookie,
|
||||
} from './auth-token';
|
||||
import { createAuthLoginFlow } from './flow';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Per-instance mask id so the logo's eye cutouts can't collide.
|
||||
const maskId = `bl-eyes-${useId()}`;
|
||||
|
||||
// The page's own steps precede the OAuth flow's: an existing token cookie
|
||||
// short-circuits the flow entirely (redirect when a target is known, a plain
|
||||
// "signed in" state otherwise).
|
||||
type PageStep = 'checking' | 'signed-in' | 'flow';
|
||||
const pageStep = ref<PageStep>('checking');
|
||||
// Optional redirect target. Missing or invalid values both degrade to "no
|
||||
// redirect" — a malformed param must not strand the user off the sign-in flow.
|
||||
const redirectUri = ref<string | null>(null);
|
||||
|
||||
function writeTokenCookie(accessToken: string, expiresAt: number): void {
|
||||
document.cookie = buildTokenCookie(accessToken, {
|
||||
expiresAt,
|
||||
secure: location.protocol === 'https:',
|
||||
});
|
||||
}
|
||||
|
||||
const authFlow = createAuthLoginFlow({
|
||||
// Persist the cookie the instant the token arrives — the success state (and
|
||||
// its "you can close" hint) shows during the state machine's dwell, so
|
||||
// waiting for onSuccess would let an early tab-close lose the sign-in.
|
||||
onToken: (token) => writeTokenCookie(token.accessToken, token.expiresAt),
|
||||
});
|
||||
const { step, pollError, flow, secondsLeft, startFlow } = useOAuthLoginFlow({
|
||||
...authFlow.callbacks,
|
||||
onSuccess: finishSignIn,
|
||||
});
|
||||
|
||||
const displayStep = computed(() => (pageStep.value === 'flow' ? step.value : pageStep.value));
|
||||
|
||||
function finishSignIn(): void {
|
||||
// The cookie is already persisted (onToken); here we only navigate.
|
||||
const target = redirectUri.value;
|
||||
if (target) {
|
||||
// replace(): the interstitial should not stay in browser history.
|
||||
location.replace(target);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.title = t('login.title');
|
||||
redirectUri.value = parseRedirectUri(location.search);
|
||||
// The tunnel bounces users back with force_relogin=1 when the presented
|
||||
// cookie was rejected (revoked, wrong environment): drop it and start over
|
||||
// instead of ping-ponging between this page and the target.
|
||||
const forceRelogin = new URLSearchParams(location.search).get('force_relogin') === '1';
|
||||
if (forceRelogin) {
|
||||
document.cookie = clearTokenCookie();
|
||||
} else if (readTokenCookie(document.cookie)) {
|
||||
// A readable cookie is unexpired by definition (Expires handles eviction),
|
||||
// so its presence means "already signed in".
|
||||
if (redirectUri.value) {
|
||||
location.replace(redirectUri.value);
|
||||
} else {
|
||||
pageStep.value = 'signed-in';
|
||||
}
|
||||
return;
|
||||
}
|
||||
pageStep.value = 'flow';
|
||||
await startFlow();
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
|
||||
async function copyLink(): Promise<void> {
|
||||
if (!flow.value) return;
|
||||
const ok = await copyTextToClipboard(flow.value.verificationUriComplete);
|
||||
if (!ok) return;
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Format seconds as m:ss
|
||||
function formatSeconds(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page">
|
||||
<div class="panel">
|
||||
<header class="brand">
|
||||
<!-- Legacy "little blue" brand mark (static rendering of the web
|
||||
onboarding BrandLogo, minus the blink easter egg). -->
|
||||
<svg
|
||||
class="brand-logo"
|
||||
viewBox="0 0 32 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Kimi Code"
|
||||
>
|
||||
<defs>
|
||||
<mask :id="maskId" maskUnits="userSpaceOnUse">
|
||||
<rect x="0" y="0" width="32" height="22" fill="#fff" />
|
||||
<g fill="#000">
|
||||
<rect x="11.8" y="7" width="2.8" height="8" rx="1.4" />
|
||||
<rect x="17.4" y="7" width="2.8" height="8" rx="1.4" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" :mask="`url(#${maskId})`" />
|
||||
</svg>
|
||||
<h1 class="title">{{ t('login.title') }}</h1>
|
||||
<p class="subtitle">{{ t('login.rcSubtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<Transition name="step" mode="out-in">
|
||||
<!-- Checking cookie / starting the flow -->
|
||||
<div v-if="displayStep === 'checking' || displayStep === 'starting'" key="busy" class="center-body">
|
||||
<Spinner size="md" />
|
||||
<span class="center-text">{{ displayStep === 'checking' ? t('login.rcChecking') : t('login.starting') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Device-code step -->
|
||||
<div v-else-if="displayStep === 'device-code' && flow" key="device-code" class="flow-body">
|
||||
<p class="lead">{{ t('login.rcLead') }}</p>
|
||||
|
||||
<!-- Primary path: open the complete URI (device code embedded). An
|
||||
anchor, not a Button — it must keep href/target (same pattern as
|
||||
the client's LoginDialog). -->
|
||||
<a
|
||||
class="primary-link"
|
||||
:href="flow.verificationUriComplete"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('login.rcAuthorize') }}
|
||||
<Icon name="external-link" size="sm" />
|
||||
</a>
|
||||
|
||||
<!-- Verification code + copyable link for the "open it elsewhere"
|
||||
path -->
|
||||
<div class="code-row">
|
||||
<div class="code-meta">
|
||||
<span class="code-label">{{ t('login.rcUserCodeLabel') }}</span>
|
||||
<span class="code">{{ flow.userCode }}</span>
|
||||
</div>
|
||||
<Button class="copy-btn" :class="{ 'is-copied': copied }" variant="secondary" size="sm" @click="copyLink">
|
||||
<template v-if="copied">
|
||||
<Icon name="check" size="sm" />
|
||||
{{ t('login.copied') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon name="copy" size="sm" />
|
||||
{{ t('login.copyLink') }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="status">
|
||||
<Spinner size="sm" :label="t('login.waitingAuth')" />
|
||||
<span class="status-text">{{ t('login.waitingAutoClose') }}</span>
|
||||
<span class="countdown">{{ formatSeconds(secondsLeft) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success (just authorized) -->
|
||||
<div v-else-if="displayStep === 'success'" key="success" class="center-body">
|
||||
<AuthStateIcon kind="success" />
|
||||
<span class="center-text success-text">{{ t('login.success') }}</span>
|
||||
<span class="center-hint">{{ redirectUri ? t('login.rcSuccessHint') : t('login.rcSuccessNoRedirect') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Already signed in (valid cookie, no redirect target) -->
|
||||
<div v-else-if="displayStep === 'signed-in'" key="signed-in" class="center-body">
|
||||
<AuthStateIcon kind="success" />
|
||||
<span class="center-text success-text">{{ t('login.success') }}</span>
|
||||
<span class="center-hint">{{ t('login.rcSuccessNoRedirect') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Expired / declined -->
|
||||
<div v-else-if="displayStep === 'expired'" key="expired" class="center-body">
|
||||
<AuthStateIcon kind="expired" />
|
||||
<span class="center-text err-text">{{ t('login.rcExpiredTitle') }}</span>
|
||||
<span class="center-hint">{{ t('login.expiredHint') }}</span>
|
||||
<Button variant="primary" class="retry-btn" @click="startFlow">{{ t('login.retry') }}</Button>
|
||||
</div>
|
||||
|
||||
<!-- Error (start failure or repeated poll failures) -->
|
||||
<div v-else-if="displayStep === 'error'" key="error" class="center-body">
|
||||
<AuthStateIcon kind="error" />
|
||||
<span class="center-text warn-text">
|
||||
{{ pollError ? t('login.pollErrorTitle') : t('login.rcStartErrorTitle') }}
|
||||
</span>
|
||||
<span class="center-hint">{{ t('login.rcConnectionErrorHint') }}</span>
|
||||
<Button variant="primary" class="retry-btn" @click="startFlow">{{ t('login.retry') }}</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: calc(var(--space-4) + env(safe-area-inset-top))
|
||||
calc(var(--space-4) + env(safe-area-inset-right))
|
||||
calc(var(--space-4) + env(safe-area-inset-bottom))
|
||||
calc(var(--space-4) + env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* Desktop widths: centered quiet card (mobile keeps the flat page). */
|
||||
@media (min-width: 640px) {
|
||||
.panel {
|
||||
background: var(--color-surface);
|
||||
border: var(--p-hairline) solid var(--color-line);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Brand */
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 56px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Centered single-state bodies */
|
||||
.center-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
.center-text {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.success-text { color: var(--color-success); }
|
||||
.err-text { color: var(--color-danger); }
|
||||
.warn-text { color: var(--color-warning); }
|
||||
.center-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.retry-btn {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Device-code body */
|
||||
.flow-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
line-height: var(--leading-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Primary action: open the complete verification URI. */
|
||||
.primary-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: var(--touch-target-min);
|
||||
padding: 0 var(--space-4);
|
||||
box-sizing: border-box;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
border: var(--p-hairline) solid var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out),
|
||||
transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
/* Hover only where hover exists (touch taps would flash it). */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.primary-link:hover { background: var(--color-accent-hover); border-color: var(--color-accent-hover); }
|
||||
}
|
||||
.primary-link:active { transform: scale(0.98); }
|
||||
.primary-link:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
|
||||
|
||||
/* Code + copy row */
|
||||
.code-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: var(--color-surface-sunken);
|
||||
border: var(--p-hairline) solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.code-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.code-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text);
|
||||
letter-spacing: 0.14em;
|
||||
user-select: text;
|
||||
}
|
||||
.copy-btn { flex: none; }
|
||||
.copy-btn.is-copied { color: var(--color-success); border-color: var(--color-success-bd); }
|
||||
|
||||
/* Mobile: the copy action drops to a full-width second row under the code. */
|
||||
@media (max-width: 639px) {
|
||||
.code-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.copy-btn {
|
||||
min-height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: var(--p-hairline) solid var(--color-line);
|
||||
}
|
||||
.status-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.countdown {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* State transitions: short fade + slight rise (reduced-motion friendly). */
|
||||
.step-enter-active,
|
||||
.step-leave-active {
|
||||
transition: opacity var(--duration-fast) var(--ease-out),
|
||||
transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.step-enter-from { opacity: 0; transform: translateY(4px); }
|
||||
.step-leave-to { opacity: 0; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.step-enter-active,
|
||||
.step-leave-active { transition: none; }
|
||||
}
|
||||
</style>
|
||||
84
apps/auth-login/src/auth-token.ts
Normal file
84
apps/auth-login/src/auth-token.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Cookie + query-param helpers for the auth-login page. Pure functions so the
|
||||
// contract stays unit-testable; the page wires them to document/location.
|
||||
|
||||
export const TOKEN_COOKIE_NAME = 'kimi-auth';
|
||||
|
||||
/** Read the access token from a `document.cookie`-shaped string. Returns null
|
||||
when absent or empty. */
|
||||
export function readTokenCookie(cookieString: string): string | null {
|
||||
for (const part of cookieString.split(';')) {
|
||||
const [name, ...rest] = part.trim().split('=');
|
||||
if (name === TOKEN_COOKIE_NAME) {
|
||||
const value = rest.join('=');
|
||||
return value.length > 0 ? decodeURIComponent(value) : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface TokenCookieOptions {
|
||||
/** Unix seconds when the token expires; mapped to the cookie's Expires. */
|
||||
readonly expiresAt: number;
|
||||
/** Emit the Secure attribute. True on https; false for http://localhost dev. */
|
||||
readonly secure: boolean;
|
||||
}
|
||||
|
||||
/** Serialize the token cookie. SameSite=Lax so the tunnel's top-level
|
||||
navigation back to the app carries it; Path=/ so every route on the host
|
||||
sees it. */
|
||||
export function buildTokenCookie(accessToken: string, options: TokenCookieOptions): string {
|
||||
const expires = new Date(options.expiresAt * 1000).toUTCString();
|
||||
const parts = [
|
||||
`${TOKEN_COOKIE_NAME}=${encodeURIComponent(accessToken)}`,
|
||||
'Path=/',
|
||||
`Expires=${expires}`,
|
||||
'SameSite=Lax',
|
||||
];
|
||||
if (options.secure) parts.push('Secure');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
/** Serialize an immediately-expired token cookie (i.e. delete it). Used when
|
||||
the server side has rejected the token — e.g. the tunnel bounces the user
|
||||
back with `force_relogin=1` — so a stale cookie can't ping-pong the user
|
||||
between this page and the target. */
|
||||
export function clearTokenCookie(): string {
|
||||
return `${TOKEN_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
|
||||
}
|
||||
|
||||
/** Parse and validate the `redirect_uri` query param. Only absolute http(s)
|
||||
URLs are accepted — anything else (javascript:, relative, protocol-relative)
|
||||
is rejected so the page cannot become an open-redirect trampoline onto
|
||||
non-http schemes. Returns null when missing or invalid. */
|
||||
export function parseRedirectUri(search: string): string | null {
|
||||
const raw = new URLSearchParams(search).get('redirect_uri');
|
||||
if (!raw) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const LOCALE_COOKIE_NAME = 'KIMI_LOCALE';
|
||||
|
||||
/** Read the locale preference from the shared `KIMI_LOCALE` cookie (set on the
|
||||
root domain, so the main site's language choice carries over to this page).
|
||||
Only en/zh are recognized (regional variants like zh-CN / en-US count);
|
||||
anything else — or absence — returns null and the caller falls back to the
|
||||
browser language, then English. */
|
||||
export function readLocaleCookie(cookieString: string): 'en' | 'zh' | null {
|
||||
for (const part of cookieString.split(';')) {
|
||||
const [name, ...rest] = part.trim().split('=');
|
||||
if (name === LOCALE_COOKIE_NAME) {
|
||||
const value = rest.join('=').toLowerCase();
|
||||
if (value.startsWith('zh')) return 'zh';
|
||||
if (value.startsWith('en')) return 'en';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
21
apps/auth-login/src/env.d.ts
vendored
Normal file
21
apps/auth-login/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
// `~icons/*?raw` module declarations live in the shared preset.
|
||||
import '@moonshot-ai/vite-preset/icons';
|
||||
|
||||
declare global {
|
||||
// Injected by Vite `define` (see vite.config.ts): this bundle never ships
|
||||
// inside the desktop app.
|
||||
const __KIMI_WEB_DESKTOP__: boolean;
|
||||
|
||||
// Injected by Vite `define` (from @moonshot-ai/vite-preset): bundle build
|
||||
// time (ISO). Declared for shared sources that may reference it.
|
||||
const __KIMI_BUILD_TIME__: string;
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
160
apps/auth-login/src/flow.ts
Normal file
160
apps/auth-login/src/flow.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// Adapts the shared useOAuthLoginFlow state machine to direct browser-side
|
||||
// device-flow calls against the OAuth host (the desktop/web apps inject daemon
|
||||
// callbacks instead). The OAuth host allows cross-origin POSTs, so the page
|
||||
// talks to it straight from the browser.
|
||||
|
||||
import type {
|
||||
OAuthLoginFlowCallbacks,
|
||||
OAuthLoginStartResult,
|
||||
} from '@moonshot-ai/app-client/composables';
|
||||
import {
|
||||
KIMI_CODE_FLOW_CONFIG,
|
||||
pollDeviceToken,
|
||||
requestDeviceAuthorization,
|
||||
type TokenInfo,
|
||||
} from '@moonshot-ai/kimi-code-oauth/device';
|
||||
|
||||
const DEVICE_ID_STORAGE_KEY = 'kimi-code-auth-login.device-id';
|
||||
const APP_VERSION = '0.0.0';
|
||||
/** Fallback when the OAuth host omits expires_in (it currently sends 1800). */
|
||||
const DEFAULT_EXPIRES_IN = 1800;
|
||||
/** Extra delay folded into a poll callback after a `slow_down` response
|
||||
(RFC 8628 §3.5 asks for +5s). The shared state machine schedules polls at a
|
||||
fixed interval, so the backoff is absorbed here by returning later. */
|
||||
const SLOW_DOWN_EXTRA_MS = 5000;
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
/** crypto.randomUUID is secure-context-only, and this page may well be served
|
||||
over plain http (LAN IP, plain-http tunnel) — fall back to getRandomValues
|
||||
(available in every context), then Math.random. The id feeds an auxiliary
|
||||
header, not a secret, so the weakest fallback is still acceptable. */
|
||||
function randomId(): string {
|
||||
const c = globalThis.crypto;
|
||||
if (typeof c?.randomUUID === 'function') return c.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
if (typeof c?.getRandomValues === 'function') {
|
||||
c.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
let memoryDeviceId: string | null = null;
|
||||
|
||||
/** Stable per-browser id, the equivalent of the CLI's `<home>/device_id`.
|
||||
Falls back to a per-page random id when storage is unavailable (private
|
||||
mode, blocked origin) — the header is auxiliary and must not block sign-in. */
|
||||
function deviceId(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(DEVICE_ID_STORAGE_KEY);
|
||||
if (stored) return stored;
|
||||
const id = randomId();
|
||||
localStorage.setItem(DEVICE_ID_STORAGE_KEY, id);
|
||||
return id;
|
||||
} catch {
|
||||
memoryDeviceId ??= randomId();
|
||||
return memoryDeviceId;
|
||||
}
|
||||
}
|
||||
|
||||
/** X-Msh-* identity reported to the OAuth host: the platform value reuses the
|
||||
CLI's by decision, and the User-Agent stays the browser's own — it cannot
|
||||
be overridden from JS. */
|
||||
function deviceHeaders(): Record<string, string> {
|
||||
return {
|
||||
'X-Msh-Platform': 'kimi_code_cli',
|
||||
'X-Msh-Version': APP_VERSION,
|
||||
'X-Msh-Device-Id': deviceId(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthLoginFlowOptions {
|
||||
/** Called the instant a poll returns the token — before the state machine's
|
||||
success dwell — so the cookie can be persisted even if the user closes
|
||||
the tab while the success state is showing. */
|
||||
readonly onToken?: (token: TokenInfo) => void;
|
||||
}
|
||||
|
||||
export interface AuthLoginFlow {
|
||||
readonly callbacks: OAuthLoginFlowCallbacks;
|
||||
/** Token captured by the latest successful poll. */
|
||||
readonly authenticatedToken: () => TokenInfo | null;
|
||||
}
|
||||
|
||||
export function createAuthLoginFlow(options?: AuthLoginFlowOptions): AuthLoginFlow {
|
||||
let deviceCode: string | null = null;
|
||||
let token: TokenInfo | null = null;
|
||||
|
||||
async function onStartOAuthLogin(): Promise<OAuthLoginStartResult | null> {
|
||||
let auth;
|
||||
try {
|
||||
auth = await requestDeviceAuthorization(KIMI_CODE_FLOW_CONFIG, {
|
||||
deviceHeaders: deviceHeaders(),
|
||||
});
|
||||
} catch {
|
||||
// Start failure → the state machine shows the error step.
|
||||
return null;
|
||||
}
|
||||
deviceCode = auth.deviceCode;
|
||||
token = null;
|
||||
const expiresIn = auth.expiresIn ?? DEFAULT_EXPIRES_IN;
|
||||
return {
|
||||
flowId: auth.deviceCode,
|
||||
provider: KIMI_CODE_FLOW_CONFIG.name,
|
||||
status: 'pending',
|
||||
verificationUri: auth.verificationUri,
|
||||
verificationUriComplete: auth.verificationUriComplete,
|
||||
userCode: auth.userCode,
|
||||
expiresIn,
|
||||
interval: auth.interval,
|
||||
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function onPollOAuthLogin(): Promise<{
|
||||
flowId: string;
|
||||
status: 'pending' | 'authenticated' | 'expired' | 'cancelled';
|
||||
resolvedAt?: string;
|
||||
} | null> {
|
||||
if (!deviceCode) return null;
|
||||
let result;
|
||||
try {
|
||||
result = await pollDeviceToken(KIMI_CODE_FLOW_CONFIG, deviceCode, {
|
||||
deviceHeaders: deviceHeaders(),
|
||||
});
|
||||
} catch {
|
||||
// Transport/unknown failure reads as a blip; the state machine retries
|
||||
// and turns three in a row into the error step.
|
||||
return null;
|
||||
}
|
||||
const flowId = deviceCode;
|
||||
const resolvedAt = new Date().toISOString();
|
||||
switch (result.kind) {
|
||||
case 'success':
|
||||
token = result.token;
|
||||
options?.onToken?.(result.token);
|
||||
return { flowId, status: 'authenticated', resolvedAt };
|
||||
case 'pending':
|
||||
if (result.errorCode === 'slow_down') await sleep(SLOW_DOWN_EXTRA_MS);
|
||||
return { flowId, status: 'pending' };
|
||||
case 'expired':
|
||||
return { flowId, status: 'expired', resolvedAt };
|
||||
case 'denied':
|
||||
return { flowId, status: 'cancelled', resolvedAt };
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancelOAuthLogin(): Promise<void> {
|
||||
deviceCode = null;
|
||||
}
|
||||
|
||||
return {
|
||||
callbacks: { onStartOAuthLogin, onPollOAuthLogin, onCancelOAuthLogin },
|
||||
authenticatedToken: () => token,
|
||||
};
|
||||
}
|
||||
22
apps/auth-login/src/i18n.ts
Normal file
22
apps/auth-login/src/i18n.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createKimiI18n } from '@moonshot-ai/app-i18n';
|
||||
import { readLocaleCookie } from './auth-token';
|
||||
|
||||
// Locale resolution for this page: the shared `KIMI_LOCALE` cookie (a
|
||||
// root-domain cookie, so the main site's language choice carries over) →
|
||||
// browser language → English.
|
||||
function detectLocale(): 'en' | 'zh' {
|
||||
try {
|
||||
const fromCookie = readLocaleCookie(document.cookie);
|
||||
if (fromCookie) return fromCookie;
|
||||
} catch {
|
||||
// document unavailable — fall through to the browser language.
|
||||
}
|
||||
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
||||
}
|
||||
|
||||
// Single i18n instance from the shared app-i18n factory: the page reuses the
|
||||
// client's `login` namespace (plus the rc* keys) and `common` (Spinner's aria
|
||||
// label).
|
||||
export const i18n = createKimiI18n({ locale: detectLocale() });
|
||||
|
||||
export default i18n;
|
||||
22
apps/auth-login/src/main.ts
Normal file
22
apps/auth-login/src/main.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createApp } from 'vue';
|
||||
import { IconResolverKey } from '@moonshot-ai/app-ui';
|
||||
import { KimiI18nKey, type KimiI18nApi } from '@moonshot-ai/app-i18n';
|
||||
import { getIcon, type IconName } from '@moonshot-ai/app-client/icons';
|
||||
import App from './App.vue';
|
||||
import i18n from './i18n';
|
||||
import './style.css';
|
||||
|
||||
document.documentElement.lang = i18n.global.locale.value === 'zh' ? 'zh-CN' : 'en';
|
||||
|
||||
const app = createApp(App).use(i18n);
|
||||
// Let package components (e.g. Spinner's aria label) translate without
|
||||
// importing the global vue-i18n directly.
|
||||
const kimiI18n: KimiI18nApi = {
|
||||
t: (key, params) => i18n.global.t(key, params as never),
|
||||
locale: i18n.global.locale.value,
|
||||
};
|
||||
app.provide(KimiI18nKey, kimiI18n);
|
||||
// Bridge app-ui's <Icon> to the icon registry (unplugin-icons `kimi`
|
||||
// collection, wired in vite.config.ts).
|
||||
app.provide(IconResolverKey, (name) => getIcon(name as IconName)?.component);
|
||||
app.mount('#app');
|
||||
21
apps/auth-login/src/style.css
Normal file
21
apps/auth-login/src/style.css
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
@import '@moonshot-ai/app-ui/style.css';
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
106
apps/auth-login/test/auth-token.test.ts
Normal file
106
apps/auth-login/test/auth-token.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTokenCookie,
|
||||
clearTokenCookie,
|
||||
parseRedirectUri,
|
||||
readLocaleCookie,
|
||||
readTokenCookie,
|
||||
TOKEN_COOKIE_NAME,
|
||||
} from '../src/auth-token';
|
||||
|
||||
describe('readTokenCookie', () => {
|
||||
it('returns null when the cookie is absent', () => {
|
||||
expect(readTokenCookie('')).toBeNull();
|
||||
expect(readTokenCookie('other=abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the token among several cookies', () => {
|
||||
expect(readTokenCookie(`a=1; ${TOKEN_COOKIE_NAME}=tok123; b=2`)).toBe('tok123');
|
||||
});
|
||||
|
||||
it('returns null for an empty value', () => {
|
||||
expect(readTokenCookie(`${TOKEN_COOKIE_NAME}=`)).toBeNull();
|
||||
});
|
||||
|
||||
it('decodes percent-encoded values', () => {
|
||||
expect(readTokenCookie(`${TOKEN_COOKIE_NAME}=${encodeURIComponent('a b=c')}`)).toBe('a b=c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTokenCookie', () => {
|
||||
it('sets Path/Expires/SameSite and Secure on https', () => {
|
||||
const cookie = buildTokenCookie('tok', { expiresAt: 1_900_000_000, secure: true });
|
||||
expect(cookie).toContain(`${TOKEN_COOKIE_NAME}=tok`);
|
||||
expect(cookie).toContain('Path=/');
|
||||
expect(cookie).toContain(`Expires=${new Date(1_900_000_000 * 1000).toUTCString()}`);
|
||||
expect(cookie).toContain('SameSite=Lax');
|
||||
expect(cookie).toContain('Secure');
|
||||
});
|
||||
|
||||
it('omits Secure on http (localhost dev)', () => {
|
||||
const cookie = buildTokenCookie('tok', { expiresAt: 1_900_000_000, secure: false });
|
||||
expect(cookie).not.toContain('Secure');
|
||||
});
|
||||
|
||||
it('encodes the token value', () => {
|
||||
expect(buildTokenCookie('a b', { expiresAt: 1_900_000_000, secure: true })).toContain(
|
||||
`${TOKEN_COOKIE_NAME}=a%20b`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearTokenCookie', () => {
|
||||
it('expires the cookie immediately', () => {
|
||||
const cookie = clearTokenCookie();
|
||||
expect(cookie).toContain(`${TOKEN_COOKIE_NAME}=;`);
|
||||
expect(cookie).toContain('Expires=Thu, 01 Jan 1970');
|
||||
expect(cookie).toContain('Path=/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRedirectUri', () => {
|
||||
it('accepts absolute https URLs', () => {
|
||||
expect(parseRedirectUri('?redirect_uri=https%3A%2F%2Frc.example.com%2Fs%2Fabc')).toBe(
|
||||
'https://rc.example.com/s/abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts http URLs (local tunnel dev)', () => {
|
||||
expect(parseRedirectUri('?redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2F')).toBe(
|
||||
'http://127.0.0.1:8080/',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(parseRedirectUri(`?redirect_uri=${encodeURIComponent('javascript:alert(1)')}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects missing or unparsable values', () => {
|
||||
expect(parseRedirectUri('')).toBeNull();
|
||||
expect(parseRedirectUri('?redirect_uri=')).toBeNull();
|
||||
expect(parseRedirectUri('?redirect_uri=%2F%2Fevil.example.com')).toBeNull();
|
||||
expect(parseRedirectUri(`?redirect_uri=${encodeURIComponent('/relative/path')}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLocaleCookie', () => {
|
||||
it('returns null when the cookie is absent', () => {
|
||||
expect(readLocaleCookie('')).toBeNull();
|
||||
expect(readLocaleCookie('other=1')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads zh and en values among several cookies', () => {
|
||||
expect(readLocaleCookie('KIMI_LOCALE=zh')).toBe('zh');
|
||||
expect(readLocaleCookie(`a=1; KIMI_LOCALE=en; ${TOKEN_COOKIE_NAME}=x`)).toBe('en');
|
||||
});
|
||||
|
||||
it('accepts regional variants', () => {
|
||||
expect(readLocaleCookie('KIMI_LOCALE=zh-CN')).toBe('zh');
|
||||
expect(readLocaleCookie('KIMI_LOCALE=en-US')).toBe('en');
|
||||
});
|
||||
|
||||
it('ignores unknown values', () => {
|
||||
expect(readLocaleCookie('KIMI_LOCALE=fr')).toBeNull();
|
||||
});
|
||||
});
|
||||
23
apps/auth-login/tsconfig.json
Normal file
23
apps/auth-login/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"types": ["vite/client", "unplugin-icons/types/vue"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "test/**/*.ts"]
|
||||
}
|
||||
40
apps/auth-login/vite.config.ts
Normal file
40
apps/auth-login/vite.config.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { kimiRendererViteConfig } from '@moonshot-ai/vite-preset';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Dev/preview port for the standalone auth-login page.
|
||||
const port = Number(process.env.AUTH_LOGIN_PORT) || 5176;
|
||||
|
||||
// Shared renderer preset (Vue plugin, unplugin-icons `kimi` collection sourced
|
||||
// from app-client's icon dir, ES2022 target). This page talks directly to the
|
||||
// OAuth host (CORS is open), so there is no dev proxy to layer on.
|
||||
const preset = kimiRendererViteConfig({
|
||||
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||
iconsDir: fileURLToPath(
|
||||
new URL('./src/icons/kimi', import.meta.resolve('@moonshot-ai/app-client/package.json')),
|
||||
),
|
||||
defines: {
|
||||
// This bundle never ships inside the desktop app (shared sources read the
|
||||
// flag through a `typeof` guard, so this stays `false` everywhere).
|
||||
__KIMI_WEB_DESKTOP__: JSON.stringify(false),
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
...preset,
|
||||
// Relative asset URLs: the bundle can be mounted at any path on the RC
|
||||
// tunnel host without a rebuild.
|
||||
base: './',
|
||||
build: {
|
||||
...preset.build,
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port,
|
||||
strictPort: false,
|
||||
},
|
||||
preview: {
|
||||
port: Number(process.env.AUTH_LOGIN_PREVIEW_PORT) || 4176,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
@import '@moonshot-ai/app-ui/style.css';
|
||||
@import '@moonshot-ai/app-ui/fonts.css';
|
||||
|
||||
/* ---- Minimal reset (replaces Tailwind preflight) ----
|
||||
Just enough normalization so UA defaults don't leak through; the app's own
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It t
|
|||
## Design system (normative — required when modifying the UI)
|
||||
|
||||
- **Before changing any component, style, layout, or theme, read the design system view at `src/views/DesignSystemView.vue` (open it as an overlay: long-press the sidebar logo).** It is the canonical design system and visual spec for this app (tokens §02, primitives §03, chat §04, theme rules §05, style rules §06). It consumes the product tokens from `src/style.css` directly, so it stays in sync with the app. New and modified UI must match it.
|
||||
- **Use the primitives from `@moonshot-ai/app-ui`** (source in `packages/app-ui`; import as `import { Button, Icon, … } from '@moonshot-ai/app-ui'`, and the app's `src/style.css` already imports `@moonshot-ai/app-ui/style.css` for the tokens). The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks).
|
||||
- **Use the primitives from `@moonshot-ai/app-ui`** (source in `packages/app-ui`; import as `import { Button, Icon, … } from '@moonshot-ai/app-ui'`, and the app's `src/style.css` imports `@moonshot-ai/app-ui/style.css` for the tokens plus `@moonshot-ai/app-ui/fonts.css` for the self-hosted fonts — a page that only needs tokens may import `style.css` alone). The library covers Button, IconButton, Badge, Pill, Card, Input/Select/Textarea/Field, Dialog, Spinner, Link, Menu/MenuItem, SegmentedControl, Tabs, Switch, Checkbox, Avatar, EmptyState, Divider, Tooltip, Banner, Sheet, Skeleton, CommandBar, TopBar. One semantic = one component — do not hand-roll a bespoke button/badge/dialog/input for a single screen. When a primitive replaces an element, **delete the old scoped CSS** (do not append override blocks).
|
||||
- **Use the tokens, not ad-hoc values.** Colors, fonts, radii, spacing, shadows, z-index, and motion come from the CSS custom properties in `src/style.css` (catalogued in the design-system view §02). Canonical names are `--color-*` / `--radius-*` / `--space-*` / `--text-*` / `--font-*` / `--z-*` / `--shadow-*` / `--ease-*` / `--duration-*` / `--weight-*` / `--leading-*`, plus `--anim-*` for the designer-exported icon animation tracks (§02 Motion; verbatim Rive timings, intentionally outside the `--duration-*` ramp). A small set of layout/focus tokens keep the `--p-` prefix: `--p-focus-ring`, `--p-selection`, `--p-ic-sm/md/lg`, `--p-sidebar-w`, `--p-content-max/-wide`, `--p-table-max/-cell-max`, `--p-findbar-w`, `--p-findring-w`, `--p-hairline`, `--p-bp-sm/-md`.
|
||||
- **The chat working indicator (小蓝 mascot + phase label) is reserved** for the chat working state after a prompt is sent only, and is rendered solely by `chat/WorkingIndicator.vue`; every other loading state uses the plain `Spinner`.
|
||||
- **Run `pnpm --filter kimi-code-web check:style`** (`scripts/check-style.mjs`) — it enforces the §06 anti-pattern rules (no-gradient, no-glassmorphism except TopBar `frost` and the whitelisted menu surfaces via the `--p-menu-backdrop` token, no-emoji-icon, no-hardcoded-hex/font, radius/z/weight from scale). Do not add new violations.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@import '@moonshot-ai/app-ui/style.css';
|
||||
@import '@moonshot-ai/app-ui/fonts.css';
|
||||
|
||||
/* ---- Minimal reset (replaces Tailwind preflight) ----
|
||||
Just enough normalization so UA defaults don't leak through; the app's own
|
||||
|
|
|
|||
158
docs/plans/2026-08-13-rc-auth-login.md
Normal file
158
docs/plans/2026-08-13-rc-auth-login.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Remote Control 鉴权登录页(apps/auth-login)实施方案
|
||||
|
||||
> 2026-08-13。Remote Control(RC)链路的鉴权中间页:只承载「Kimi Code OAuth 登录 + 重定向」,移动端优先、响应式兼容 PC。已拍板决策:
|
||||
>
|
||||
> 1. 页面放 **code-app 仓内**,新 app 目录名 **`apps/auth-login`**。
|
||||
> 2. 登录逻辑**复用 kimi-code 仓的 `@moonshot-ai/kimi-code-oauth` 包**(device code flow),浏览器直连 `auth.kimi.com`;token 存 cookie。
|
||||
> 3. cookie 契约:名 **`kimi-auth`**;回跳参数名 **`redirect_uri`**。
|
||||
> 4. UI 与 web/desktop 同源(`@moonshot-ai/app-ui` tokens + 原语,Kimi 风格)。
|
||||
> 5. kimi-code 侧所需的 oauth 包改动由本人完成,工作克隆选干净的那个(见下文)。
|
||||
|
||||
## 背景:Remote Control 链路
|
||||
|
||||
1. 远程机器本地执行 TUI 的 `/rc` 命令 → 启动本地 server + kimi code web 服务。
|
||||
2. 请求在线服务 `kimi.com/rc` → 把本地 ip:port 转发到公网。
|
||||
3. 远端用户(手机)访问公网地址 → **先经过一层鉴权**:用 Kimi Code 的 OAuth 登录,通过后才能访问。
|
||||
|
||||
本方案只覆盖第 3 步里的**鉴权中间页**:隧道入口发现无有效凭证 → 跳转到本页(带 `redirect_uri`)→ 本页完成 OAuth device flow → 写 cookie → 跳回 `redirect_uri`。隧道服务本身的鉴权校验、`/rc` 命令、`kimi.com/rc` 转发服务均不在本方案范围。
|
||||
|
||||
## 已验证的事实(2026-08-13 实测 / 代码引用为准)
|
||||
|
||||
- **CORS 全通**:`OPTIONS https://auth.kimi.com/api/oauth/device_authorization` 与 `/api/oauth/token` 均返回 `access-control-allow-origin: *`,`content-type` / `x-msh-platform` 头放行。移动端浏览器直连 auth.kimi.com 无障碍。
|
||||
- **授权确认页已存在,不在本方案范围**:真实调用 `POST /api/oauth/device_authorization`(client_id `17e5f671-d194-4dfb-9706-5516cb48c098`)返回 `verification_uri = https://www.kimi.com/code/authorize_device`。用户在手机浏览器打开该页完成「登录账号 + 确认授权」,我们不做这个页面。实测该接口**不带 `X-Msh-*` 头也成功**,设备头可选。
|
||||
- **oauth 包的浏览器可用性**(`kimi-code/packages/oauth/src`):
|
||||
- `oauth.ts` 的 `requestDeviceAuthorization` / `pollDeviceToken` / `refreshAccessToken` 是纯 `fetch` 封装;依赖闭包(`api-error.ts` / `errors.ts` / `types.ts` / `utils.ts` / `constants.ts`)均无 Node import——**浏览器安全**。
|
||||
- 但顶层 `index.ts` barrel 会拖进 `OAuthManager`(`node:fs` / `proper-lockfile`)、`identity.ts`(`node:os` / `node:child_process`)、`toolkit.ts`、`storage.ts`——**浏览器 import 包根会炸**,必须加浏览器安全的子路径 export。
|
||||
- `tsdown.config.ts` 单入口 `./src/index.ts`;加子路径需同步加 entry。
|
||||
- `KIMI_CODE_FLOW_CONFIG`(oauthHost + clientId)由 `constants.ts` 导出,纯模块,直接复用——授权页显示的就是 Kimi Code,语义正确。
|
||||
- **workspace**:`pnpm-workspace.yaml` 已含 `kimi-code/packages/*`,新 app 按包名 `workspace:*` 依赖即可,符合「只经包名 import」硬约束。
|
||||
- **状态机复用**:`useOAuthLoginFlow`(`packages/app-client/src/composables/useOAuthLoginFlow.ts`)是回调注入式 device-flow 状态机(starting → device-code → success/expired/error、倒计时、轮询三连败容错、disposed 清理),埋点 `track` 默认 no-op(`contracts.ts` 的 `noopProductTracker`)。新 app 直接复用,回调改为直连 oauth 包,零重写。
|
||||
- **既有约定要守住**:授权完成页读 `from=kimi_code_desktop` 渲染「打开 App」按钮并跳 `kimi-code://auth/success`(`apps/desktop/src/renderer/lib/loginSource.ts`、`apps/desktop/src/main/deep-link.ts`)。RC 场景**不带** `from` 参数(裸 URL),该按钮保持隐藏。
|
||||
|
||||
## kimi-code 侧改动(一个小 PR)
|
||||
|
||||
**工作克隆选择**(2026-08-13 核查):
|
||||
|
||||
| 克隆 | 分支 | 状态 | 结论 |
|
||||
|---|---|---|---|
|
||||
| kimi-code-2 | feat/oauth-region-split | 3 脏文件 | 有任务,不动 |
|
||||
| kimi-code-3 | fix/sdk-retry-cancel | 干净但在任务分支 | 有任务,不动 |
|
||||
| kimi-code-5 | auto-title | 4 脏文件 | 有任务,不动 |
|
||||
| kimi-code-tips | feat/upgrade-reminder-banner | 2 脏文件 | 有任务,不动 |
|
||||
| kimi-code-ky | main | 干净但不可用(拍板排除) | 不动 |
|
||||
| kimi-code / kimi-code-4 | main | 干净、无未推送、node_modules 齐 | 候选 |
|
||||
|
||||
**选用 `~/Desktop/moonshot/kimi-code-4`**(干净 main;备选 kimi-code),工作分支 `feat/oauth-device-subpath-export`。
|
||||
|
||||
改动(`packages/oauth`,均为新增导出,无行为变更):
|
||||
|
||||
1. 新建 `src/device.ts`:re-export 浏览器安全闭包——`./oauth` 的三函数与 `DevicePollResult` 类型、`./constants` 的 `KIMI_CODE_FLOW_CONFIG`、`./types` 的相关类型(`DeviceAuthorization` / `TokenInfo` / `OAuthFlowConfig` / `OAuthRequestHeaders`)、`./errors` 的错误类。
|
||||
2. `package.json` `exports` 增加:`"./device": { "types": "./src/device.ts", "default": "./src/device.ts" }`。
|
||||
3. `tsdown.config.ts` `entry` 增加 `./src/device.ts`(dist 双产物)。
|
||||
4. 验证:`pnpm --filter @moonshot-ai/kimi-code-oauth typecheck`、`build`,以及 oauth 包既有测试全绿。
|
||||
5. PR 标题:`feat(oauth): add browser-safe ./device subpath export`(Conventional Commits,无署名)。
|
||||
|
||||
## code-app 侧改动
|
||||
|
||||
### 新 app `apps/auth-login`
|
||||
|
||||
**形态**:Vue 3 + Vite + TS strict,无 router、无 Pinia,vue-i18n v11(Composition 模式,`legacy: false`,fallback `en`,跟随浏览器 locale)。pnpm workspace 成员,private。
|
||||
|
||||
**依赖**:
|
||||
|
||||
- `@moonshot-ai/app-ui`(tokens + Button / Spinner / Icon 原语;`src/style.css` import `@moonshot-ai/app-ui/style.css`)
|
||||
- `@moonshot-ai/app-client`(`useOAuthLoginFlow`)
|
||||
- `@moonshot-ai/kimi-code-oauth`(**只经 `./device` 子路径** import)
|
||||
- `vue` / `vue-i18n`;dev 依赖 `@moonshot-ai/vite-preset`、`@vitejs/plugin-vue`、`vite`、`typescript`、`vue-tsc`、`vitest`
|
||||
|
||||
**vite 配置**:复用 `kimiRendererViteConfig`(iconsDir 指向 app-client 的 kimi 图标目录,同 apps/web 的做法);`base: './'`(产物可挂任意路径);**无 dev proxy**(浏览器直连 auth.kimi.com,无需代理);独立端口(如 `5176`,env `AUTH_LOGIN_PORT` 可调)。
|
||||
|
||||
**文件结构**:
|
||||
|
||||
```
|
||||
apps/auth-login/
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── index.html # viewport-fit=cover、页面标题、noindex
|
||||
├── src/
|
||||
│ ├── main.ts # 启动 + i18n 安装
|
||||
│ ├── App.vue # 单页状态机 UI(复用 useOAuthLoginFlow)
|
||||
│ ├── flow.ts # 回调适配:useOAuthLoginFlow ← oauth 包直连
|
||||
│ ├── auth-token.ts # cookie 读写 + redirect_uri 解析校验(纯函数,可测)
|
||||
│ ├── style.css # import app-ui tokens + 页面级样式
|
||||
│ └── i18n/
|
||||
│ ├── index.ts
|
||||
│ └── locales/{en,zh}.ts
|
||||
└── test/
|
||||
└── auth-token.test.ts # cookie / redirect_uri 纯函数测试
|
||||
```
|
||||
|
||||
**页面状态机**(进入页面即跑):
|
||||
|
||||
```
|
||||
1. checking:读 cookie kimi-auth
|
||||
→ 存在且未过期:有 redirect_uri → 立即 location.replace 回跳;无 → 显示「已登录,可关闭本页」
|
||||
2. 无/过期 → startFlow():
|
||||
requestDeviceAuthorization(flowConfig(), { deviceHeaders: 见「请求身份」节 })
|
||||
3. device-code:
|
||||
- 主按钮「前往授权」新标签打开 verification_uri_complete(authorize_device 页)
|
||||
- 展示 user_code + 复制授权链接(二次路径,同 LoginDialog 的 parked 思路)
|
||||
- 倒计时(expires_in 1800s)+ 轮询(pollDeviceToken,server 建议 interval=5s,三连败转 error)
|
||||
4. success:无条件写 cookie;有 redirect_uri → location.replace 回跳,无 → 停留显示「登录成功,可关闭本页」
|
||||
5. expired / error:提示 + 重试按钮(重新 startFlow)
|
||||
```
|
||||
|
||||
**cookie 写入**(`auth-token.ts`):
|
||||
|
||||
- `kimi-auth=<access_token>`;`Path=/`;`Secure`;`SameSite=Lax`(顶级导航跳转需携带);`Expires` = token 的 `expiresAt`。
|
||||
- 只存 access token(拍板);过期后隧道侧 401 → 再跳回本页 → 重走 device flow(authorize_device 页有会话时确认一键完成,体验可接受)。
|
||||
- refresh token 不落 cookie;页面生命周期短(登录即跳走),无需 refresh 逻辑。
|
||||
|
||||
**请求身份(拍板)**:
|
||||
|
||||
- `X-Msh-Platform: kimi_code_cli`(**拍板沿用 CLI 的值**,不新造 RC 专属 platform);`X-Msh-Version` = auth-login 自身版本;`X-Msh-Device-Id` = localStorage 持久化的 `crypto.randomUUID()`(首次访问生成,语义等价 CLI 的 device_id 文件)。`Device-Name` / `Device-Model` / `Os-Version` 浏览器拿不到准确值,不伪造、不带(实测不带头接口也正常,头是可选统计维度)。
|
||||
- `User-Agent`:**一定会传,且 JS 无法干预**——`User-Agent` 是浏览器 forbidden header,fetch 既改不了也删不掉,每个请求自动携带用户浏览器的原生 UA(iPhone Safari / Android Chrome 等)。Node 侧 CLI/desktop 经 `createKimiDefaultHeaders` 把产品 UA 注入 `deviceHeaders`(`kimi-code-cli/x.y.z`);浏览器里即使写这个键也会被 fetch 静默丢弃,所以页面不传。最终服务端看到的 RC web 流量 = `X-Msh-Platform: kimi_code_cli` + 浏览器原生 UA,按 UA 仍可区分出浏览器形态。
|
||||
- 自定义头触发 CORS preflight;`x-msh-platform` 已实测被服务端回显放行(动态回显),其余头实施时顺手验证。
|
||||
|
||||
**redirect_uri 处理(可选)**:
|
||||
|
||||
- `redirect_uri` **不必填**:隧道场景由隧道跳转时拼在登录地址上带来;直接访问(无参)也正常走登录流程。
|
||||
- 仅允许 `https:` / `http:`(拒绝 `javascript:` 等);缺失或非法一律按「无跳转目标」宽容降级,不阻塞登录。
|
||||
- 授权成功后 **cookie 无条件写入**;有 `redirect_uri` 才 `location.replace` 回跳,无则停留显示「登录成功,可以关闭本页」。已持有有效 cookie 直接进入时同理:有参跳走,无参显示已登录态。
|
||||
- 隧道域名动态生成,不做白名单;token 只经 cookie 传递、不上 URL,跳转本身不带敏感信息,开放重定向风险可控。
|
||||
|
||||
**移动端 / 响应式**:
|
||||
|
||||
- 页面无文本输入框(user_code 是展示),天然规避 iOS 聚焦缩放与键盘遮挡问题。
|
||||
- `viewport-fit=cover` + `env(safe-area-inset-*)` 上下安全区;触控目标 ≥ 44px。
|
||||
- 移动端纵向居中卡片全宽;`≥640px` 桌面居中窄卡片(约 400px),内容同构。
|
||||
- 同设备旅程:「前往授权」开新标签 → 授权 → 切回本标签时轮询已成功 → 自动跳走;授权页标签由用户自行关闭(与 desktop LoginDialog 同模式)。
|
||||
- 亮/暗双主题:跟随系统(`data-color-scheme` 模式,token 自动适配),亮暗两态都要视觉验证。
|
||||
|
||||
**样式纪律**:全部走 `--color-*` / `--space-*` / `--text-*` / `--radius-*` token,零 ad-hoc 值;UI 实现前读 `kimi-design-skill` 移动端规范与 `apps/desktop/src/renderer/views/DesignSystemView.vue`。
|
||||
|
||||
### 双仓工作流顺序
|
||||
|
||||
1. **kimi-code-4**:oauth 包子路径 export 改动 → typecheck/build/test → 推送分支、提 PR。
|
||||
2. **code-app 联调**:`kimi-code/` submodule `git fetch origin feat/oauth-device-subpath-export && git checkout <commit>`,auth-login 即可经 `./device` 子路径 import 开发。
|
||||
3. kimi-code PR merge 后:submodule checkout 到 main 的新 commit,`git add kimi-code` bump(单独 commit)。
|
||||
4. **auth-login 开发**:脚手架 → flow/cookie 纯函数 + 测试 → UI → i18n → 双主题视觉验证。
|
||||
5. 验证:`pnpm typecheck`、`pnpm lint`、auth-login 的 `vitest`;`pnpm dev` 起页面真实走一遍 device flow(桌面浏览器 + 手机各一遍),确认 cookie 写入与回跳。
|
||||
|
||||
### changeset
|
||||
|
||||
auth-login 独立于 desktop(`kimi-code-app`)发版,产物为静态页、不进 release CI。初步判断本改动对 kimi-code-app 无用户可见变化、**不需要 changeset**;提 PR 前按 `changeset` skill 规则最终确认。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不做授权确认页(`www.kimi.com/code/authorize_device` 已存在)。
|
||||
- 不动隧道服务鉴权逻辑、`/rc` 命令、`kimi.com/rc` 转发服务;cookie 契约以拍板为准。
|
||||
- 不接 telemetry(`track` 保持 no-op);不存 refresh token;不做登录态管理页。
|
||||
|
||||
## 风险与备注
|
||||
|
||||
- **CORS 后续收紧**:当前 `*` 且无凭证请求,风险低;若收紧,退路是 RC 服务同域反代 auth.kimi.com 接口(页面只改 baseURL)。
|
||||
- **authorize_device 页的移动端体验**归其所属团队;若发现该页移动端不适配,会影响整体旅程,需反馈——本方案不绕开它。
|
||||
- **cookie 域**:cookie 写在页面当前域,要求登录页与隧道入口同域部署(部署侧职责,产物用 `base: './'` 适配)。
|
||||
- **子路径 export 是新增面**:kimi-code 侧只加 export 不动行为,对 CLI/desktop 零影响。
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 43c68f58f578c88d9f503afb72f12d343c2aa5c7
|
||||
Subproject commit 4a93f70aa2cf5f70a88b4f8eeb2e409aab2c8f59
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"dev:web": "pnpm --filter kimi-code-web run dev",
|
||||
"sync:web": "node scripts/sync-web-to-kimi-code.mjs",
|
||||
"package:macos": "bash apps/desktop/scripts/package-local-macos.sh",
|
||||
"typecheck": "pnpm --filter kimi-code-app run typecheck && pnpm --filter kimi-code-web run typecheck",
|
||||
"typecheck": "pnpm --filter kimi-code-app run typecheck && pnpm --filter kimi-code-web run typecheck && pnpm --filter kimi-code-auth-login run typecheck",
|
||||
"lint": "oxlint --type-aware --config .oxlintrc.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,4 +30,15 @@ export default {
|
|||
// Send gating (signed-in free account, no usable models)
|
||||
upgradeRequiredTitle: 'Upgrade required',
|
||||
upgradeRequiredMessage: 'Your account is on the free plan. Upgrade to a membership to start chatting with Kimi models.',
|
||||
// Remote Control auth-login page (apps/auth-login)
|
||||
rcSubtitle: 'Remote Control session',
|
||||
rcChecking: 'Checking sign-in status…',
|
||||
rcLead: 'This Remote Control session requires authorization. Sign in with your Kimi account to continue.',
|
||||
rcAuthorize: 'Open authorization page',
|
||||
rcUserCodeLabel: 'Authorization code',
|
||||
rcSuccessHint: 'Redirecting…',
|
||||
rcExpiredTitle: 'Authorization expired or declined',
|
||||
rcStartErrorTitle: 'Could not start sign-in',
|
||||
rcConnectionErrorHint: 'Check your connection and try again.',
|
||||
rcSuccessNoRedirect: 'You can close this page now.',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -30,4 +30,15 @@ export default {
|
|||
// Send gating (signed-in free account, no usable models)
|
||||
upgradeRequiredTitle: '请升级会员',
|
||||
upgradeRequiredMessage: '当前为免费账户,升级会员后即可使用 Kimi 模型开始对话。',
|
||||
// Remote Control auth-login page (apps/auth-login)
|
||||
rcSubtitle: '远程控制会话',
|
||||
rcChecking: '正在检查登录状态…',
|
||||
rcLead: '此远程控制会话需要授权。使用 Kimi 账号登录后即可继续。',
|
||||
rcAuthorize: '打开授权页',
|
||||
rcUserCodeLabel: '授权码',
|
||||
rcSuccessHint: '正在跳转…',
|
||||
rcExpiredTitle: '授权已过期或被取消',
|
||||
rcStartErrorTitle: '无法开始登录',
|
||||
rcConnectionErrorHint: '请检查网络连接后重试。',
|
||||
rcSuccessNoRedirect: '现在可以关闭本页了。',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./style.css": "./src/style.css"
|
||||
"./style.css": "./src/style.css",
|
||||
"./fonts.css": "./src/fonts.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moonshot-ai/app-i18n": "workspace:*"
|
||||
|
|
|
|||
29
packages/app-ui/src/fonts.css
Normal file
29
packages/app-ui/src/fonts.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* Self-hosted variable fonts, generated once into ./assets/fonts by
|
||||
scripts/prepare-fonts.mjs (gitignored) and fingerprinted by Vite into each
|
||||
final build. Kept as a separate entry from style.css so font-free consumers
|
||||
(e.g. apps/auth-login, which falls back to platform fonts) don't ship
|
||||
~7.5MB of CJK. Consumers that want the Kimi typefaces import this once,
|
||||
alongside style.css. */
|
||||
@font-face {
|
||||
font-family: "Noto Sans SC Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url("./assets/fonts/NotoSansSC[wght].woff2") format("woff2-variations");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Schibsted Grotesk Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400 900;
|
||||
src: url("./assets/fonts/SchibstedGrotesk[wght].woff2") format("woff2-variations");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Schibsted Grotesk Variable";
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 400 900;
|
||||
src: url("./assets/fonts/SchibstedGrotesk-Italic[wght].woff2") format("woff2-variations");
|
||||
}
|
||||
|
|
@ -1,29 +1,6 @@
|
|||
/* UI variable fonts are generated once in app-ui so both renderer builds
|
||||
resolve the same local assets. Vite fingerprints them into each final build. */
|
||||
@font-face {
|
||||
font-family: "Noto Sans SC Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url("./assets/fonts/NotoSansSC[wght].woff2") format("woff2-variations");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Schibsted Grotesk Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400 900;
|
||||
src: url("./assets/fonts/SchibstedGrotesk[wght].woff2") format("woff2-variations");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Schibsted Grotesk Variable";
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 400 900;
|
||||
src: url("./assets/fonts/SchibstedGrotesk-Italic[wght].woff2") format("woff2-variations");
|
||||
}
|
||||
|
||||
/* Design tokens + shared styles. The self-hosted variable fonts live in
|
||||
./fonts.css, a separate entry so font-free consumers don't ship ~7.5MB of
|
||||
CJK — consumers that want the Kimi typefaces import it alongside this file. */
|
||||
:root {
|
||||
--dim: rgba(0, 0, 0, 0.6);
|
||||
--muted: rgba(0, 0, 0, 0.45);
|
||||
|
|
|
|||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
|
|
@ -53,6 +53,52 @@ importers:
|
|||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
|
||||
apps/auth-login:
|
||||
dependencies:
|
||||
'@moonshot-ai/app-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/app-client
|
||||
'@moonshot-ai/app-core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/app-core
|
||||
'@moonshot-ai/app-i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/app-i18n
|
||||
'@moonshot-ai/app-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/app-ui
|
||||
'@moonshot-ai/kimi-code-oauth':
|
||||
specifier: workspace:*
|
||||
version: link:../../kimi-code/packages/oauth
|
||||
vue:
|
||||
specifier: ^3.5.35
|
||||
version: 3.5.39(typescript@6.0.2)
|
||||
vue-i18n:
|
||||
specifier: ^11.4.5
|
||||
version: 11.4.6(vue@3.5.39(typescript@6.0.2))
|
||||
devDependencies:
|
||||
'@moonshot-ai/vite-preset':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/vite-preset
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.4
|
||||
version: 5.2.4(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.2))
|
||||
typescript:
|
||||
specifier: 6.0.2
|
||||
version: 6.0.2
|
||||
unplugin-icons:
|
||||
specifier: ^23.0.0
|
||||
version: 23.0.1(@vue/compiler-sfc@3.5.39)
|
||||
vite:
|
||||
specifier: ^6.3.3
|
||||
version: 6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: 4.1.4
|
||||
version: 4.1.4(@types/node@24.13.3)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.0)(yaml@2.9.0))
|
||||
vue-tsc:
|
||||
specifier: ~3.2.0
|
||||
version: 3.2.9(typescript@6.0.2)
|
||||
|
||||
apps/desktop:
|
||||
dependencies:
|
||||
'@xterm/addon-web-links':
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue