Skip to content

Commit 563e3ae

Browse files
committed
feat: 实现浏览器扩展与桌面端的自动配对功能
1 parent 07a3533 commit 563e3ae

8 files changed

Lines changed: 197 additions & 15 deletions

File tree

app/services/browser_service.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@
44
from typing import TYPE_CHECKING, Any, Self
55
from secrets import token_urlsafe
66

7-
from PySide6.QtCore import QObject, QTimer, Slot
7+
from PySide6.QtCore import QObject, QTimer, Slot, Qt
88
from PySide6.QtNetwork import QHostAddress
99
from PySide6.QtWebSockets import QWebSocketServer
1010
from loguru import logger
1111
from orjson import dumps, loads
12+
from qfluentwidgets import MessageBox
1213

1314
from app.bases.models import Task, TaskStatus
1415
from app.services.core_service import coreService
1516
from app.services.feature_service import featureService
1617
from app.supports.config import VERSION, cfg
1718
from app.supports.recorder import taskRecorder
18-
from app.supports.utils import getProxies, openFile, openFolder
19+
from app.supports.utils import bringWindowToTop, getProxies, openFile, openFolder
1920

2021
if TYPE_CHECKING:
2122
from PySide6.QtWebSockets import QWebSocket
@@ -35,6 +36,8 @@ class BrowserMessageType(StrEnum):
3536
ERROR = "error"
3637
HELLO = "hello"
3738
HELLO_ACK = "hello_ack"
39+
PAIR_REQUEST = "pair_request"
40+
PAIR_RESULT = "pair_result"
3841
SUBSCRIBE_TASKS = "subscribe_tasks"
3942
TASK_SNAPSHOT = "task_snapshot"
4043
CREATE_TASK = "create_task"
@@ -204,6 +207,41 @@ def _sendError(
204207
payload["requestId"] = requestId
205208
self._send(session, payload)
206209

210+
def _showPairRequestDialog(self, session: _BrowserClientSession, data: dict[str, Any]) -> bool:
211+
extensionVersion = self._stringField(data, "extensionVersion", self.tr("未知"))
212+
clientKind = self._stringField(data, "clientKind", self.tr("浏览器扩展"))
213+
peerAddress = f"{session.socket.peerAddress().toString()}:{session.socket.peerPort()}"
214+
content = self.tr(
215+
"浏览器扩展正在请求连接到 Ghost Downloader。\n\n"
216+
"来源: {0}\n"
217+
"客户端: {1}\n"
218+
"扩展版本: {2}\n\n"
219+
"仅在你刚刚点击扩展里的“自动配对”时允许。"
220+
).format(peerAddress, clientKind, extensionVersion)
221+
222+
bringWindowToTop(self.mainWindow)
223+
dialog = MessageBox(self.tr("浏览器扩展配对请求"), content, self.mainWindow)
224+
dialog.yesButton.setText(self.tr("允许配对"))
225+
dialog.cancelButton.setText(self.tr("拒绝"))
226+
dialog.contentLabel.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
227+
return bool(dialog.exec())
228+
229+
def _handlePairRequest(self, session: _BrowserClientSession, data: dict[str, Any]) -> None:
230+
requestId = self._stringField(data, "requestId")
231+
232+
if int(data.get("protocolVersion") or 0) != self.PROTOCOL_VERSION:
233+
result = {"ok": False, "message": self.tr("协议版本不匹配")}
234+
elif not self._showPairRequestDialog(session, data):
235+
result = {"ok": False, "message": self.tr("已拒绝配对请求")}
236+
else:
237+
result = {"ok": True, "message": self.tr("配对成功"), "token": self.pairToken}
238+
239+
self._send(session, {
240+
"type": BrowserMessageType.PAIR_RESULT,
241+
"requestId": requestId,
242+
**result,
243+
})
244+
207245
@staticmethod
208246
def _stringField(data: dict[str, Any], key: str, default: str = "") -> str:
209247
value = data.get(key)
@@ -691,6 +729,10 @@ def _onReceiveMessage(self, message: str):
691729
self._sendError(session, self.tr("未知的消息类型"))
692730
return
693731

732+
if messageType == BrowserMessageType.PAIR_REQUEST:
733+
self._handlePairRequest(session, data)
734+
return
735+
694736
if messageType == BrowserMessageType.HELLO:
695737
requestId = self._stringField(data, "requestId") or None
696738
if int(data.get("protocolVersion") or 0) != self.PROTOCOL_VERSION:

app/supports/utils.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,13 +285,13 @@ def getLocalTimeFromGithubApiTime(gmtTimeStr: str) -> str:
285285
return localTime.strftime("%Y-%m-%d %H:%M:%S")
286286

287287

288-
def bringWindowToTop(window):
288+
def bringWindowToTop(window) -> None:
289289
window.show()
290-
if window.isMinimized():
291-
window.showNormal()
292-
# 激活窗口,使其显示在最前面
293-
window.activateWindow()
290+
window.setWindowState(
291+
(window.windowState() & ~Qt.WindowState.WindowMinimized) | Qt.WindowState.WindowActive
292+
)
294293
window.raise_()
294+
window.activateWindow()
295295

296296

297297
def showMessageBox(

browser_extension/app/src/background.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
302302
})());
303303
}
304304

