diff --git a/package.json b/package.json index e5b9200..77de37d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.6.6", + "version": "0.6.7", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4e78874..4415332 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.6.6" +version = "0.6.7" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 19e7d94..4f291ec 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.6.6" +version = "0.6.7" description = "Kimi Switch - model config manager" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 45da9c3..8ed4739 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1056,4 +1056,25 @@ pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), Strin app.opener() .open_url(url, None::<&str>) .map_err(|e| e.to_string()) -} \ No newline at end of file +} +// --------------------------------------------------------------------------- +// Kimi OAuth device-code sign-in (in-app replacement for `kimi login`) +// --------------------------------------------------------------------------- + +/// Step 1: ask auth.kimi.com for a user_code + verification URI. +#[tauri::command] +pub async fn kimi_oauth_start() -> Result { + crate::oauth::start_device_authorization().await +} + +/// Step 2: poll the token endpoint until the user approves. On success the +/// tokens are written to the CLI credentials file (`kimi login` no longer +/// needed). Errors are transient (network); deterministic outcomes +/// (pending / success / expired / denied / timeout) come back in the enum. +#[tauri::command] +pub async fn kimi_oauth_poll( + device_code: String, + interval: i64, +) -> Result { + crate::oauth::poll_device_token(&device_code, interval).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c763b8..ce58f72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -106,6 +106,8 @@ pub fn run() { commands::download_update, commands::open_installer, commands::open_external_url, + commands::kimi_oauth_start, + commands::kimi_oauth_poll, dashboard::get_paths, dashboard::get_prices, dashboard::get_summary, diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs index 75e4d12..da0d6ea 100644 --- a/src-tauri/src/oauth.rs +++ b/src-tauri/src/oauth.rs @@ -21,8 +21,18 @@ //! - right before writing back, the file is re-read once more — if the CLI //! refreshed meanwhile its newer tokens win (refresh tokens rotate on use, //! so clobbering the CLI's write would break its next refresh). +//! +//! v3 adds the in-app sign-in flow: a Device Code Flow (RFC 8628) identical +//! to the official `kimi` CLI, so users can authorize Kimi from the app +//! instead of running `kimi login` in a terminal. Flow: +//! 1. `start_device_authorization()` → POST /api/oauth/device_authorization, +//! returns user_code + device_code + verification_uri; +//! 2. user opens the verification URI in a browser and approves; +//! 3. `poll_device_token()` polls POST /api/oauth/token with the device_code +//! grant until the tokens arrive, then writes them to the same +//! `~/.kimi-code/credentials/kimi-code.json` file the CLI uses. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::kimi_code_io::kimi_code_config_dir; @@ -32,11 +42,17 @@ const EXPIRY_LEEWAY_SECS: i64 = 30; /// OAuth token endpoint (confirmed in the official kimi.exe binary). const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token"; +/// Device authorization endpoint (RFC 8628). +const DEVICE_AUTHORIZATION_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/device_authorization"; /// Public OAuth client id used by the official CLI (from kimi.exe). const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; /// Refresh request timeout; refresh is rare, a bit more headroom than the 8s /// query default is fine. const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +/// Device grant type (RFC 8628). +const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; +/// Total polling budget for the device flow, matching the official CLI. +const POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60); #[derive(Debug, Clone, Deserialize)] pub struct OAuthCredentials { @@ -227,6 +243,200 @@ async fn refresh_credentials(creds: &OAuthCredentials) -> Result Ok(token.access_token) } +// --------------------------------------------------------------------------- +// Device Code Flow (RFC 8628) — in-app sign-in, mirrors the official CLI +// --------------------------------------------------------------------------- + +/// Response of POST /api/oauth/device_authorization. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DeviceAuthorization { + pub user_code: String, + pub device_code: String, + pub verification_uri: Option, + pub verification_uri_complete: Option, + pub expires_in: Option, + pub interval: Option, +} + +/// Poll outcome, serialized back to the frontend so it can drive the dialog. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum DevicePollStatus { + /// Keep polling with the returned interval (seconds). + Pending { interval: i64 }, + /// Server asked to slow down; keep polling with interval + 5. + SlowDown { interval: i64 }, + /// Tokens obtained and written to the credentials file. + Success, + /// Device code expired; the user must start over. + Expired, + /// User denied the authorization. + AccessDenied, + /// Polling budget exhausted. + Timeout, +} + +/// `~/.kimi-code/device_id` — reused by the CLI so the app looks like the +/// same device. Created on first use. +fn device_id_path() -> std::path::PathBuf { + kimi_code_config_dir().join("device_id") +} + +fn load_or_create_device_id() -> Option { + let path = device_id_path(); + if let Ok(content) = std::fs::read_to_string(&path) { + let id = content.trim().to_string(); + if !id.is_empty() { + return Some(id); + } + } + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let id = format!("kimi-switch-{nanos:x}-{}", std::process::id()); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + // Best-effort write; a missing file just means the header is omitted. + let _ = std::fs::write(&path, &id); + Some(id) +} + +/// Device identity headers matching the official CLI (`X-Msh-*`). +fn identity_headers() -> Vec<(&'static str, String)> { + let hostname = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "kimi-switch".to_string()); + let os_version = std::env::var("OS").unwrap_or_else(|_| std::env::consts::OS.to_string()); + let mut headers = vec![ + ("X-Msh-Platform", "kimi_code_cli".to_string()), + ("X-Msh-Version", env!("CARGO_PKG_VERSION").to_string()), + ("X-Msh-Device-Name", hostname.clone()), + ( + "X-Msh-Device-Model", + format!("{} {}", std::env::consts::OS, hostname), + ), + ("X-Msh-Os-Version", os_version), + ]; + if let Some(device_id) = load_or_create_device_id() { + headers.push(("X-Msh-Device-Id", device_id)); + } + headers +} + +/// Step 1 of the device flow: ask the server for a user_code + device_code. +/// No user interaction required here; the frontend shows the code + URL. +pub async fn start_device_authorization() -> Result { + let client = reqwest::Client::builder() + .timeout(REFRESH_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + + let mut req = client + .post(DEVICE_AUTHORIZATION_ENDPOINT) + .form(&[("client_id", CLIENT_ID)]); + for (name, value) in identity_headers() { + req = req.header(name, value); + } + + let resp = req + .send() + .await + .map_err(|e| format!("Device authorization request failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let body: String = body.chars().take(200).collect(); + return Err(format!("Device authorization failed (HTTP {status}): {body}")); + } + resp.json::() + .await + .map_err(|e| format!("Failed to parse device authorization response: {e}")) +} + +/// Step 2 of the device flow: poll the token endpoint until the user +/// approves (or the flow fails). On success the tokens are merged into the +/// CLI credentials file, making `kimi login` unnecessary. +pub async fn poll_device_token( + device_code: &str, + initial_interval: i64, +) -> Result { + let client = reqwest::Client::builder() + .timeout(REFRESH_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + + let deadline = std::time::Instant::now() + POLL_TIMEOUT; + let mut interval = initial_interval.max(1); + + loop { + let mut req = client + .post(TOKEN_ENDPOINT) + .form(&[ + ("grant_type", DEVICE_GRANT_TYPE), + ("client_id", CLIENT_ID), + ("device_code", device_code), + ]); + for (name, value) in identity_headers() { + req = req.header(name, value); + } + + let resp = match req.send().await { + Ok(r) => r, + Err(e) => return Err(format!("Token polling request failed: {e}")), + }; + + if resp.status().is_success() { + let token: TokenResponse = match resp.json().await { + Ok(t) => t, + Err(e) => return Err(format!("Failed to parse token response: {e}")), + }; + persist_device_token(&token)?; + return Ok(DevicePollStatus::Success); + } + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let error: Option = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)); + match error.as_deref() { + Some("authorization_pending") => {} + Some("slow_down") => interval += 5, + Some("expired_token") => return Ok(DevicePollStatus::Expired), + Some("access_denied") => return Ok(DevicePollStatus::AccessDenied), + Some(other) => { + let body: String = body.chars().take(200).collect(); + return Err(format!("Token polling error ({other}): {body}")); + } + None => { + let body: String = body.chars().take(200).collect(); + return Err(format!("Token polling failed (HTTP {status}): {body}")); + } + } + + if std::time::Instant::now() + std::time::Duration::from_secs(interval as u64) >= deadline { + return Ok(DevicePollStatus::Timeout); + } + tokio::time::sleep(std::time::Duration::from_secs(interval as u64)).await; + } +} + +/// Write a freshly obtained token set into the CLI credentials file, +/// preserving unrelated fields and using the same snake_case shape. +fn persist_device_token(token: &TokenResponse) -> Result<(), String> { + let path = credentials_path(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let current = std::fs::read_to_string(&path).unwrap_or_default(); + let merged = merge_token_response(¤t, token); + std::fs::write(&path, merged) + .map_err(|e| format!("Failed to write Kimi credentials: {e}")) +} + #[cfg(test)] mod tests { use super::*; @@ -330,4 +540,33 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); assert_eq!(v["refresh_token"], "old-refresh"); } + + #[test] + fn device_authorization_serializes_snake_case() { + let auth = DeviceAuthorization { + user_code: "ABC-DEF".to_string(), + device_code: "dev-1".to_string(), + verification_uri: Some("https://auth.kimi.com/device".to_string()), + verification_uri_complete: None, + expires_in: Some(1800), + interval: Some(5), + }; + let v = serde_json::to_value(&auth).unwrap(); + assert_eq!(v["user_code"], "ABC-DEF"); + assert_eq!(v["device_code"], "dev-1"); + assert_eq!(v["verification_uri_complete"], serde_json::Value::Null); + assert_eq!(v["interval"], 5); + assert!(v.get("userCode").is_none(), "must be snake_case for the frontend"); + } + + #[test] + fn device_poll_status_serializes_snake_case() { + // Internally tagged: {"status":"pending","interval":5} — easy for the + // frontend to switch on. + let pending = serde_json::to_value(DevicePollStatus::Pending { interval: 5 }).unwrap(); + assert_eq!(pending["status"], "pending"); + assert_eq!(pending["interval"], 5); + let success = serde_json::to_value(DevicePollStatus::Success).unwrap(); + assert_eq!(success["status"], "success"); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e3de3b2..10d762c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.6.6", + "version": "0.6.7", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/KimiOAuthDialog.tsx b/src/components/KimiOAuthDialog.tsx new file mode 100644 index 0000000..6351349 --- /dev/null +++ b/src/components/KimiOAuthDialog.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { invoke } from "@tauri-apps/api/core"; +import { useTranslation } from "../i18n"; + +interface DeviceAuthorization { + user_code: string; + device_code: string; + verification_uri: string | null; + verification_uri_complete: string | null; + expires_in: number | null; + interval: number | null; +} + +type PollStatus = + | { status: "pending"; interval: number } + | { status: "slow_down"; interval: number } + | { status: "success" } + | { status: "expired" } + | { status: "access_denied" } + | { status: "timeout" }; + +interface KimiOAuthDialogProps { + open: boolean; + onClose: () => void; +} + +/** Kimi device-code sign-in dialog (in-app `kimi login`). */ +export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) { + const { t } = useTranslation(); + const [auth, setAuth] = useState(null); + const [polling, setPolling] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const activeRef = useRef(false); + const intervalRef = useRef(5); + + // Start a fresh device authorization when the dialog opens. + useEffect(() => { + if (!open) return; + activeRef.current = true; + setAuth(null); + setPolling(false); + setDone(false); + setError(null); + setCopied(false); + intervalRef.current = 5; + invoke("kimi_oauth_start") + .then((a) => { + if (!activeRef.current) return; + setAuth(a); + if (a.interval && a.interval > 0) intervalRef.current = a.interval; + setPolling(true); + }) + .catch((e) => { + if (!activeRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + }); + return () => { + activeRef.current = false; + }; + }, [open]); + + // Poll the token endpoint while the user authorizes in the browser. + useEffect(() => { + if (!open || !polling || !auth) return; + let cancelled = false; + + const tick = async () => { + if (cancelled || !activeRef.current) return; + try { + const res = await invoke("kimi_oauth_poll", { + deviceCode: auth.device_code, + interval: intervalRef.current, + }); + if (cancelled) return; + switch (res.status) { + case "pending": + case "slow_down": + if (res.interval > 0) intervalRef.current = res.interval; + break; + case "success": + setPolling(false); + setDone(true); + setTimeout(() => { + if (activeRef.current) onClose(); + }, 1600); + return; + case "expired": + case "access_denied": + case "timeout": + setPolling(false); + setError( + res.status === "expired" + ? t("kimiOAuthExpired") + : res.status === "access_denied" + ? t("kimiOAuthDenied") + : t("kimiOAuthTimeout") + ); + return; + } + } catch { + if (!cancelled) setError(t("kimiOAuthNetworkError")); + } + // Schedule the next poll (also on network errors — keep retrying). + setTimeout(() => { + if (!cancelled && activeRef.current && polling) void tick(); + }, intervalRef.current * 1000); + }; + + void tick(); + return () => { + cancelled = true; + }; + // `polling` and `auth` gate the loop; restart polling explicitly via + // startPolling when retrying. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, polling, auth]); + + const restart = () => { + setAuth(null); + setPolling(false); + setDone(false); + setError(null); + intervalRef.current = 5; + invoke("kimi_oauth_start") + .then((a) => { + if (!activeRef.current) return; + setAuth(a); + if (a.interval && a.interval > 0) intervalRef.current = a.interval; + setPolling(true); + }) + .catch((e) => { + if (!activeRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + }); + }; + + const copyCode = () => { + if (!auth) return; + navigator.clipboard.writeText(auth.user_code).catch(() => {}); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + // Open the authorization page via the Rust-side opener command (bypasses the + // JS plugin scope, same reliable path the provider website links use); + // fall back to a new tab when even that fails. + const openAuthPage = () => { + if (!auth) return; + const url = auth.verification_uri_complete || auth.verification_uri; + if (!url) return; + invoke("open_external_url", { url }).catch(() => { + const w = window.open(url, "_blank"); + if (!w) console.error(`failed to open ${url}`); + }); + }; + + if (!open) return null; + + return createPortal( +
+
+
+

