-
Notifications
You must be signed in to change notification settings - Fork 149
feat: 添加 Native Messaging 桥接,支持 CLI 和 AI Agent 控制扩展 #1143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
4bf30c0
cf2b110
914f29d
09fdfae
57f6706
3b2b714
49a0270
62382ab
33f9568
145b2a3
1fcb807
8b3aabe
9bdef03
0a0061f
0b94183
1409306
ca230b7
80304d5
2161d96
7b0749d
797c73d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,307 @@ | ||
| import { onMessage, sendMessage } from "@/messages.ts"; | ||
| import { setupOffscreenDocument } from "./offscreen.ts"; | ||
|
|
||
| const NATIVE_HOST_NAME = "com.ptd.native"; | ||
| const INSTANCE_ID_KEY = "ptd_native_instance_id"; | ||
| const ENABLED_KEY = "ptd_native_bridge_enabled"; | ||
| const RECONNECT_BASE_MS = 1000; | ||
| const RECONNECT_MAX_MS = 30000; | ||
| const MAX_RECONNECT_ATTEMPTS = 10; | ||
|
|
||
| /** Errors that indicate the native host is not installed — no point retrying. */ | ||
| const FATAL_ERRORS = [ | ||
| "Specified native messaging host not found.", | ||
| "Access to the specified native messaging host is forbidden.", | ||
| ]; | ||
|
|
||
| /** Methods the bridge will proxy to sendMessage(). Everything else is rejected. */ | ||
| const ALLOWED_METHODS = new Set([ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CLI 能够使用的 sendMessage methods 有必要进一步细分吗?比如按 site, search, download, userInfo, keepUpload 这种大类?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这现阶段应该问题不大,还没有那么多方法到让人困惑的地步 |
||
| // Storage and logging (read-only) | ||
| "getExtStorage", | ||
| "getLogger", | ||
| // Site config | ||
| "getSiteList", | ||
| "getSiteUserConfig", | ||
| "getSiteFavicon", | ||
| "clearSiteFaviconCache", | ||
| // Search | ||
LeiShi1313 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "getSiteSearchResult", | ||
| "getMediaServerSearchResult", | ||
| // Download and downloader | ||
| "getDownloaderList", | ||
| "getDownloaderConfig", | ||
| "getDownloaderVersion", | ||
| "getDownloaderStatus", | ||
| "getTorrentDownloadLink", | ||
| "getTorrentInfoForVerification", | ||
| "downloadTorrent", | ||
| "getDownloadHistory", | ||
| "getDownloadHistoryById", | ||
| "deleteDownloadHistoryById", | ||
| "clearDownloadHistory", | ||
| // User info | ||
| "getSiteUserInfoResult", | ||
| "cancelUserInfoQueue", | ||
| "getSiteUserInfo", | ||
| "removeSiteUserInfo", | ||
| // Keep-upload | ||
| "getKeepUploadTasks", | ||
| "getKeepUploadTaskById", | ||
| "createKeepUploadTask", | ||
| "updateKeepUploadTask", | ||
| "deleteKeepUploadTask", | ||
| "clearKeepUploadTasks", | ||
| ]); | ||
|
|
||
| // ── Module-scoped state ────────────────────────────────────────────── | ||
| type BridgeState = "no-permission" | "disabled" | "connecting" | "connected" | "retrying" | "error"; | ||
|
|
||
| let port: chrome.runtime.Port | null = null; | ||
| let reconnectTimer: ReturnType<typeof setTimeout> | null = null; | ||
| let reconnectAttempt = 0; | ||
| let permissionGranted = false; | ||
| let enabled = true; | ||
| let state: BridgeState = "no-permission"; | ||
| let lastError: string | undefined; | ||
| let intentionalDisconnect = false; | ||
|
|
||
| // ── Helpers ────────────────────────────────────────────────────────── | ||
|
|
||
| async function getOrCreateInstanceId(): Promise<string> { | ||
| const stored = await chrome.storage.local.get(INSTANCE_ID_KEY); | ||
| const storedInstanceId = stored[INSTANCE_ID_KEY]; | ||
|
|
||
| if (typeof storedInstanceId === "string" && storedInstanceId.length > 0) { | ||
| return storedInstanceId; | ||
| } | ||
| const id = crypto.randomUUID(); | ||
| await chrome.storage.local.set({ [INSTANCE_ID_KEY]: id }); | ||
| return id; | ||
| } | ||
|
|
||
| function getStatus() { | ||
|
||
| return { | ||
| permissionGranted, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 此处的 permissionGranted 建议动态获取,因为下面的 onAdded 以及 onRemoved 在重复授权的情况下不会触发,在极端条件下会导致已授权的在设置页面仍然显示未授权(需要reload插件解决)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed |
||
| enabled, | ||
| state, | ||
| connected: state === "connected", | ||
| lastError, | ||
| }; | ||
| } | ||
|
|
||
| function clearReconnectTimer() { | ||
| if (reconnectTimer !== null) { | ||
| clearTimeout(reconnectTimer); | ||
| reconnectTimer = null; | ||
| } | ||
| } | ||
|
|
||
| // ── Lifecycle ──────────────────────────────────────────────────────── | ||
|
|
||
| function disconnect(intentional: boolean) { | ||
| intentionalDisconnect = intentional; | ||
| clearReconnectTimer(); | ||
| reconnectAttempt = 0; | ||
|
|
||
| if (port) { | ||
| try { | ||
| port.disconnect(); | ||
| } catch { | ||
| // Already disconnected — ignore | ||
| } | ||
| port = null; | ||
| } | ||
| } | ||
|
|
||
| function scheduleReconnect() { | ||
| clearReconnectTimer(); | ||
| reconnectAttempt++; | ||
|
|
||
| if (reconnectAttempt > MAX_RECONNECT_ATTEMPTS) { | ||
| state = "error"; | ||
| lastError = `Gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`; | ||
| console.debug("[PTD] Native bridge exceeded max reconnect attempts, giving up."); | ||
| return; | ||
| } | ||
|
|
||
| const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS); | ||
| state = "retrying"; | ||
| console.debug( | ||
| `[PTD] Native bridge reconnecting in ${delay}ms (attempt ${reconnectAttempt}/${MAX_RECONNECT_ATTEMPTS})...`, | ||
| ); | ||
| reconnectTimer = setTimeout(connect, delay); | ||
| } | ||
|
|
||
| function connect() { | ||
| if (!permissionGranted || !enabled) { | ||
| return; | ||
| } | ||
|
|
||
| clearReconnectTimer(); | ||
| state = "connecting"; | ||
| lastError = undefined; | ||
| intentionalDisconnect = false; | ||
|
|
||
| try { | ||
| port = chrome.runtime.connectNative(NATIVE_HOST_NAME); | ||
| } catch (e: any) { | ||
| state = "error"; | ||
| lastError = e?.message ?? String(e); | ||
| console.debug("[PTD] Native messaging host not available:", lastError); | ||
| return; | ||
| } | ||
|
|
||
| // Send hello handshake — mark connected after successful send. | ||
| // The native host does not send an ack, so a successful postMessage | ||
| // is our best signal. If the host is absent, onDisconnect fires. | ||
| getOrCreateInstanceId().then((instanceId) => { | ||
| if (!port) return; | ||
| port.postMessage({ | ||
| type: "hello", | ||
| instanceId, | ||
| browser: __BROWSER__, | ||
| extensionId: chrome.runtime.id, | ||
| version: __EXT_VERSION__, | ||
| capabilities: ["bridge-v1"], | ||
| }); | ||
| state = "connected"; | ||
| reconnectAttempt = 0; | ||
| }); | ||
LeiShi1313 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| port.onMessage.addListener(async (msg: any) => { | ||
| if (msg?.type !== "request" || !msg.id || !msg.method) { | ||
| return; | ||
| } | ||
|
|
||
| const { id, method, params } = msg; | ||
|
|
||
| if (!ALLOWED_METHODS.has(method)) { | ||
| port!.postMessage({ | ||
| type: "response", | ||
| id, | ||
| error: { code: "METHOD_NOT_ALLOWED", message: `Method '${method}' is not allowed` }, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| await setupOffscreenDocument(); | ||
| const result = await sendMessage(method as any, params); | ||
| port?.postMessage({ type: "response", id, result }); | ||
| } catch (e: any) { | ||
| port?.postMessage({ | ||
| type: "response", | ||
| id, | ||
| error: { code: "EXTENSION_ERROR", message: e?.message ?? String(e) }, | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| port.onDisconnect.addListener(() => { | ||
| const err = chrome.runtime.lastError; | ||
| const errMsg = err?.message ?? ""; | ||
|
|
||
| port = null; | ||
|
|
||
| if (intentionalDisconnect) { | ||
LeiShi1313 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| // Intentional disconnect — don't retry | ||
| return; | ||
| } | ||
|
|
||
| if (err) { | ||
| console.debug("[PTD] Native messaging disconnected:", errMsg); | ||
| } | ||
|
|
||
| // Fatal errors — no retry | ||
| if (FATAL_ERRORS.some((e) => errMsg.includes(e))) { | ||
| state = "error"; | ||
| lastError = errMsg; | ||
| console.debug("[PTD] Native host not available, CLI bridge disabled."); | ||
| return; | ||
| } | ||
|
|
||
| // Recoverable (e.g. host exited) — retry with backoff | ||
| lastError = errMsg || "Connection lost"; | ||
| scheduleReconnect(); | ||
| }); | ||
| } | ||
|
|
||
| async function init() { | ||
| // Refresh permission state | ||
| try { | ||
| permissionGranted = await chrome.permissions.contains({ permissions: ["nativeMessaging"] }); | ||
| } catch { | ||
| permissionGranted = false; | ||
| } | ||
|
|
||
| // Refresh enabled flag | ||
| const stored = await chrome.storage.local.get(ENABLED_KEY); | ||
| enabled = stored[ENABLED_KEY] !== false; // default true | ||
|
|
||
| // Reconcile desired state | ||
| if (!permissionGranted) { | ||
| disconnect(true); | ||
| state = "no-permission"; | ||
| lastError = undefined; | ||
| return; | ||
| } | ||
|
|
||
| if (!enabled) { | ||
| disconnect(true); | ||
| state = "disabled"; | ||
| lastError = undefined; | ||
| return; | ||
| } | ||
|
|
||
| // Permission granted and enabled — connect | ||
| connect(); | ||
| } | ||
|
|
||
| // ── Runtime permission listeners ───────────────────────────────────── | ||
|
|
||
| chrome.permissions.onAdded?.addListener((permissions) => { | ||
| if (permissions.permissions?.includes("nativeMessaging")) { | ||
| init(); | ||
| } | ||
| }); | ||
|
|
||
| chrome.permissions.onRemoved?.addListener((permissions) => { | ||
| if (permissions.permissions?.includes("nativeMessaging")) { | ||
| init(); | ||
| } | ||
| }); | ||
Rhilip marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // ── Message handlers ───────────────────────────────────────────────── | ||
|
|
||
| onMessage("nativeBridgeGetStatus", async () => { | ||
| return getStatus(); | ||
| }); | ||
|
|
||
| onMessage("nativeBridgeSetEnabled", async ({ data }) => { | ||
LeiShi1313 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| await chrome.storage.local.set({ [ENABLED_KEY]: data }); | ||
| await init(); | ||
| // Brief wait for the async hello handshake to complete | ||
| if (state === "connecting") { | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| return getStatus(); | ||
| }); | ||
|
|
||
| onMessage("nativeBridgeReconnect", async () => { | ||
| if (!permissionGranted) { | ||
| lastError = "Permission not granted — cannot reconnect"; | ||
| return getStatus(); | ||
| } | ||
| if (!enabled) { | ||
| lastError = "Bridge is disabled — cannot reconnect"; | ||
| return getStatus(); | ||
| } | ||
|
|
||
| disconnect(true); | ||
| connect(); | ||
| return getStatus(); | ||
| }); | ||
|
|
||
| // ── Startup ────────────────────────────────────────────────────────── | ||
|
|
||
| init(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,6 +145,33 @@ interface ProtocolMap extends TMessageMap { | |
| updateKeepUploadTask(task: IKeepUploadTask): void; | ||
| deleteKeepUploadTask(taskId: TKeepUploadTaskKey): void; | ||
| clearKeepUploadTasks(): void; | ||
|
|
||
| // 2.8 Lightweight list queries (for CLI discovery) | ||
| getSiteList(): Array<{ id: string; name: string; url: string; offline: boolean }>; | ||
| getDownloaderList(): Array<{ id: string; name: string; type: string; enabled: boolean; address: string }>; | ||
|
|
||
| // 2.9 Native messaging bridge control | ||
| nativeBridgeGetStatus(): { | ||
|
||
| permissionGranted: boolean; | ||
| enabled: boolean; | ||
| state: "no-permission" | "disabled" | "connecting" | "connected" | "retrying" | "error"; | ||
| connected: boolean; | ||
| lastError?: string; | ||
| }; | ||
| nativeBridgeSetEnabled(data: boolean): { | ||
| permissionGranted: boolean; | ||
| enabled: boolean; | ||
| state: "no-permission" | "disabled" | "connecting" | "connected" | "retrying" | "error"; | ||
| connected: boolean; | ||
| lastError?: string; | ||
| }; | ||
| nativeBridgeReconnect(): { | ||
| permissionGranted: boolean; | ||
| enabled: boolean; | ||
| state: "no-permission" | "disabled" | "connecting" | "connected" | "retrying" | "error"; | ||
| connected: boolean; | ||
| lastError?: string; | ||
| }; | ||
| } | ||
|
|
||
| // 全局消息处理函数映射 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.