diff --git a/package.json b/package.json index 735335ea7..c61011464 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pt-depiler", "private": true, - "version": "0.0.5", + "version": "0.0.6", "type": "module", "homepage": "https://github.com/pt-plugins/PT-depiler", "packageManager": "pnpm@10.21.0", diff --git a/privacy-statement.md b/privacy-statement.md index 0fb243bce..cdec286d1 100644 --- a/privacy-statement.md +++ b/privacy-statement.md @@ -17,12 +17,18 @@ - 使用 `https` 的方式配置服务器地址; - 启用备份数据加密(采用 [AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard))功能后再进行备份操作; -## 四、与第三人共用个人信息之政策 +## 四、原生通信桥(Native Messaging) +- 当您在设置页面中授予 `nativeMessaging` 权限并启用原生通信桥功能后,助手将通过 Chrome Native Messaging 机制与本地安装的 CLI 工具(ptd-cli)进行通信; +- 通过此通道可访问的信息包括:站点配置、搜索结果、下载历史、用户信息、下载器状态等; +- 所有通信均在本地进行(浏览器与本机进程之间),不会将数据发送至任何远程服务器; +- 此功能为可选功能,需要用户主动授权并启用,您可以随时在设置页面中撤销权限或禁用该功能; + +## 五、与第三人共用个人信息之政策 - 助手不会收集任何相关的个人信息,所以助手绝不会提供、交换、出租或出售任何您的个人信息给其他个人、团体、私人企业或公务机关; -## 五、Cookie之使用 +## 六、Cookie之使用 - 当您进行搜索操作时,助手会访问您已配置的站点,Cookie 由浏览器提供,助手无权访问也不会对内容进行探测和修改,一切内容由浏览器和站点进行相互验证; - 当您授权助手访问 Cookie 信息时,这些信息仅用于备份和恢复操作,助手不会在除此之外的任何其他操作中使用它们; -## 六、隐私权保护政策之修正 +## 七、隐私权保护政策之修正 - 助手隐私权保护政策将按用户需求变更而随时进行修正,修正后的条款将在本页面显示。 diff --git a/src/entries/background/main.ts b/src/entries/background/main.ts index 71a89e898..58b1b6bbe 100644 --- a/src/entries/background/main.ts +++ b/src/entries/background/main.ts @@ -8,6 +8,7 @@ import "./utils/contextMenus.ts"; import "./utils/omnibox.ts"; import "./utils/alarms.ts"; import "./utils/webRequest.ts"; +import "./utils/nativeMessaging.ts"; // 监听 点击图标 事件 chrome.action.onClicked.addListener(async () => { diff --git a/src/entries/background/utils/nativeMessaging.ts b/src/entries/background/utils/nativeMessaging.ts new file mode 100644 index 000000000..c8ce3a521 --- /dev/null +++ b/src/entries/background/utils/nativeMessaging.ts @@ -0,0 +1,335 @@ +import { onMessage, sendMessage } from "@/messages.ts"; +import { setupOffscreenDocument } from "./offscreen.ts"; +import type { BridgeState, BridgeStatus } from "@/shared/types.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([ + // Site config + "getSiteList", + "getSiteUserConfig", + "getSiteFavicon", + "clearSiteFaviconCache", + // Search + "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 ────────────────────────────────────────────── + +let port: chrome.runtime.Port | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempt = 0; +let enabled = true; +let state: BridgeState = "no-permission"; +let lastError: string | undefined; +let intentionalDisconnect = false; + +// ── Helpers ────────────────────────────────────────────────────────── + +async function getOrCreateInstanceId(): Promise { + 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; +} + +async function checkPermission(): Promise { + try { + return await chrome.permissions.contains({ permissions: ["nativeMessaging"] }); + } catch { + return false; + } +} + +async function getStatus(): Promise { + const permissionGranted = await checkPermission(); + return { + permissionGranted, + enabled, + state, + connected: port !== null && 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 (!enabled) { + return; + } + + clearReconnectTimer(); + state = "connecting"; + lastError = undefined; + intentionalDisconnect = false; + + let currentPort: chrome.runtime.Port; + try { + currentPort = chrome.runtime.connectNative(NATIVE_HOST_NAME); + port = currentPort; + } 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 !== currentPort) return; + + try { + currentPort.postMessage({ + type: "hello", + instanceId, + browser: __BROWSER__, + extensionId: chrome.runtime.id, + version: __EXT_VERSION__, + capabilities: ["bridge-v1"], + }); + } catch (e: any) { + state = "error"; + lastError = e?.message ?? String(e); + console.debug("[PTD] Failed to send native hello message:", lastError); + return; + } + + state = "connected"; + reconnectAttempt = 0; + }) + .catch((e: any) => { + if (port !== currentPort) return; + state = "error"; + lastError = e?.message ?? String(e); + console.debug("[PTD] Failed to get or create native instance id:", lastError); + try { + currentPort.disconnect(); + } catch { + // ignore + } + }); + + currentPort.onMessage.addListener(async (msg: any) => { + if (msg?.type !== "request" || !msg.id || !msg.method) { + return; + } + + const { id, method, params } = msg; + + if (!ALLOWED_METHODS.has(method)) { + currentPort.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); + if (port === currentPort) { + currentPort.postMessage({ type: "response", id, result }); + } + } catch (e: any) { + if (port === currentPort) { + currentPort.postMessage({ + type: "response", + id, + error: { code: "EXTENSION_ERROR", message: e?.message ?? String(e) }, + }); + } + } + }); + + currentPort.onDisconnect.addListener(() => { + const err = chrome.runtime.lastError; + const errMsg = err?.message ?? ""; + + // Stale port — a new connection has already replaced this one + if (port !== currentPort) return; + + port = null; + + if (intentionalDisconnect) { + return; + } + + if (err) { + console.debug("[PTD] Native messaging disconnected:", errMsg); + } + + if (FATAL_ERRORS.some((e) => errMsg.includes(e))) { + state = "error"; + lastError = errMsg; + console.debug("[PTD] Native host not available, CLI bridge disabled."); + return; + } + + lastError = errMsg || "Connection lost"; + scheduleReconnect(); + }); +} + +async function init() { + const permissionGranted = await checkPermission(); + + // Refresh enabled flag + const stored = await chrome.storage.local.get(ENABLED_KEY); + enabled = stored[ENABLED_KEY] !== false; // default true + + if (!permissionGranted) { + disconnect(true); + state = "no-permission"; + lastError = undefined; + return; + } + + if (!enabled) { + disconnect(true); + state = "disabled"; + lastError = undefined; + return; + } + + 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(); + } +}); + +// ── Message handlers ───────────────────────────────────────────────── + +onMessage("nativeBridgeGetStatus", async () => { + return getStatus(); +}); + +onMessage("nativeBridgeSetEnabled", async ({ data }) => { + if (typeof data !== "boolean") { + return getStatus(); + } + await chrome.storage.local.set({ [ENABLED_KEY]: data }); + await init(); + if (state === "connecting") { + await new Promise((r) => setTimeout(r, 200)); + } + return getStatus(); +}); + +onMessage("nativeBridgeReconnect", async () => { + const permissionGranted = await checkPermission(); + 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(); diff --git a/src/entries/messages.ts b/src/entries/messages.ts index 72a2600f9..cef7523b5 100644 --- a/src/entries/messages.ts +++ b/src/entries/messages.ts @@ -40,6 +40,7 @@ import { AugmentedRequired, IKeepUploadTask, TKeepUploadTaskKey, + BridgeStatus, } from "@/shared/types.ts"; import { isDebug } from "~/helper.ts"; @@ -145,6 +146,15 @@ 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(): BridgeStatus; + nativeBridgeSetEnabled(data: boolean): BridgeStatus; + nativeBridgeReconnect(): BridgeStatus; } // 全局消息处理函数映射 diff --git a/src/entries/offscreen/utils/download.ts b/src/entries/offscreen/utils/download.ts index dec7d7b77..8b3f7863c 100644 --- a/src/entries/offscreen/utils/download.ts +++ b/src/entries/offscreen/utils/download.ts @@ -41,6 +41,18 @@ export async function getDownloaderConfig(downloaderId: string) { onMessage("getDownloaderConfig", async ({ data: downloaderId }) => await getDownloaderConfig(downloaderId)); +onMessage("getDownloaderList", async () => { + const metadata = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema; + const downloaders = metadata?.downloaders ?? {}; + return Object.entries(downloaders).map(([id, config]) => ({ + id, + name: config.name ?? "", + type: config.type ?? "", + enabled: config.enabled ?? false, + address: config.address ?? "", + })); +}); + onMessage("getDownloaderVersion", async ({ data: downloaderId }) => { let downloaderVersion = "unknown"; diff --git a/src/entries/offscreen/utils/search.ts b/src/entries/offscreen/utils/search.ts index d5fa3fcb3..0e3150bc8 100644 --- a/src/entries/offscreen/utils/search.ts +++ b/src/entries/offscreen/utils/search.ts @@ -25,7 +25,7 @@ onMessage("getSiteSearchResult", async ({ data: { siteId, keyword = "", searchEn if (searchResult.data.length > 0) { let autoDetectOfficialGroupFromTitlePattern: TPatterns | undefined; - if (configStorage.searchEntity.autoDetectOfficialGroupFromTitle && site.metadata.officialGroupPattern?.length) { + if (configStorage?.searchEntity?.autoDetectOfficialGroupFromTitle && site.metadata.officialGroupPattern?.length) { autoDetectOfficialGroupFromTitlePattern = site.metadata.officialGroupPattern; } diff --git a/src/entries/offscreen/utils/site.ts b/src/entries/offscreen/utils/site.ts index 93df02151..ee1582bf4 100644 --- a/src/entries/offscreen/utils/site.ts +++ b/src/entries/offscreen/utils/site.ts @@ -53,6 +53,24 @@ export async function getSiteUserConfig(siteId: TSiteID, flush = false) { onMessage("getSiteUserConfig", async ({ data: { siteId, flush } }) => await getSiteUserConfig(siteId, flush)); +onMessage("getSiteList", async () => { + const metadata = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema; + const sites = metadata?.sites ?? {}; + const nameMap = metadata?.siteNameMap ?? {}; + return Promise.all( + Object.entries(sites).map(async ([id, config]) => { + const siteMetaData = await getDefinedSiteMetadata(id as TSiteID); + const isDead = siteMetaData.isDead ?? false; + return { + id, + name: nameMap[id] ?? config.merge?.name ?? id, + url: config.url ?? "", + offline: (isDead || config.isOffline) ?? false, + }; + }), + ); +}); + export async function getSiteInstance( siteId: TSiteID, options: { mergeUserConfig?: boolean } = {}, diff --git a/src/entries/options/plugins/router.ts b/src/entries/options/plugins/router.ts index d02e0d084..bef5b13ce 100644 --- a/src/entries/options/plugins/router.ts +++ b/src/entries/options/plugins/router.ts @@ -26,6 +26,12 @@ export const setBaseChildren: RouteRecordRaw[] = [ meta: { icon: "mdi-account" }, component: () => import("../views/Settings/SetBase/UserInfoWindow.vue"), }, + { + path: "native-bridge", + name: "SetBaseNativeBridge", + meta: { icon: "mdi-connection", usesGlobalSave: false }, + component: () => import("../views/Settings/SetBase/NativeBridgeWindow.vue"), + }, { path: "backup", name: "SetBaseBackup", @@ -88,6 +94,7 @@ export const routes: RouteRecordRaw[] = [ { path: "/settings", name: "Settings", + redirect: "/set-base", meta: { isMainMenu: true }, children: [ { diff --git a/src/entries/options/views/Settings/SetBase/Index.vue b/src/entries/options/views/Settings/SetBase/Index.vue index 1c2f782f7..20bb3c1ad 100644 --- a/src/entries/options/views/Settings/SetBase/Index.vue +++ b/src/entries/options/views/Settings/SetBase/Index.vue @@ -30,6 +30,10 @@ const activeTab = computed({ }, }); +const showSaveButton = computed(() => { + return route.meta?.usesGlobalSave !== false; +}); + async function save() { await setTabRef.value?.beforeSave?.(); // 如果对应的 tab 有 afterSave 方法,则调用 await configStore.$save(); @@ -53,8 +57,8 @@ async function save() { - - + + {{ t("common.save") }} diff --git a/src/entries/options/views/Settings/SetBase/NativeBridgeWindow.vue b/src/entries/options/views/Settings/SetBase/NativeBridgeWindow.vue new file mode 100644 index 000000000..23667f23c --- /dev/null +++ b/src/entries/options/views/Settings/SetBase/NativeBridgeWindow.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/src/entries/shared/types.ts b/src/entries/shared/types.ts index af6d15016..5f5e43d8f 100644 --- a/src/entries/shared/types.ts +++ b/src/entries/shared/types.ts @@ -8,6 +8,7 @@ import type { TBackupFields } from "./types/storages/metadata.ts"; export * from "./types/extends.ts"; export * from "./types/common/download.ts"; export * from "./types/common/ptpp.ts"; +export * from "./types/common/nativeBridge.ts"; export * from "./types/storages/config.ts"; export * from "./types/storages/indexdb.ts"; export * from "./types/storages/metadata.ts"; diff --git a/src/entries/shared/types/common/nativeBridge.ts b/src/entries/shared/types/common/nativeBridge.ts new file mode 100644 index 000000000..691527e0b --- /dev/null +++ b/src/entries/shared/types/common/nativeBridge.ts @@ -0,0 +1,9 @@ +export type BridgeState = "no-permission" | "disabled" | "connecting" | "connected" | "retrying" | "error"; + +export interface BridgeStatus { + permissionGranted: boolean; + enabled: boolean; + state: BridgeState; + connected: boolean; + lastError?: string; +} diff --git a/src/locales/en.json b/src/locales/en.json index 6c703ae86..6c07b0942 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -587,7 +587,8 @@ "download": "Download", "user-info": "UserInfo", "backup": "Backup & Restore", - "social-information": "Social Rating" + "social-information": "Social Rating", + "native-bridge": "Native Bridge" }, "ui": { "changeLanguage": "Switch Language", @@ -672,6 +673,38 @@ "userInfoDisplay": "User Info Display" } }, + "SetNativeBridge": { + "permission": { + "title": "Permission", + "granted": "Permission Granted", + "notGranted": "Permission Not Granted", + "grant": "Grant Permission", + "revoke": "Revoke Permission", + "grantFailed": "Permission request was denied" + }, + "bridge": { + "title": "Bridge Control", + "enabled": "Enable Native Bridge", + "testConnection": "Test Connection", + "status": { + "no-permission": "No Permission", + "disabled": "Disabled", + "connecting": "Connecting...", + "connected": "Connected", + "retrying": "Retrying...", + "error": "Error" + } + }, + "info": { + "title": "About Native Bridge", + "description": "The native bridge allows the PT-Depiler CLI tool to communicate with this extension. It requires the CLI tool to be installed separately on your computer.", + "cliRequired": "The external CLI tool must be installed for this feature to work. Native bridge is not available on mobile platforms. See {0} for installation instructions.", + "cliLink": "ptd-cli", + "setupCommand": "Run the following command to register this extension with the CLI tool:", + "setupHint": "After running the command, restart the browser or click Test Connection.", + "privacy": "When enabled, the native bridge allows the locally installed CLI tool to access extension data including site configuration, search results, download history, user info, and downloader status. All communication is local (between browser and local process) — no data is sent to remote servers. You can disable this feature or revoke the permission at any time." + } + }, "SetDownloader": { "add": { "NoneSelectNotice": "Please select a server type", diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index 61013a5de..8ab8619a1 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -587,7 +587,8 @@ "download": "下载", "user-info": "用户信息", "backup": "备份恢复", - "social-information": "媒体评分" + "social-information": "媒体评分", + "native-bridge": "原生通信桥" }, "ui": { "changeLanguage": "切换语言", @@ -672,6 +673,38 @@ "userInfoDisplay": "用户信息展示" } }, + "SetNativeBridge": { + "permission": { + "title": "权限", + "granted": "已授权", + "notGranted": "未授权", + "grant": "授予权限", + "revoke": "撤销权限", + "grantFailed": "权限请求被拒绝" + }, + "bridge": { + "title": "通信桥控制", + "enabled": "启用原生通信桥", + "testConnection": "测试连接", + "status": { + "no-permission": "未授权", + "disabled": "已禁用", + "connecting": "连接中...", + "connected": "已连接", + "retrying": "重试中...", + "error": "错误" + } + }, + "info": { + "title": "关于原生通信桥", + "description": "原生通信桥允许 PT-Depiler CLI 工具与本扩展进行通信。需要在电脑上单独安装 CLI 工具。", + "cliRequired": "此功能需要安装外部 CLI 工具。移动平台上不可用。安装说明请参考 {0}。", + "cliLink": "ptd-cli", + "setupCommand": "运行以下命令将此扩展注册到 CLI 工具:", + "setupHint": "运行命令后,重启浏览器或点击测试连接。", + "privacy": "启用后,原生通信桥允许本地安装的 CLI 工具访问扩展数据,包括站点配置、搜索结果、下载历史、用户信息和下载器状态。所有通信均在本地进行(浏览器与本机进程之间),不会将数据发送至任何远程服务器。您可以随时禁用此功能或撤销权限。" + } + }, "SetDownloader": { "add": { "NoneSelectNotice": "请选择一个服务器类型", diff --git a/vite.config.ts b/vite.config.ts index 17455c50b..b4e9dac7c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,8 @@ const permissions = [ "notifications", ]; +const optionalPermissions = ["nativeMessaging"]; + // @ts-ignore const git_count = git.count("HEAD"); const base_version = `${pkg.version}.${git_count}`; @@ -119,7 +121,9 @@ export default defineConfig({ // 在 Chrome 中需要多注册一个 offscreen 权限 "{{chrome}}.permissions": [...permissions, "offscreen"], + "{{chrome}}.optional_permissions": optionalPermissions, "{{firefox}}.permissions": permissions, + "{{firefox}}.optional_permissions": optionalPermissions, host_permissions: ["*://*/*"], "{{firefox}}.browser_specific_settings": {