{t("kimiOAuthLogin")}

+ +
+ +
+ {done && ( +
+ ✓ {t("kimiOAuthSuccess")} +
+ )} + + {error && !done && ( +
+ {error} +
+ )} + + {auth && !done && ( + <> + {/* user code + copy */} +
+ + {auth.user_code} + + +
+ + + +

+ {t("kimiOAuthWaiting")} +

+ + )} + + {!auth && !done && ( +
+ )} + +
+ {error && !done && ( + + )} + +
+
+
+
, + document.body + ); +} diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx index fc606d6..a07f18f 100644 --- a/src/components/ProviderEdit.tsx +++ b/src/components/ProviderEdit.tsx @@ -8,6 +8,7 @@ import { getDefaultMaxContextSize } from "../lib/model-defaults"; import { capabilitiesFromRef, getModelRef } from "../lib/models-dev"; import { getIconMetadata } from "../icons/extracted/metadata"; import { AgentSettingsPanel } from "./AgentSettingsPanel"; +import { KimiOAuthDialog } from "./KimiOAuthDialog"; import { ProviderIcon } from "./ProviderIcon"; import { IconPicker } from "./IconPicker"; import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types"; @@ -117,6 +118,7 @@ export function ProviderEdit({ const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic"); const [showApiKey, setShowApiKey] = useState(false); const [showIconPicker, setShowIconPicker] = useState(false); + const [kimiOAuthOpen, setKimiOAuthOpen] = useState(false); // Preset this provider was created from (if any) — provides the // "Get API Key" / referral links shown under the key input. @@ -270,6 +272,17 @@ export function ProviderEdit({ {t("managedProvider")}
+ {provider.managed && ( +
+ +
+ )} @@ -439,6 +452,11 @@ export function ProviderEdit({ , document.body )} + + setKimiOAuthOpen(false)} + /> ); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 6bfdcd0..e7a5443 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { useTranslation, type Language } from "../i18n"; import { useTheme, type Theme } from "../hooks/useTheme"; import type { UpdateInfo } from "../hooks/useUpdateCheck"; @@ -110,10 +109,10 @@ export function SettingsModal({ invoke("open_installer", { path: downloadedPath }).catch(() => {}); }; - // Open an external link in the system browser. Falls back to a new tab - // when the opener plugin call fails, so a broken click never stays silent. + // Open an external link via the Rust-side opener command (bypasses the JS + // plugin scope); fall back to a new tab when even that fails. const openExternal = (url: string) => { - openUrl(url).catch(() => { + invoke("open_external_url", { url }).catch(() => { const w = window.open(url, "_blank"); if (!w) console.error(`failed to open ${url}`); }); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f39f347..84bac24 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -56,6 +56,18 @@ export const enTranslations: Record = { notePlaceholder: "e.g. company account", officialUrl: "Official URL", managedProvider: "Managed provider (no credentials needed)", + kimiOAuthLogin: "Sign in with Kimi", + kimiOAuthOpenPage: "Open authorization page", + kimiOAuthCopyCode: "Copy code", + kimiOAuthCodeCopied: "Copied", + kimiOAuthWaiting: "Enter the code on the page that opens, waiting for authorization…", + kimiOAuthSuccess: "Authorized — credentials written for Kimi login", + kimiOAuthExpired: "Code expired, please start over", + kimiOAuthDenied: "Authorization denied", + kimiOAuthTimeout: "Authorization timed out, please start over", + kimiOAuthRetry: "Restart", + kimiOAuthCancel: "Cancel", + kimiOAuthNetworkError: "Network error, retrying…", apiSettings: "API Settings", apiFormat: "API Format", authField: "Auth Field", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 736947e..b3e9ac1 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -54,6 +54,18 @@ export const zhTranslations = { notePlaceholder: "例如:公司专用账号", officialUrl: "官网链接", managedProvider: "托管供应商(无需凭证)", + kimiOAuthLogin: "Kimi 授权登录", + kimiOAuthOpenPage: "打开授权页", + kimiOAuthCopyCode: "复制授权码", + kimiOAuthCodeCopied: "已复制", + kimiOAuthWaiting: "请在打开的页面中输入授权码,等待授权中…", + kimiOAuthSuccess: "授权成功,已写入 Kimi 登录凭据", + kimiOAuthExpired: "授权码已过期,请重新开始", + kimiOAuthDenied: "授权被拒绝", + kimiOAuthTimeout: "授权超时,请重新开始", + kimiOAuthRetry: "重新开始", + kimiOAuthCancel: "取消", + kimiOAuthNetworkError: "网络错误,正在重试…", apiSettings: "API 设置", apiFormat: "API 格式", authField: "认证字段",