305+
if (message.type === "popup_request_pairing") {
306+
void desktopBridge.requestPairing().catch(() => {
307+
// The bridge snapshot carries the user-facing pairing failure message.
308+
});
309+
sendResponse({ ok: true, message: "配对请求已发送,请在桌面端确认" });
310+
return true;
311+
}
312+
305313
if (message.type === "popup_set_intercept_downloads") {
306314
return reply(sendResponse, (async () => {
307315
interceptDownloads = Boolean(message.enabled);

browser_extension/app/src/background/desktop-bridge.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ type PendingRequest = {
1818
timeoutId: number;
1919
};
2020

21+
type PairingResponse = {
22+
type?: string;
23+
ok?: boolean;
24+
token?: string;
25+
message?: string;
26+
};
27+
28+
const PAIRING_TIMEOUT_MS = 60000;
29+
const MISSING_PAIRING_MESSAGE = "请在扩展设置页自动配对桌面端";
30+
2131
export type DesktopBridgeSnapshot = {
2232
connectionState: DesktopConnectionState;
2333
connectionMessage: string;
@@ -32,7 +42,7 @@ export function createDesktopBridge() {
3242
let reconnectTimer: number | null = null;
3343

3444
let connectionState: DesktopConnectionState = "missing_token";
35-
let connectionMessage = "请先在扩展设置里填写配对令牌";
45+
let connectionMessage = MISSING_PAIRING_MESSAGE;
3646
let desktopVersion = "";
3747
let pairToken = "";
3848
let serverUrl = DEFAULT_SERVER_URL;
@@ -125,7 +135,7 @@ export function createDesktopBridge() {
125135
if (!pairToken) {
126136
desktopVersion = "";
127137
taskSnapshot = [];
128-
setConnectionState("missing_token", "请先在扩展设置里填写配对令牌");
138+
setConnectionState("missing_token", MISSING_PAIRING_MESSAGE);
129139
return;
130140
}
131141

@@ -190,6 +200,83 @@ export function createDesktopBridge() {
190200
});
191201
}
192202

203+
async function requestPairing(): Promise<void> {
204+
clearReconnectTimer();
205+
setConnectionState("connecting", "正在请求桌面端确认配对");
206+
207+
try {
208+
const token = await new Promise<string>((resolve, reject) => {
209+
const socket = new WebSocket(serverUrl);
210+
let settled = false;
211+
let timeoutId = 0;
212+
213+
const finish = (done: () => void) => {
214+
if (settled) {
215+
return;
216+
}
217+
settled = true;
218+
self.clearTimeout(timeoutId);
219+
socket.close();
220+
done();
221+
};
222+
223+
timeoutId = self.setTimeout(() => {
224+
finish(() => reject(new Error("等待桌面端确认配对超时")));
225+
}, PAIRING_TIMEOUT_MS);
226+
227+
socket.addEventListener("open", () => {
228+
socket.send(
229+
JSON.stringify({
230+
type: "pair_request",
231+
requestId: nextRequestId(),
232+
protocolVersion: PROTOCOL_VERSION,
233+
extensionVersion: chrome.runtime.getManifest().version,
234+
clientKind: "browser_extension",
235+
}),
236+
);
237+
});
238+
239+
socket.addEventListener("message", (event) => {
240+
let response: PairingResponse;
241+
try {
242+
response = JSON.parse(String(event.data ?? "")) as PairingResponse;
243+
} catch {
244+
return;
245+
}
246+
if (response.type !== "pair_result") {
247+
return;
248+
}
249+
250+
if (!response.ok) {
251+
finish(() => reject(new Error(response.message || "桌面端已拒绝配对请求")));
252+
return;
253+
}
254+
255+
const token = String(response.token ?? "").trim();
256+
if (!token) {
257+
finish(() => reject(new Error("桌面端未返回配对令牌")));
258+
return;
259+
}
260+
261+
finish(() => resolve(token));
262+
});
263+
264+
socket.addEventListener("close", () => {
265+
finish(() => reject(new Error("配对连接已断开")));
266+
});
267+
268+
socket.addEventListener("error", () => {
269+
finish(() => reject(new Error("无法连接到 Ghost Downloader")));
270+
});
271+
});
272+
await setToken(token);
273+
} catch (error: unknown) {
274+
const message = error instanceof Error ? error.message : "自动配对失败";
275+
setConnectionState(pairToken ? "disconnected" : "missing_token", message);
276+
throw error;
277+
}
278+
}
279+
193280
async function sendRequest<T extends DesktopRequestResult>(payload: Record<string, unknown>): Promise<T> {
194281
if (!isReady() || !desktopSocket) {
195282
throw new Error("Ghost Downloader 未连接");
@@ -241,7 +328,7 @@ export function createDesktopBridge() {
241328
desktopSocket.close();
242329
desktopSocket = null;
243330
}
244-
setConnectionState("missing_token", "请先在扩展设置里填写配对令牌");
331+
setConnectionState("missing_token", MISSING_PAIRING_MESSAGE);
245332
}
246333

247334
async function setServerUrl(nextServerUrl: string) {
@@ -288,6 +375,7 @@ export function createDesktopBridge() {
288375
handleReconnectAlarm,
289376
isReady,
290377
loadPersistentState,
378+
requestPairing,
291379
sendRequest,
292380
setServerUrl,
293381
setToken,

browser_extension/app/src/popup/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,11 @@ export function App({
9999
savingToken={bridge.isSavingToken}
100100
savingServerUrl={bridge.isSavingServerUrl}
101101
refreshingConnection={bridge.isRefreshingConnection}
102+
requestingPairing={bridge.isRequestingPairing}
102103
onSaveToken={bridge.saveToken}
103104
onSaveServerUrl={bridge.saveServerUrl}
104105
onRefreshConnection={bridge.refreshConnection}
106+
onRequestPairing={bridge.requestPairing}
105107
themePreference={themePreference}
106108
resolvedThemePreference={resolvedThemePreference}
107109
onThemePreferenceChange={onThemePreferenceChange}

browser_extension/app/src/popup/components/SettingsPage.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
ArrowClockwiseRegular,
1414
ClipboardPasteRegular,
15+
PlugConnectedRegular,
1516
} from "@fluentui/react-icons";
1617
import { useEffect, useState } from "react";
1718

@@ -81,9 +82,11 @@ export function SettingsPage({
8182
savingToken,
8283
savingServerUrl,
8384
refreshingConnection,
85+
requestingPairing,
8486
onSaveToken,
8587
onSaveServerUrl,
8688
onRefreshConnection,
89+
onRequestPairing,
8790
themePreference,
8891
resolvedThemePreference,
8992
onThemePreferenceChange,
@@ -96,9 +99,11 @@ export function SettingsPage({
9699
savingToken?: boolean;
97100
savingServerUrl?: boolean;
98101
refreshingConnection?: boolean;
102+
requestingPairing?: boolean;
99103
onSaveToken: (value: string) => Promise<boolean>;
100104
onSaveServerUrl: (value: string) => Promise<boolean>;
101105
onRefreshConnection: () => Promise<boolean>;
106+
onRequestPairing: () => Promise<boolean>;
102107
themePreference: ThemePreference;
103108
resolvedThemePreference: Exclude<ThemePreference, "system">;
104109
onThemePreferenceChange: (nextPreference: ThemePreference) => void;
@@ -167,7 +172,17 @@ export function SettingsPage({
167172
<Card appearance="filled-alternative" className={styles.card}>
168173
<div className={styles.header}>
169174
<Body1Strong>连接配置</Body1Strong>
170-
<ConnectionStatusBadge state={connectionState} message={connectionMessage} />
175+
<div className={styles.inputRow}>
176+
<ConnectionStatusBadge state={connectionState} message={connectionMessage} />
177+
<Button
178+
appearance="primary"
179+
disabled={requestingPairing || savingToken || savingServerUrl}
180+
icon={<PlugConnectedRegular />}
181+
onClick={() => void onRequestPairing()}
182+
>
183+
自动配对
184+
</Button>
185+
</div>
171186
</div>
172187

173188
<Field label="本地服务地址">

browser_extension/app/src/popup/hooks/usePopupBridge.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function createEmptyMediaState(): MediaPlaybackState {
6060
function createEmptyPayload(): PopupStatePayload {
6161
return {
6262
connectionState: "missing_token",
63-
connectionMessage: "请先在扩展设置里填写配对令牌",
63+
connectionMessage: "请在扩展设置页自动配对桌面端",
6464
desktopVersion: "",
6565
token: "",
6666
serverUrl: "",
@@ -160,6 +160,7 @@ export function usePopupBridge(activeView: PopupView) {
160160
const [isSavingToken, setIsSavingToken] = useState(false);
161161
const [isSavingServerUrl, setIsSavingServerUrl] = useState(false);
162162
const [isRefreshingConnection, setIsRefreshingConnection] = useState(false);
163+
const [isRequestingPairing, setIsRequestingPairing] = useState(false);
163164
const [isUpdatingIntercept, setIsUpdatingIntercept] = useState(false);
164165
const [isUpdatingMediaDownloadOverlay, setIsUpdatingMediaDownloadOverlay] = useState(false);
165166
const [isUpdatingMedia, setIsUpdatingMedia] = useState(false);
@@ -338,6 +339,30 @@ export function usePopupBridge(activeView: PopupView) {
338339
}
339340
}, [applyPopupState, requestPopupState, setFlash]);
340341

342+
const requestPairing = useCallback(async () => {
343+
setIsRequestingPairing(true);
344+
try {
345+
const result = await sendRuntimeMessage<DesktopRequestResult>({
346+
type: "popup_request_pairing",
347+
});
348+
if (!result.ok) {
349+
throw new Error(result.message || "自动配对失败");
350+
}
351+
setFlash(result.message || "配对请求已发送,请在桌面端确认");
352+
void refreshState(activeViewRef.current).catch(() => {
353+
// Ignore transient popup refresh failures.
354+
});
355+
return true;
356+
} catch (error) {
357+
setFlash(getErrorMessage(error, "自动配对失败"), "error");
358+
return false;
359+
} finally {
360+
if (mountedRef.current) {
361+
setIsRequestingPairing(false);
362+
}
363+
}
364+
}, [refreshState, setFlash]);
365+
341366
const setInterceptDownloads = useCallback(
342367
async (enabled: boolean) => {
343368
setIsUpdatingIntercept(true);
@@ -572,12 +597,14 @@ export function usePopupBridge(activeView: PopupView) {
572597
isSavingToken,
573598
isSavingServerUrl,
574599
isRefreshingConnection,
600+
isRequestingPairing,
575601
isUpdatingIntercept,
576602
isUpdatingMediaDownloadOverlay,
577603
isUpdatingMedia,
578604
saveToken,
579605
saveServerUrl,
580606
refreshConnection,
607+
requestPairing,
581608
setInterceptDownloads,
582609
setMediaDownloadOverlay,
583610
performTaskAction,

browser_extension/app/src/shared/constants.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ export const HELP_CONTENT: Record<string, { title: string; body: string[] }> = {
5252
title: "如何配对桌面端",
5353
body: [
5454
"1. 在 Ghost-Downloader-3 桌面端设置页开启“启用浏览器扩展”。",
55-
"2. 复制桌面端显示的配对令牌。",
56-
"3. 回到扩展设置页,把令牌粘贴到“配对令牌”输入框并保存。",
55+
"2. 在扩展设置页点击“自动配对”。",
56+
"3. 回到桌面端确认配对请求。",
5757
"4. 连接状态变成“已连接”后,浏览器就能直接管理桌面任务了。",
5858
],
5959
},
@@ -77,7 +77,7 @@ export const HELP_CONTENT: Record<string, { title: string; body: string[] }> = {
7777
title: "故障排查",
7878
body: [
7979
"1. 先确认桌面端浏览器扩展服务已经开启。",
80-
"2. 检查配对令牌是否和桌面端一致,必要时重新生成后再次保存。",
80+
"2. 优先点击“自动配对”,失败时再检查配对令牌是否和桌面端一致。",
8181
"3. 如果状态一直断开,尝试点击设置页里的重新连接。",
8282
"4. 浏览器下载没有被接管时,可先确认顶部“拦截下载”开关是否开启。",
8383
],

0 commit comments

Comments
 (0)