mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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>
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
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 },
|
||
})
|
||
},
|
||
}))
|