cc-haha/desktop/src/stores/adapterStore.ts
程序员阿江(Relakkes) 3e3c2bc116 experiment(desktop): auto-spawn adapter sidecar on launch + restart on save
Wires the new claude-sidecar adapters mode into the actual desktop UX:

* On app launch, Tauri main process now spawns the adapter sidecar right
  after the server sidecar comes up. The sidecar reads ~/.claude/adapters.json
  and connects whichever of Feishu / Telegram has credentials configured;
  if neither does, it warns + skips + exits cleanly (treated as expected).
* When the user saves credentials in the existing AdapterSettings page,
  the frontend store invokes restart_adapters_sidecar after the PUT
  /api/adapters succeeds. Tauri kills the old child and spawns a new one,
  which picks up the fresh config and establishes the WebSocket connection
  to Feishu / Telegram immediately — no app restart needed.

End-to-end behavior is now: install app → open → configure credentials in
settings → click save → IM bot is live.

Implementation
==============

* desktop/src-tauri/capabilities/default.json: replace the stale
  binaries/claude-server allowlist with binaries/claude-sidecar across
  shell:allow-execute, shell:allow-spawn, plus a new shell:allow-kill
  entry needed for the restart path. (P2 changed externalBin to
  claude-sidecar but missed updating capabilities, which is why the prior
  bundle worked at all in dev mode but would have failed in production.)

* desktop/src-tauri/src/lib.rs:
  - new AdapterState that holds an Option<CommandChild>
  - start_adapters_sidecar() spawns `claude-sidecar adapters --feishu --telegram`
    with ADAPTER_SERVER_URL env var pointing at the dynamic server port
    (converted http://→ws:// since WsBridge does `new WebSocket(url)`
    directly without protocol translation)
  - spawn_and_track_adapters_sidecar() handles spawn + state insertion
  - stop_adapters_sidecar() kills + clears state
  - new #[tauri::command] restart_adapters_sidecar that calls stop+spawn
  - sidecar Terminated events are info-logged, not treated as errors,
    so the credential-missing path doesn't show up as a crash
  - setup() spawns the adapter sidecar after server startup completes
  - RunEvent::Exit cleanup also kills adapter sidecar

* desktop/src/stores/adapterStore.ts: after every successful PUT
  /api/adapters, dynamic-import @tauri-apps/api/core and call
  invoke('restart_adapters_sidecar'). Wrapped in try/catch so non-Tauri
  test environments fall through quietly. Triggers on every config
  change (including pairing code generation, paired-user removal) by
  design — keeps the rule simple and guarantees any save takes effect.

* desktop/src/pages/AdapterSettings.tsx: removed the stale "Server URL"
  text input. The field defaulted to ws://127.0.0.1:3456 but the actual
  server uses a dynamic port chosen at startup. Even when filled in
  correctly, loadConfig() in adapters/common/config.ts gives env var
  priority over file value, so this UI control had zero effect inside
  the desktop app. Standalone-mode adapter users can still edit the
  field directly in adapters.json if they need to.

Bundle size: unchanged at 88 MB .app / 37 MB DMG. The Rust changes
add only a few KB to the desktop main binary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:55:44 +08:00

107 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { create } from 'zustand'
import { adaptersApi } from '../api/adapters'
import type { AdapterFileConfig } from '../types/adapter'
/**
* Tauri command 触发器:让主进程 kill + respawn adapter sidecar
* 让 ~/.claude/adapters.json 里的最新凭据被新进程读到,建立飞书 / Telegram
* 的 WebSocket 连接。
*
* 在非 Tauri 环境(纯浏览器调试 / 单元测试)这会安静失败 —— 那种场景下
* 本来也没有 sidecar 可重启。
*/
async function notifyTauriRestartAdapters(): Promise<void> {
try {
// 用 dynamic import 避开 SSR / non-tauri 测试环境的硬依赖
const { invoke } = await import('@tauri-apps/api/core')
await invoke('restart_adapters_sidecar')
} catch (err) {
// 不阻塞保存流程 —— 配置文件已经写入,下次启动 App 也会生效
if (typeof console !== 'undefined') {
console.warn('[adapterStore] restart_adapters_sidecar failed:', err)
}
}
}
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
const CODE_LENGTH = 6
const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes
function generateCode(): string {
const maxValid = Math.floor(256 / SAFE_ALPHABET.length) * SAFE_ALPHABET.length
let code = ''
while (code.length < CODE_LENGTH) {
const array = new Uint8Array(1)
crypto.getRandomValues(array)
if (array[0]! < maxValid) {
code += SAFE_ALPHABET[array[0]! % SAFE_ALPHABET.length]
}
}
return code
}
type AdapterStore = {
config: AdapterFileConfig
isLoading: boolean
error: string | null
fetchConfig: () => Promise<void>
updateConfig: (patch: Partial<AdapterFileConfig>) => Promise<void>
generatePairingCode: () => Promise<string>
removePairedUser: (platform: 'telegram' | 'feishu', userId: string | number) => Promise<void>
}
export const useAdapterStore = create<AdapterStore>((set, get) => ({
config: {},
isLoading: false,
error: null,
fetchConfig: async () => {
set({ isLoading: true, error: null })
try {
const config = await adaptersApi.getConfig()
set({ config, isLoading: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load config'
set({ isLoading: false, error: message })
}
},
updateConfig: async (patch) => {
const config = await adaptersApi.updateConfig(patch)
set({ config })
// 配置文件已写入磁盘,让 Tauri 主进程 kill + respawn adapter sidecar
// 触发飞书 / Telegram WebSocket 用新凭据重连。pairing code / paired users
// 这种轻量更新也会触发重启 —— 这是个有意为之的简化:保证"任何配置变更
// 都立刻生效",比起精细判断哪些字段值得重启更可靠。
void notifyTauriRestartAdapters()
},
generatePairingCode: async () => {
const code = generateCode()
const now = Date.now()
await get().updateConfig({
pairing: {
code,
expiresAt: now + CODE_TTL_MS,
createdAt: now,
},
})
return code
},
removePairedUser: async (platform, userId) => {
const { config } = get()
const platformConfig = config[platform]
if (!platformConfig) return
const pairedUsers = (platformConfig.pairedUsers ?? []).filter(
(u) => String(u.userId) !== String(userId),
)
await get().updateConfig({
[platform]: { ...platformConfig, pairedUsers },
})
},
}))