dsh-mobile 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -5,6 +5,674 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
+ //#region src/client-messages.ts
9
+ const MOBILE_CONTROL_MESSAGES = {
10
+ en: {
11
+ mobileAccess: "Mobile access",
12
+ collapseMobileAccess: "Collapse Mobile access",
13
+ downloadAndroid: "Download Android app",
14
+ downloadAndroidAria: "Download the latest Android app from GitHub Releases",
15
+ lan: "Local network",
16
+ remote: "Remote",
17
+ lanAccess: "Local network access",
18
+ remoteAccess: "Remote access",
19
+ browserAccess: "Browser access",
20
+ remoteAddress: "Remote address",
21
+ loadingStatus: "Loading status…",
22
+ loadingRemoteStatus: "Loading remote status…",
23
+ generateCopyKey: "Generate and copy key",
24
+ copyPairLink: "Copy pairing link",
25
+ managePairedDevices: "Manage paired devices",
26
+ clearAllDevices: "Clear all devices",
27
+ pairingQr: "Pairing QR code",
28
+ remoteIntro: "Choose the remote channel that suits you. Switching or disabling remote access does not affect the local network.",
29
+ chooseProvider: "Choose connection method",
30
+ providerInfoAria: "View remote connection security and network information",
31
+ providerGroupAria: "Remote connection method",
32
+ providerSafeTitle: "You remain protected",
33
+ providerSafeText: "Only paired devices can access DSH. cpolar is installed on demand and can be removed completely; Tailscale may be slow or unavailable on mainland China networks, where cpolar is recommended.",
34
+ builtIn: "Built in",
35
+ mainlandPreferred: "Preferred in mainland China",
36
+ tailscaleDescription: "Wider coverage; mainland China networks may be unstable. The first connection requires login and Funnel authorization.",
37
+ cpolarDescription: "Installs the official component on demand and is suitable for mainland China networks.",
38
+ prepareCpolar: "Prepare cpolar",
39
+ checkingComponent: "Checking component…",
40
+ installOfficial: "Install official component",
41
+ cpolarAccountNote: "Sign in to the cpolar website and copy the Authtoken. The token is stored only in the plugin private directory and is never shown on the page or in logs.",
42
+ registerCpolar: "Register with cpolar",
43
+ openDashboard: "Open dashboard to get token",
44
+ tokenPlaceholder: "Paste cpolar Authtoken",
45
+ saveConnect: "Save and connect",
46
+ saving: "Saving…",
47
+ componentDetails: "Component source and cleanup",
48
+ componentDetailsText: "Downloaded from the official cpolar website and verified at a fixed version only after you choose Install. It does not add a system service, startup item, registry entry, or PATH entry.",
49
+ pluginPrivateDirectory: "Plugin private directory",
50
+ officialDownload: "Official download page",
51
+ terms: "Terms of service",
52
+ purgeCpolar: "Completely remove cpolar component and configuration",
53
+ tailscaleHelp: "How to use Tailscale",
54
+ tailscaleHelpText: "The runtime component is included with the plugin. The first connection opens the official Tailscale login and Funnel authorization pages; the plugin never accesses your account password.",
55
+ funnelGuideAria: "Steps to enable Tailscale Funnel",
56
+ funnelGuideTitle: "Remote access setup · Step 2",
57
+ funnelGuideSummary: "Tailscale login is complete. Funnel must still be allowed for this computer; the official page also enables HTTPS.",
58
+ funnelStep1: "Open the official Tailscale authorization page for this node.",
59
+ funnelStep2: "Confirm Funnel; you do not need to sign in to DSH again.",
60
+ funnelStep3: "Return to DSH; the plugin checks and connects automatically.",
61
+ funnelGuideNote: "Requires an Owner, Admin, or Network admin account.",
62
+ continueFunnel: "Continue Funnel authorization",
63
+ retryNow: "Done, retry now",
64
+ enableRemote: "Enable remote access",
65
+ disableRemote: "Disable remote access",
66
+ continueLogin: "Continue login",
67
+ reconnect: "Reconnect",
68
+ generateRemoteQr: "Generate remote pairing QR code",
69
+ manageRemoteDevices: "Manage remote devices",
70
+ resetRemoteLogin: "Sign out and clear remote login",
71
+ resetRemoteDevices: "Disable and clear remote devices",
72
+ lanOn: "Local network access is on.",
73
+ lanOff: "Local network access is off.",
74
+ enableLan: "Enable local network access",
75
+ disableLan: "Disable local network access",
76
+ extensionsLoaded: "Extensions: {loaded} loaded",
77
+ extensionsFailed: "Extensions: {loaded} loaded, {failed} failed",
78
+ keyGenerationFailed: "Could not generate a pairing key.",
79
+ keyCopied: "Pairing key copied. Paste it into the Android app.",
80
+ linkCopied: "Pairing link copied. Send it to the phone and paste it into the app or open it in a browser.",
81
+ copySecret: "Copy {kind}: {value}",
82
+ pairingKey: "pairing key",
83
+ pairingLink: "pairing link",
84
+ noDevices: "No paired devices.",
85
+ noRemoteDevices: "No remote paired devices.",
86
+ device: "Device",
87
+ expires: "Expires {time}",
88
+ revoke: "Revoke",
89
+ confirmResetDevices: "Remove all paired devices? Connected devices will be disconnected immediately.",
90
+ ready: "Ready",
91
+ installed: "Installed",
92
+ installWithSize: "Install official component · {size} MB",
93
+ cpolarUnsupported: "Only Windows x64 is currently supported. You can still use the built-in Tailscale Funnel.",
94
+ cpolarNotInstalled: "Not installed. A fixed version is downloaded from the official cpolar website only after you click the button below.",
95
+ cpolarNeedsToken: "Official component {version} is verified. Save the account token to continue.",
96
+ cpolarReady: "Official component {version} and the local account configuration are ready.",
97
+ remoteOff: "Remote access is disabled. Local network access is unaffected.",
98
+ remoteUnavailableCpolar: "cpolar is not installed or its local account is not configured.",
99
+ remoteUnavailableTailscale: "This computer is missing the Funnel runtime component. Reinstall the complete plugin package.",
100
+ remoteStartingCpolar: "Connecting to a cpolar node…",
101
+ remoteStartingTailscale: "Starting the secure Tailscale channel…",
102
+ remoteNeedsLogin: "Complete a one-time Tailscale login in the browser. The plugin never reads your password.",
103
+ remoteConnectingCpolar: "Public address allocated; starting the DSH authentication gateway…",
104
+ remoteConnectingTailscale: "Login complete; creating the public HTTPS address…",
105
+ remoteReady: "Remote access is ready. Only paired devices can access DSH.",
106
+ remoteError: "The remote connection was not established. Reconnect; local network access still works.",
107
+ funnelPermission: "Login is complete. Authorize Funnel to establish the remote connection automatically.",
108
+ funnelHttps: "Login is complete. Authorize Funnel; the official page also enables HTTPS.",
109
+ funnelStart: "Login is complete. Finish the initial Tailscale Funnel authorization.",
110
+ tailscaleDnsMissing: "Tailscale has not provided a remote address. Reconnect and confirm login is complete.",
111
+ gatewayStartFailed: "The remote gateway failed to start. Reconnect; local network access is unaffected.",
112
+ controlChannelFailed: "The remote component connection was interrupted. Reconnect.",
113
+ cpolarMissing: "The official cpolar component is not installed. Complete the preparation steps above.",
114
+ cpolarInvalid: "cpolar component verification failed. Remove it completely and reinstall.",
115
+ cpolarConfigMissing: "No cpolar account token is saved. Complete the preparation steps above.",
116
+ cpolarConfigInvalid: "The local cpolar configuration is invalid. Save the account token again.",
117
+ cpolarPortUnavailable: "Could not allocate a local remote-gateway port. Try again.",
118
+ cpolarLaunchFailed: "The cpolar client failed to start.",
119
+ cpolarTimeout: "Timed out connecting to a cpolar node. Reconnect.",
120
+ cpolarStopped: "The cpolar connection stopped.",
121
+ cpolarExited: "The cpolar connection exited unexpectedly. Reconnect.",
122
+ cpolarOutputInvalid: "cpolar returned an unrecognized status.",
123
+ cpolarOriginInvalid: "The public address returned by cpolar failed validation.",
124
+ setupOpened: "The official Tailscale page is open. Return to DSH after enabling Funnel; reconnection is automatic.",
125
+ switchProviderConfirm: "Switching connection method first disables the current remote channel. Local network access and paired devices are unaffected. Continue?",
126
+ switchingCpolar: "Switching to cpolar…",
127
+ switchingTailscale: "Switching to Tailscale Funnel…",
128
+ installConfirm: "Download and verify a fixed version from the official cpolar website (about 7.3 MB) and extract it only to the DSH Mobile private directory? No system service, PATH/registry entry, or startup item is added.",
129
+ downloading: "Downloading and verifying…",
130
+ installingCpolar: "Installing the official cpolar component. Keep DSH running until it completes.",
131
+ installFailed: "Component installation failed: {error}",
132
+ invalidToken: "Paste the complete Authtoken from the cpolar dashboard.",
133
+ configuredConnecting: "Account configuration saved; connecting through cpolar…",
134
+ configureFailed: "Configuration failed: {error}",
135
+ purgeConfirm: "Completely remove the cpolar component, token configuration, and runtime logs from the DSH Mobile private directory? Local network access, DSH data, and other system programs are unaffected.",
136
+ purging: "Stopping the channel and removing cpolar files managed by DSH Mobile…",
137
+ purgeFailed: "Cleanup failed: {error}",
138
+ reconnectingCpolar: "Reconnecting to a cpolar node…",
139
+ reconnectingTailscale: "Checking Tailscale settings and reconnecting…",
140
+ remoteQrReady: "Remote pairing QR code generated. Scan it from “Remote access” in the app.",
141
+ resetCpolarConfirm: "Disable the cpolar remote channel and remove all remote paired devices? Your cpolar account and other tunnels are unchanged.",
142
+ resetTailscaleConfirm: "Sign out of Tailscale on this computer and remove all remote paired devices? Local network configuration is unchanged.",
143
+ requestFailed: "Request failed: {error}"
144
+ },
145
+ it: {
146
+ mobileAccess: "Accesso mobile",
147
+ collapseMobileAccess: "Riduci Accesso mobile",
148
+ downloadAndroid: "Scarica app Android",
149
+ downloadAndroidAria: "Scarica l’ultima app Android da GitHub Releases",
150
+ lan: "Rete locale",
151
+ remote: "Remoto",
152
+ lanAccess: "Accesso rete locale",
153
+ remoteAccess: "Accesso remoto",
154
+ browserAccess: "Accesso browser",
155
+ remoteAddress: "Indirizzo remoto",
156
+ loadingStatus: "Lettura stato…",
157
+ loadingRemoteStatus: "Lettura stato remoto…",
158
+ generateCopyKey: "Genera e copia chiave",
159
+ copyPairLink: "Copia link di abbinamento",
160
+ managePairedDevices: "Gestisci dispositivi abbinati",
161
+ clearAllDevices: "Rimuovi tutti i dispositivi",
162
+ pairingQr: "Codice QR di abbinamento",
163
+ remoteIntro: "Scegli il canale remoto più adatto. Cambiare o disattivare l’accesso remoto non influisce sulla rete locale.",
164
+ chooseProvider: "Scegli metodo di connessione",
165
+ providerInfoAria: "Informazioni su sicurezza e rete della connessione remota",
166
+ providerGroupAria: "Metodo di connessione remota",
167
+ providerSafeTitle: "La protezione resta attiva",
168
+ providerSafeText: "Solo i dispositivi abbinati possono accedere a DSH. cpolar viene installato su richiesta e può essere rimosso completamente; Tailscale può essere lento o non disponibile nelle reti della Cina continentale, dove è consigliato cpolar.",
169
+ builtIn: "Integrato",
170
+ mainlandPreferred: "Preferito in Cina continentale",
171
+ tailscaleDescription: "Copertura più ampia; le reti della Cina continentale possono essere instabili. Il primo collegamento richiede accesso e autorizzazione Funnel.",
172
+ cpolarDescription: "Installa su richiesta il componente ufficiale, adatto alle reti della Cina continentale.",
173
+ prepareCpolar: "Prepara cpolar",
174
+ checkingComponent: "Controllo componente…",
175
+ installOfficial: "Installa componente ufficiale",
176
+ cpolarAccountNote: "Accedi al sito cpolar e copia l’Authtoken. Il token resta solo nella directory privata del plugin e non viene mostrato nella pagina o nei log.",
177
+ registerCpolar: "Registrati su cpolar",
178
+ openDashboard: "Apri la dashboard per il token",
179
+ tokenPlaceholder: "Incolla Authtoken cpolar",
180
+ saveConnect: "Salva e connetti",
181
+ saving: "Salvataggio…",
182
+ componentDetails: "Origine e rimozione del componente",
183
+ componentDetailsText: "Viene scaricato dal sito ufficiale cpolar e verificato a una versione fissa solo dopo aver scelto Installa. Non aggiunge servizi di sistema, avvio automatico, voci di registro o PATH.",
184
+ pluginPrivateDirectory: "Directory privata del plugin",
185
+ officialDownload: "Pagina download ufficiale",
186
+ terms: "Termini di servizio",
187
+ purgeCpolar: "Rimuovi completamente componente e configurazione cpolar",
188
+ tailscaleHelp: "Guida a Tailscale",
189
+ tailscaleHelpText: "Il componente runtime è incluso nel plugin. Il primo collegamento apre le pagine ufficiali di accesso Tailscale e autorizzazione Funnel; il plugin non accede mai alla password.",
190
+ funnelGuideAria: "Passaggi per abilitare Tailscale Funnel",
191
+ funnelGuideTitle: "Configurazione accesso remoto · Passaggio 2",
192
+ funnelGuideSummary: "L’accesso Tailscale è completato. Devi ancora consentire Funnel per questo computer; la pagina ufficiale abilita anche HTTPS.",
193
+ funnelStep1: "Apri la pagina ufficiale di autorizzazione Tailscale per questo nodo.",
194
+ funnelStep2: "Conferma Funnel; non serve accedere di nuovo a DSH.",
195
+ funnelStep3: "Torna in DSH; il plugin controlla e si connette automaticamente.",
196
+ funnelGuideNote: "Richiede un account Owner, Admin o Network admin.",
197
+ continueFunnel: "Continua autorizzazione Funnel",
198
+ retryNow: "Fatto, riprova ora",
199
+ enableRemote: "Attiva accesso remoto",
200
+ disableRemote: "Disattiva accesso remoto",
201
+ continueLogin: "Continua accesso",
202
+ reconnect: "Riconnetti",
203
+ generateRemoteQr: "Genera QR di abbinamento remoto",
204
+ manageRemoteDevices: "Gestisci dispositivi remoti",
205
+ resetRemoteLogin: "Esci e rimuovi accesso remoto",
206
+ resetRemoteDevices: "Disattiva e rimuovi dispositivi remoti",
207
+ lanOn: "Accesso dalla rete locale attivo.",
208
+ lanOff: "Accesso dalla rete locale disattivato.",
209
+ enableLan: "Attiva accesso rete locale",
210
+ disableLan: "Disattiva accesso rete locale",
211
+ extensionsLoaded: "Estensioni: {loaded} caricate",
212
+ extensionsFailed: "Estensioni: {loaded} caricate, {failed} non riuscite",
213
+ keyGenerationFailed: "Impossibile generare la chiave di abbinamento.",
214
+ keyCopied: "Chiave di abbinamento copiata. Incollala nell’app Android.",
215
+ linkCopied: "Link di abbinamento copiato. Invialo al telefono e incollalo nell’app oppure aprilo nel browser.",
216
+ copySecret: "Copia {kind}: {value}",
217
+ pairingKey: "chiave di abbinamento",
218
+ pairingLink: "link di abbinamento",
219
+ noDevices: "Nessun dispositivo abbinato.",
220
+ noRemoteDevices: "Nessun dispositivo remoto abbinato.",
221
+ device: "Dispositivo",
222
+ expires: "Scade {time}",
223
+ revoke: "Revoca",
224
+ confirmResetDevices: "Rimuovere tutti i dispositivi abbinati? I dispositivi connessi verranno disconnessi subito.",
225
+ ready: "Pronto",
226
+ installed: "Installato",
227
+ installWithSize: "Installa componente ufficiale · {size} MB",
228
+ cpolarUnsupported: "Attualmente è supportato solo Windows x64. Puoi comunque usare Tailscale Funnel integrato.",
229
+ cpolarNotInstalled: "Non installato. Una versione fissa viene scaricata dal sito ufficiale cpolar solo dopo aver premuto il pulsante.",
230
+ cpolarNeedsToken: "Componente ufficiale {version} verificato. Salva il token account per continuare.",
231
+ cpolarReady: "Componente ufficiale {version} e configurazione account locale pronti.",
232
+ remoteOff: "Accesso remoto disattivato. La rete locale non è interessata.",
233
+ remoteUnavailableCpolar: "cpolar non è installato o l’account locale non è configurato.",
234
+ remoteUnavailableTailscale: "Su questo computer manca il componente runtime Funnel. Reinstalla il pacchetto completo del plugin.",
235
+ remoteStartingCpolar: "Connessione a un nodo cpolar…",
236
+ remoteStartingTailscale: "Avvio del canale sicuro Tailscale…",
237
+ remoteNeedsLogin: "Completa una volta l’accesso Tailscale nel browser. Il plugin non legge mai la password.",
238
+ remoteConnectingCpolar: "Indirizzo pubblico assegnato; avvio del gateway di autenticazione DSH…",
239
+ remoteConnectingTailscale: "Accesso completato; creazione dell’indirizzo HTTPS pubblico…",
240
+ remoteReady: "Accesso remoto pronto. Solo i dispositivi abbinati possono accedere a DSH.",
241
+ remoteError: "Connessione remota non stabilita. Riconnettiti; la rete locale continua a funzionare.",
242
+ funnelPermission: "Accesso completato. Autorizza Funnel per stabilire automaticamente la connessione remota.",
243
+ funnelHttps: "Accesso completato. Autorizza Funnel; la pagina ufficiale abilita anche HTTPS.",
244
+ funnelStart: "Accesso completato. Termina la prima autorizzazione Tailscale Funnel.",
245
+ tailscaleDnsMissing: "Tailscale non ha fornito un indirizzo remoto. Riconnettiti e verifica di aver completato l’accesso.",
246
+ gatewayStartFailed: "Avvio del gateway remoto non riuscito. Riconnettiti; la rete locale non è interessata.",
247
+ controlChannelFailed: "Connessione al componente remoto interrotta. Riconnettiti.",
248
+ cpolarMissing: "Il componente ufficiale cpolar non è installato. Completa i passaggi sopra.",
249
+ cpolarInvalid: "Verifica del componente cpolar non riuscita. Rimuovilo completamente e reinstallalo.",
250
+ cpolarConfigMissing: "Nessun token account cpolar salvato. Completa i passaggi sopra.",
251
+ cpolarConfigInvalid: "Configurazione locale cpolar non valida. Salva nuovamente il token.",
252
+ cpolarPortUnavailable: "Impossibile assegnare una porta locale al gateway remoto. Riprova.",
253
+ cpolarLaunchFailed: "Avvio del client cpolar non riuscito.",
254
+ cpolarTimeout: "Connessione al nodo cpolar scaduta. Riconnettiti.",
255
+ cpolarStopped: "Connessione cpolar arrestata.",
256
+ cpolarExited: "Connessione cpolar terminata inaspettatamente. Riconnettiti.",
257
+ cpolarOutputInvalid: "cpolar ha restituito uno stato non riconosciuto.",
258
+ cpolarOriginInvalid: "L’indirizzo pubblico restituito da cpolar non ha superato la verifica.",
259
+ setupOpened: "La pagina ufficiale Tailscale è aperta. Torna in DSH dopo aver abilitato Funnel; la riconnessione è automatica.",
260
+ switchProviderConfirm: "Il cambio di metodo disattiva prima il canale remoto corrente. Rete locale e dispositivi abbinati non sono interessati. Continuare?",
261
+ switchingCpolar: "Passaggio a cpolar…",
262
+ switchingTailscale: "Passaggio a Tailscale Funnel…",
263
+ installConfirm: "Scaricare e verificare una versione fissa dal sito ufficiale cpolar (circa 7,3 MB), estraendola solo nella directory privata DSH Mobile? Non vengono aggiunti servizi, PATH/registro o avvio automatico.",
264
+ downloading: "Download e verifica…",
265
+ installingCpolar: "Installazione del componente ufficiale cpolar. Mantieni DSH in esecuzione fino al termine.",
266
+ installFailed: "Installazione componente non riuscita: {error}",
267
+ invalidToken: "Incolla l’Authtoken completo dalla dashboard cpolar.",
268
+ configuredConnecting: "Configurazione account salvata; connessione tramite cpolar…",
269
+ configureFailed: "Configurazione non riuscita: {error}",
270
+ purgeConfirm: "Rimuovere completamente componente cpolar, configurazione token e log runtime dalla directory privata DSH Mobile? Rete locale, dati DSH e altri programmi non sono interessati.",
271
+ purging: "Arresto del canale e rimozione dei file cpolar gestiti da DSH Mobile…",
272
+ purgeFailed: "Pulizia non riuscita: {error}",
273
+ reconnectingCpolar: "Riconnessione a un nodo cpolar…",
274
+ reconnectingTailscale: "Controllo impostazioni Tailscale e riconnessione…",
275
+ remoteQrReady: "QR di abbinamento remoto generato. Scansionalo da “Accesso remoto” nell’app.",
276
+ resetCpolarConfirm: "Disattivare il canale remoto cpolar e rimuovere tutti i dispositivi remoti? Account cpolar e altri tunnel restano invariati.",
277
+ resetTailscaleConfirm: "Uscire da Tailscale su questo computer e rimuovere tutti i dispositivi remoti? La configurazione della rete locale resta invariata.",
278
+ requestFailed: "Richiesta non riuscita: {error}"
279
+ },
280
+ zh: {}
281
+ };
282
+ Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
283
+ mobileAccess: "移动访问",
284
+ collapseMobileAccess: "收起移动访问",
285
+ downloadAndroid: "下载 Android App",
286
+ downloadAndroidAria: "前往 GitHub Releases 下载最新版 Android App",
287
+ lan: "局域网",
288
+ remote: "远程",
289
+ lanAccess: "局域网访问",
290
+ remoteAccess: "远程访问",
291
+ browserAccess: "浏览器访问",
292
+ remoteAddress: "远程地址",
293
+ loadingStatus: "正在读取状态…",
294
+ loadingRemoteStatus: "正在读取远程状态…",
295
+ generateCopyKey: "生成并复制密钥",
296
+ copyPairLink: "复制配对链接",
297
+ managePairedDevices: "管理配对设备",
298
+ clearAllDevices: "清除所有设备",
299
+ pairingQr: "配对二维码",
300
+ remoteIntro: "选择更适合你的远程通道。切换或关闭远程访问不会影响局域网。",
301
+ chooseProvider: "选择连接方式",
302
+ providerInfoAria: "查看远程连接安全与网络说明",
303
+ providerGroupAria: "远程连接方式",
304
+ providerSafeTitle: "你始终可以放心",
305
+ providerSafeText: "只有已配对设备能进入 DSH。cpolar 按需安装并可彻底清理;Tailscale 在中国大陆网络下可能连接缓慢、中断或无法使用,国内网络建议优先尝试 cpolar。",
306
+ builtIn: "内置",
307
+ mainlandPreferred: "国内网络优先",
308
+ tailscaleDescription: "覆盖更广;中国大陆网络可能不稳定,首次需登录并允许 Funnel。",
309
+ cpolarDescription: "按需安装官方组件,适合国内网络环境。",
310
+ prepareCpolar: "准备 cpolar",
311
+ checkingComponent: "正在检查组件…",
312
+ installOfficial: "安装官方组件",
313
+ cpolarAccountNote: "登录 cpolar 官网后复制 Authtoken。令牌只保存在本机插件私有目录,不会显示在页面或日志中。",
314
+ registerCpolar: "注册 cpolar",
315
+ openDashboard: "打开控制台获取令牌",
316
+ tokenPlaceholder: "粘贴 cpolar Authtoken",
317
+ saveConnect: "保存并连接",
318
+ saving: "正在保存…",
319
+ componentDetails: "组件来源与清理说明",
320
+ componentDetailsText: "仅在你点击安装后从 cpolar 官网下载并校验固定版本。不会写入系统服务、开机启动、注册表或 PATH。",
321
+ pluginPrivateDirectory: "插件私有目录",
322
+ officialDownload: "官方下载安装页",
323
+ terms: "服务条款",
324
+ purgeCpolar: "彻底移除 cpolar 组件与配置",
325
+ tailscaleHelp: "Tailscale 使用说明",
326
+ tailscaleHelpText: "运行组件已随插件提供。首次连接会打开 Tailscale 官方登录和 Funnel 授权页;插件不会接触你的账号密码。",
327
+ funnelGuideAria: "Tailscale Funnel 启用步骤",
328
+ funnelGuideTitle: "远程访问设置 · 第 2 步",
329
+ funnelGuideSummary: "Tailscale 登录已完成。还需为这台电脑允许 Funnel,官方页面会同时启用 HTTPS。",
330
+ funnelStep1: "打开当前节点的 Tailscale 官方授权页。",
331
+ funnelStep2: "确认启用 Funnel;无需再次登录 DSH。",
332
+ funnelStep3: "返回 DSH,插件会自动检查并建立连接。",
333
+ funnelGuideNote: "需要使用 Owner、Admin 或 Network admin 账号。",
334
+ continueFunnel: "继续完成 Funnel 授权",
335
+ retryNow: "已完成,立即重试",
336
+ enableRemote: "启用远程访问",
337
+ disableRemote: "关闭远程访问",
338
+ continueLogin: "继续登录",
339
+ reconnect: "重新连接",
340
+ generateRemoteQr: "生成远程配对二维码",
341
+ manageRemoteDevices: "管理远程设备",
342
+ resetRemoteLogin: "退出并清除远程登录",
343
+ resetRemoteDevices: "关闭并清除远程设备",
344
+ lanOn: "局域网访问已开启。",
345
+ lanOff: "局域网访问已关闭。",
346
+ enableLan: "开启局域网访问",
347
+ disableLan: "关闭局域网访问",
348
+ extensionsLoaded: "扩展:{loaded} 个已加载",
349
+ extensionsFailed: "扩展:{loaded} 个已加载,{failed} 个加载失败",
350
+ keyGenerationFailed: "无法生成配对密钥。",
351
+ keyCopied: "配对密钥已复制,请粘贴到 Android App。",
352
+ linkCopied: "配对链接已复制,发给手机后 App 粘贴或浏览器打开即可配对。",
353
+ copySecret: "请复制{kind}:{value}",
354
+ pairingKey: "配对密钥",
355
+ pairingLink: "配对链接",
356
+ noDevices: "暂无配对设备。",
357
+ noRemoteDevices: "暂无远程配对设备。",
358
+ device: "设备",
359
+ expires: "到期 {time}",
360
+ revoke: "撤销",
361
+ confirmResetDevices: "确定要移除所有配对设备吗?此操作会立即终止已连接设备。",
362
+ ready: "已就绪",
363
+ installed: "已安装",
364
+ installWithSize: "安装官方组件 · {size} MB",
365
+ cpolarUnsupported: "当前仅支持 Windows x64。你仍可选择内置的 Tailscale Funnel。",
366
+ cpolarNotInstalled: "尚未安装。只有点击下方按钮后,才会从 cpolar 官网下载固定版本。",
367
+ cpolarNeedsToken: "官方组件 {version} 已校验,下一步只需保存账号令牌。",
368
+ cpolarReady: "官方组件 {version} 与本机账号配置已就绪。",
369
+ remoteOff: "远程访问未启用。局域网访问不受影响。",
370
+ remoteUnavailableCpolar: "cpolar 尚未安装或未完成本机账号配置。",
371
+ remoteUnavailableTailscale: "当前电脑缺少 Funnel 运行组件,请重新安装完整插件包。",
372
+ remoteStartingCpolar: "正在连接 cpolar 国内节点…",
373
+ remoteStartingTailscale: "正在启动 Tailscale 安全通道…",
374
+ remoteNeedsLogin: "需要在浏览器完成一次 Tailscale 登录。插件不会读取你的密码。",
375
+ remoteConnectingCpolar: "公网地址已分配,正在启动 DSH 认证网关…",
376
+ remoteConnectingTailscale: "登录完成,正在建立公开 HTTPS 地址…",
377
+ remoteReady: "远程访问已就绪。只有已配对设备可以进入 DSH。",
378
+ remoteError: "远程连接未建立。可重新连接,局域网访问仍可正常使用。",
379
+ funnelPermission: "登录已完成。请继续授权 Funnel,完成后会自动建立远程连接。",
380
+ funnelHttps: "登录已完成。请继续授权 Funnel,官方页面会同时启用 HTTPS。",
381
+ funnelStart: "登录已完成。请继续完成 Tailscale Funnel 的首次授权。",
382
+ tailscaleDnsMissing: "Tailscale 暂未提供远程地址。请重新连接并确认已完成登录。",
383
+ gatewayStartFailed: "远程网关启动失败。请重新连接,局域网访问不受影响。",
384
+ controlChannelFailed: "远程组件连接中断。请重新连接。",
385
+ cpolarMissing: "cpolar 官方组件尚未安装。请先完成上方准备步骤。",
386
+ cpolarInvalid: "cpolar 组件校验失败。请彻底移除后重新安装。",
387
+ cpolarConfigMissing: "cpolar 尚未保存账号令牌。请先完成上方准备步骤。",
388
+ cpolarConfigInvalid: "cpolar 本机配置无效。请重新保存账号令牌。",
389
+ cpolarPortUnavailable: "无法分配本机远程网关端口,请重试。",
390
+ cpolarLaunchFailed: "cpolar 客户端未能启动。",
391
+ cpolarTimeout: "连接 cpolar 国内节点超时,请重新连接。",
392
+ cpolarStopped: "cpolar 连接已停止。",
393
+ cpolarExited: "cpolar 连接意外退出,请重新连接。",
394
+ cpolarOutputInvalid: "cpolar 返回了无法识别的状态。",
395
+ cpolarOriginInvalid: "cpolar 返回的公网地址未通过校验。",
396
+ setupOpened: "Tailscale 官方页面已打开。完成启用后返回 DSH,这里会自动重新连接。",
397
+ switchProviderConfirm: "切换连接方式会先关闭当前远程通道。局域网和配对设备不会受影响,是否继续?",
398
+ switchingCpolar: "正在切换到 cpolar…",
399
+ switchingTailscale: "正在切换到 Tailscale Funnel…",
400
+ installConfirm: "将从 cpolar 官方网站下载并校验固定版本(约 7.3 MB),仅解压到 DSH Mobile 私有目录。不会安装系统服务、写入 PATH/注册表或设置开机启动。是否继续?",
401
+ downloading: "正在下载并校验…",
402
+ installingCpolar: "正在安装 cpolar 官方组件。完成前请保持 DSH 运行。",
403
+ installFailed: "组件安装失败:{error}",
404
+ invalidToken: "请粘贴 cpolar 控制台提供的完整 Authtoken。",
405
+ configuredConnecting: "账号配置已保存,正在建立 cpolar 远程通道…",
406
+ configureFailed: "配置失败:{error}",
407
+ purgeConfirm: "彻底移除 DSH Mobile 私有目录中的 cpolar 组件、令牌配置和运行日志?不会影响局域网、DSH 数据或系统中的其他程序。",
408
+ purging: "正在关闭通道并清理 DSH Mobile 管理的 cpolar 文件…",
409
+ purgeFailed: "清理失败:{error}",
410
+ reconnectingCpolar: "正在重新连接 cpolar 国内节点…",
411
+ reconnectingTailscale: "正在确认 Tailscale 设置并重新连接…",
412
+ remoteQrReady: "远程配对二维码已生成。请在 App 的“远程访问”中扫描。",
413
+ resetCpolarConfirm: "关闭 cpolar 远程通道并移除所有远程配对设备?不会修改你的 cpolar 账号或其他隧道。",
414
+ resetTailscaleConfirm: "退出电脑上的 Tailscale 登录并移除所有远程配对设备?局域网配置不会改变。",
415
+ requestFailed: "请求失败:{error}"
416
+ });
417
+ Object.assign(MOBILE_CONTROL_MESSAGES.en, {
418
+ requestTimeout: "The operation timed out. Confirm that DSH is still running, then try again.",
419
+ diagnostics: "Diagnostics",
420
+ openDiagnostics: "Open connection diagnostics",
421
+ back: "Back",
422
+ backToMobile: "Back to Mobile access",
423
+ connectionDiagnostics: "Connection diagnostics",
424
+ diagnosticsIntro: "Check versions, gateway, network adapter, firewall, and remote channel. The report is automatically redacted and never reads conversations or credentials.",
425
+ diagnosticsNotRun: "Not checked yet",
426
+ diagnosticsStartHint: "Select the button below to begin.",
427
+ diagnosticsIdleMeta: "Waiting to run · connection status only",
428
+ diagnosticsStart: "Start check",
429
+ diagnosticsCopy: "Copy redacted report",
430
+ diagnosticsAdvanced: "Advanced diagnostic details",
431
+ diagnosticsComplete: "Check complete",
432
+ diagnosticsAttention: "Some items need attention",
433
+ diagnosticsProblem: "Connection problems found",
434
+ diagnosticsCompleteFallback: "The check is complete.",
435
+ diagnosticStatusOk: "OK",
436
+ diagnosticStatusWarning: "Attention",
437
+ diagnosticStatusError: "Problem",
438
+ diagnosticStatusInfo: "Info",
439
+ diagnosticItems: "{count} items",
440
+ diagnosticCheck: "Check",
441
+ diagnosticAction: "Suggested action",
442
+ diagnosticNeedsAction: "Needs action",
443
+ diagnosticDetails: "Check details",
444
+ diagnosticOther: "Other checks",
445
+ diagnosticNoBlockers: "{count} checks · no blocking issues found",
446
+ diagnosticNeedsCount: "{count} checks · {issues} need attention",
447
+ diagnosticsChecking: "Checking…",
448
+ diagnosticsCheckingTitle: "Checking connection",
449
+ diagnosticsCheckingText: "This usually takes a few seconds. The remote channel performs a real reachability test.",
450
+ diagnosticsRunningMeta: "Running · keep DSH online",
451
+ diagnosticsIncomplete: "Check not completed",
452
+ diagnosticsReadFailed: "Could not read diagnostics: {error}",
453
+ diagnosticsUnavailable: "Diagnostics service unavailable · try again later",
454
+ diagnosticsRetry: "Check again",
455
+ diagnosticsCopied: "Redacted report copied. You can paste it directly into an issue.",
456
+ diagnosticsCopyManual: "Clipboard access was denied. Details are expanded for manual copying.",
457
+ diagnosticLabelVersions: "Version compatibility",
458
+ diagnosticLabelNetwork: "Local network adapter",
459
+ diagnosticLabelLan: "Local network gateway",
460
+ diagnosticLabelFirewall: "Windows Firewall",
461
+ diagnosticLabelRemote: "Remote channel",
462
+ diagnosticLabelPhone: "Phone network",
463
+ funnelTimeout: "The Tailscale component timed out while starting. Check the network, then reconnect."
464
+ });
465
+ Object.assign(MOBILE_CONTROL_MESSAGES.it, {
466
+ requestTimeout: "Operazione scaduta. Verifica che DSH sia ancora in esecuzione e riprova.",
467
+ diagnostics: "Diagnostica",
468
+ openDiagnostics: "Apri diagnostica connessione",
469
+ back: "Indietro",
470
+ backToMobile: "Torna ad Accesso mobile",
471
+ connectionDiagnostics: "Diagnostica connessione",
472
+ diagnosticsIntro: "Controlla versioni, gateway, scheda di rete, firewall e canale remoto. Il report viene anonimizzato automaticamente e non legge conversazioni o credenziali.",
473
+ diagnosticsNotRun: "Controllo non eseguito",
474
+ diagnosticsStartHint: "Premi il pulsante sotto per iniziare.",
475
+ diagnosticsIdleMeta: "In attesa · solo stato connessione",
476
+ diagnosticsStart: "Avvia controllo",
477
+ diagnosticsCopy: "Copia report anonimizzato",
478
+ diagnosticsAdvanced: "Dettagli diagnostici avanzati",
479
+ diagnosticsComplete: "Controllo completato",
480
+ diagnosticsAttention: "Alcuni elementi richiedono attenzione",
481
+ diagnosticsProblem: "Rilevati problemi di connessione",
482
+ diagnosticsCompleteFallback: "Controllo completato.",
483
+ diagnosticStatusOk: "OK",
484
+ diagnosticStatusWarning: "Attenzione",
485
+ diagnosticStatusError: "Problema",
486
+ diagnosticStatusInfo: "Informazione",
487
+ diagnosticItems: "{count} elementi",
488
+ diagnosticCheck: "Controllo",
489
+ diagnosticAction: "Suggerimento",
490
+ diagnosticNeedsAction: "Da risolvere",
491
+ diagnosticDetails: "Dettagli controllo",
492
+ diagnosticOther: "Altri controlli",
493
+ diagnosticNoBlockers: "{count} controlli · nessun problema bloccante",
494
+ diagnosticNeedsCount: "{count} controlli · {issues} richiedono attenzione",
495
+ diagnosticsChecking: "Controllo…",
496
+ diagnosticsCheckingTitle: "Controllo connessione",
497
+ diagnosticsCheckingText: "Di solito richiede pochi secondi. Il canale remoto esegue un test reale di raggiungibilità.",
498
+ diagnosticsRunningMeta: "In esecuzione · mantieni DSH online",
499
+ diagnosticsIncomplete: "Controllo non completato",
500
+ diagnosticsReadFailed: "Impossibile leggere la diagnostica: {error}",
501
+ diagnosticsUnavailable: "Servizio diagnostico non disponibile · riprova più tardi",
502
+ diagnosticsRetry: "Ripeti controllo",
503
+ diagnosticsCopied: "Report anonimizzato copiato. Puoi incollarlo direttamente in una issue.",
504
+ diagnosticsCopyManual: "Il browser ha negato la copia. I dettagli sono stati aperti per la copia manuale.",
505
+ diagnosticLabelVersions: "Compatibilità versioni",
506
+ diagnosticLabelNetwork: "Scheda rete locale",
507
+ diagnosticLabelLan: "Gateway rete locale",
508
+ diagnosticLabelFirewall: "Windows Firewall",
509
+ diagnosticLabelRemote: "Canale remoto",
510
+ diagnosticLabelPhone: "Rete telefono",
511
+ funnelTimeout: "Avvio del componente Tailscale scaduto. Controlla la rete e riconnettiti."
512
+ });
513
+ Object.assign(MOBILE_CONTROL_MESSAGES.zh, {
514
+ requestTimeout: "操作超时,请确认 DSH 仍在运行后重试。",
515
+ diagnostics: "诊断",
516
+ openDiagnostics: "打开连接诊断",
517
+ back: "返回",
518
+ backToMobile: "返回移动访问",
519
+ connectionDiagnostics: "连接诊断",
520
+ diagnosticsIntro: "检查版本、网关、网卡、防火墙和远程通道。报告自动脱敏,不读取对话或凭据。",
521
+ diagnosticsNotRun: "尚未检查",
522
+ diagnosticsStartHint: "点击下方按钮开始。",
523
+ diagnosticsIdleMeta: "等待运行 · 仅收集连接状态",
524
+ diagnosticsStart: "开始检查",
525
+ diagnosticsCopy: "复制脱敏报告",
526
+ diagnosticsAdvanced: "高级诊断详情",
527
+ diagnosticsComplete: "检查完成",
528
+ diagnosticsAttention: "有项目需要留意",
529
+ diagnosticsProblem: "发现连接问题",
530
+ diagnosticsCompleteFallback: "检查已完成。",
531
+ diagnosticStatusOk: "正常",
532
+ diagnosticStatusWarning: "注意",
533
+ diagnosticStatusError: "问题",
534
+ diagnosticStatusInfo: "说明",
535
+ diagnosticItems: "{count} 项",
536
+ diagnosticCheck: "检查项",
537
+ diagnosticAction: "建议",
538
+ diagnosticNeedsAction: "需要处理",
539
+ diagnosticDetails: "检查详情",
540
+ diagnosticOther: "其他检查",
541
+ diagnosticNoBlockers: "{count} 项检查 · 未发现阻断问题",
542
+ diagnosticNeedsCount: "{count} 项检查 · {issues} 项需要处理",
543
+ diagnosticsChecking: "正在检查…",
544
+ diagnosticsCheckingTitle: "正在检查连接",
545
+ diagnosticsCheckingText: "通常几秒内完成。远程通道会执行一次真实可达性测试。",
546
+ diagnosticsRunningMeta: "正在运行 · 请保持 DSH 在线",
547
+ diagnosticsIncomplete: "检查未完成",
548
+ diagnosticsReadFailed: "无法读取诊断结果:{error}",
549
+ diagnosticsUnavailable: "诊断服务暂不可用 · 请稍后重试",
550
+ diagnosticsRetry: "重新检查",
551
+ diagnosticsCopied: "脱敏报告已复制,可直接粘贴到 Issue。",
552
+ diagnosticsCopyManual: "浏览器未允许复制,已展开详情,请手动复制。",
553
+ diagnosticLabelVersions: "版本兼容",
554
+ diagnosticLabelNetwork: "局域网网卡",
555
+ diagnosticLabelLan: "局域网网关",
556
+ diagnosticLabelFirewall: "Windows 防火墙",
557
+ diagnosticLabelRemote: "远程通道",
558
+ diagnosticLabelPhone: "手机网络",
559
+ funnelTimeout: "Tailscale 组件启动超时,请检查网络后重新连接。"
560
+ });
561
+ const LOCALIZED_DIAGNOSTIC_COPY = {
562
+ en: {
563
+ versions: "Installed plugin, DSH, and minimum Android app versions are shown.",
564
+ networkOk: "The configured local network adapter is available.",
565
+ networkError: "The saved local network adapter is unavailable.",
566
+ networkInfo: "A fixed local network configuration is in use.",
567
+ networkAction: "Run dsh-mobile setup again.",
568
+ lanOk: "The local gateway is listening and pairing is available.",
569
+ lanInfo: "Local network access is currently off.",
570
+ lanAction: "Enable local network access when the phone must connect directly.",
571
+ firewallOk: "Local TCP and discovery firewall rules are enabled.",
572
+ firewallWarning: "The complete local network firewall rules were not found.",
573
+ firewallInfo: "The system did not allow the plugin to read firewall status.",
574
+ firewallAction: "Run dsh-mobile setup as administrator and check the firewall rules.",
575
+ remoteOk: "The remote public address passed the reachability check.",
576
+ remoteWarning: "The remote channel needs attention or is still connecting.",
577
+ remoteError: "The remote connection is not currently reachable.",
578
+ remoteInfo: "Remote access is currently off.",
579
+ remoteAction: "Return to Remote access, follow the provider guidance, and reconnect.",
580
+ phone: "The computer cannot determine whether the router isolates the phone.",
581
+ phoneAction: "Confirm the phone and computer use the same network, then disable guest-network or AP isolation.",
582
+ reportTitle: "DSH Mobile diagnostic report",
583
+ generated: "Generated"
584
+ },
585
+ it: {
586
+ versions: "Sono indicate le versioni installate di plugin e DSH e la versione minima dell’app Android.",
587
+ networkOk: "La scheda di rete locale configurata è disponibile.",
588
+ networkError: "La scheda di rete locale salvata non è disponibile.",
589
+ networkInfo: "È in uso una configurazione di rete locale fissa.",
590
+ networkAction: "Esegui di nuovo dsh-mobile setup.",
591
+ lanOk: "Il gateway locale è in ascolto e l’abbinamento è disponibile.",
592
+ lanInfo: "L’accesso dalla rete locale è disattivato.",
593
+ lanAction: "Attiva l’accesso locale quando il telefono deve collegarsi direttamente.",
594
+ firewallOk: "Le regole firewall TCP locale e di rilevamento sono attive.",
595
+ firewallWarning: "Le regole firewall complete per la rete locale non sono state trovate.",
596
+ firewallInfo: "Il sistema non ha consentito al plugin di leggere lo stato del firewall.",
597
+ firewallAction: "Esegui dsh-mobile setup come amministratore e controlla le regole firewall.",
598
+ remoteOk: "L’indirizzo pubblico remoto ha superato il test di raggiungibilità.",
599
+ remoteWarning: "Il canale remoto richiede attenzione o è ancora in connessione.",
600
+ remoteError: "La connessione remota non è al momento raggiungibile.",
601
+ remoteInfo: "L’accesso remoto è disattivato.",
602
+ remoteAction: "Torna ad Accesso remoto, segui le indicazioni del provider e riconnettiti.",
603
+ phone: "Il computer non può stabilire se il router isola il telefono.",
604
+ phoneAction: "Verifica che telefono e computer usino la stessa rete, poi disattiva rete ospiti o isolamento AP.",
605
+ reportTitle: "Report diagnostico DSH Mobile",
606
+ generated: "Generato"
607
+ },
608
+ zh: {
609
+ reportTitle: "DSH Mobile 诊断报告",
610
+ generated: "生成时间"
611
+ }
612
+ };
613
+ const DIAGNOSTIC_REASON_MESSAGES = {
614
+ en: {
615
+ "versions-current": ["Installed plugin, DSH, and minimum Android app versions are shown.", ""],
616
+ "network-unavailable": ["The saved local network adapter is unavailable.", "Run dsh-mobile setup again."],
617
+ "network-interface": ["Using network interface {interfaceName}.", ""],
618
+ "network-fixed": ["A fixed local network configuration is in use.", ""],
619
+ "lan-ready": ["The local gateway is listening at {endpointSuffix}; pairing is available.", ""],
620
+ "lan-off": ["Local network access is currently off.", "Enable local network access when the phone must connect directly."],
621
+ "firewall-ready": ["Local TCP and discovery firewall rules are enabled.", ""],
622
+ "firewall-missing": ["The complete local network firewall rules were not found.", "Run dsh-mobile setup as administrator and check the firewall rules."],
623
+ "firewall-unknown": ["The system did not allow the plugin to read firewall status.", "If the phone cannot find this computer, run setup as administrator."],
624
+ "remote-off": ["Remote access through {provider} is currently off.", ""],
625
+ "remote-ready": ["{provider} endpoint {endpointSuffix} is reachable in about {latencyMs} ms.", ""],
626
+ "remote-rate-limited": ["{provider} endpoint {endpointSuffix} is reachable, but this check observed rate limiting.", "Try again later; older sessions load on demand to reduce traffic."],
627
+ "remote-fake-ip": ["The Tailscale address is intercepted by the current VPN or DNS proxy, so TLS was not established.", "Switch VPN node or proxy mode; if it still fails, use cpolar."],
628
+ "remote-unreachable": ["{provider} reports ready, but endpoint {endpointSuffix} is not reachable.", "Reconnect, then check the provider status if it still fails."],
629
+ "remote-needs-login": ["Tailscale is waiting for login to finish.", "Return to Remote access and continue login."],
630
+ "remote-connecting": ["The {provider} remote channel is still connecting.", "Wait briefly, then check again."],
631
+ "remote-controller-error": ["The {provider} controller reported {controllerCode}.", "Return to Remote access and reconnect."],
632
+ "phone-network-unknown": ["The computer cannot determine whether the router isolates the phone.", "Confirm the phone and computer use the same network, then disable guest-network or AP isolation."]
633
+ },
634
+ it: {
635
+ "versions-current": ["Sono indicate le versioni installate di plugin e DSH e la versione minima dell’app Android.", ""],
636
+ "network-unavailable": ["La scheda di rete locale salvata non è disponibile.", "Esegui di nuovo dsh-mobile setup."],
637
+ "network-interface": ["È in uso l’interfaccia di rete {interfaceName}.", ""],
638
+ "network-fixed": ["È in uso una configurazione di rete locale fissa.", ""],
639
+ "lan-ready": ["Il gateway locale è in ascolto su {endpointSuffix}; l’abbinamento è disponibile.", ""],
640
+ "lan-off": ["L’accesso dalla rete locale è disattivato.", "Attiva l’accesso locale quando il telefono deve collegarsi direttamente."],
641
+ "firewall-ready": ["Le regole firewall TCP locale e di rilevamento sono attive.", ""],
642
+ "firewall-missing": ["Le regole firewall complete per la rete locale non sono state trovate.", "Esegui dsh-mobile setup come amministratore e controlla le regole firewall."],
643
+ "firewall-unknown": ["Il sistema non ha consentito al plugin di leggere lo stato del firewall.", "Se il telefono non trova il computer, esegui setup come amministratore."],
644
+ "remote-off": ["L’accesso remoto tramite {provider} è disattivato.", ""],
645
+ "remote-ready": ["L’endpoint {provider} {endpointSuffix} è raggiungibile in circa {latencyMs} ms.", ""],
646
+ "remote-rate-limited": ["L’endpoint {provider} {endpointSuffix} è raggiungibile, ma il controllo ha rilevato una limitazione temporanea.", "Riprova più tardi; le sessioni precedenti vengono caricate su richiesta per ridurre il traffico."],
647
+ "remote-fake-ip": ["L’indirizzo Tailscale è intercettato dalla VPN o dal proxy DNS corrente e TLS non è stato stabilito.", "Cambia nodo VPN o modalità proxy; se il problema continua, usa cpolar."],
648
+ "remote-unreachable": ["{provider} risulta pronto, ma l’endpoint {endpointSuffix} non è raggiungibile.", "Riconnettiti; se il problema continua, controlla lo stato del provider."],
649
+ "remote-needs-login": ["Tailscale attende il completamento dell’accesso.", "Torna ad Accesso remoto e continua l’accesso."],
650
+ "remote-connecting": ["Il canale remoto {provider} è ancora in connessione.", "Attendi qualche istante e ripeti il controllo."],
651
+ "remote-controller-error": ["Il controller {provider} ha segnalato {controllerCode}.", "Torna ad Accesso remoto e riconnettiti."],
652
+ "phone-network-unknown": ["Il computer non può stabilire se il router isola il telefono.", "Verifica che telefono e computer usino la stessa rete, poi disattiva rete ospiti o isolamento AP."]
653
+ },
654
+ zh: {
655
+ "versions-current": ["已显示插件、DSH 和 Android App 最低版本。", ""],
656
+ "network-unavailable": ["已保存的局域网网卡当前不可用。", "重新运行 dsh-mobile setup。"],
657
+ "network-interface": ["正在使用网卡 {interfaceName}。", ""],
658
+ "network-fixed": ["当前使用固定局域网配置。", ""],
659
+ "lan-ready": ["局域网网关正在监听 {endpointSuffix},配对入口可用。", ""],
660
+ "lan-off": ["局域网访问当前未开启。", "手机需要直连时开启局域网访问。"],
661
+ "firewall-ready": ["局域网 TCP 与发现防火墙规则已启用。", ""],
662
+ "firewall-missing": ["未找到完整的局域网防火墙规则。", "以管理员身份运行 dsh-mobile setup 并检查防火墙规则。"],
663
+ "firewall-unknown": ["系统未允许插件读取防火墙状态。", "若手机找不到电脑,请以管理员身份运行 setup。"],
664
+ "remote-off": ["{provider} 远程访问当前未启用。", ""],
665
+ "remote-ready": ["{provider} 端点 {endpointSuffix} 可达,往返约 {latencyMs} ms。", ""],
666
+ "remote-rate-limited": ["{provider} 端点 {endpointSuffix} 可达,但本次检查观察到服务限流。", "稍后重试;旧会话会按需加载以减少流量。"],
667
+ "remote-fake-ip": ["Tailscale 地址被当前 VPN 或 DNS 代理接管,TLS 链路未建立。", "切换 VPN 节点或代理模式;仍失败时改用 cpolar。"],
668
+ "remote-unreachable": ["{provider} 显示已就绪,但端点 {endpointSuffix} 暂不可达。", "点击重新连接;仍失败时检查提供方状态。"],
669
+ "remote-needs-login": ["Tailscale 正在等待完成登录。", "返回远程访问并继续登录。"],
670
+ "remote-connecting": ["{provider} 远程通道仍在连接。", "等待片刻后重新检查。"],
671
+ "remote-controller-error": ["{provider} 控制器报告 {controllerCode}。", "返回远程访问并重新连接。"],
672
+ "phone-network-unknown": ["电脑无法判断路由器是否隔离了手机。", "确认手机与电脑使用同一网络,并关闭访客网络或 AP 隔离。"]
673
+ }
674
+ };
675
+ //#endregion
8
676
  //#region src/native-mobile.ts
9
677
  /** Mobile feature and compatibility rules applied to DSH React surfaces. */
10
678
  const NATIVE_MOBILE_STYLES = `
@@ -38,7 +706,8 @@ window.__ModuleLoader__.load({
38
706
  [data-dsh-mobile-header] [class*="_headerUtilities"] { gap:2px !important; }
39
707
  [data-dsh-mobile-header] [class*="_sessionLogButton"] { width:40px; min-width:40px; padding:0 !important; overflow:hidden; color:transparent; font-size:0 !important; }
40
708
  [data-dsh-mobile-header] [class*="_sessionLogButton"] > * { display:none !important; }
41
- [data-dsh-mobile-header] [class*="_sessionLogButton"]::after { color:var(--dsw-text, #171a21); content:"日志"; font-size:11px; font-weight:600; }
709
+ [data-dsh-mobile-header] [class*="_sessionLogButton"]::after { color:var(--dsw-text, #171a21); content:"Log"; font-size:11px; font-weight:600; }
710
+ html[data-dsh-mobile-language="zh"] [data-dsh-mobile-header] [class*="_sessionLogButton"]::after { content:"日志"; }
42
711
  [data-dsh-mobile-header] [class*="_tabs"] { box-sizing:border-box !important; width:max-content !important; max-width:calc(100% - 58px) !important; min-height:28px !important; height:28px !important; margin-top:0 !important; padding-left:6px !important; padding-right:6px !important; overflow-x:auto; scrollbar-width:none; }
43
712
  [data-dsh-mobile-header] [class*="_tab"] { padding-bottom:5px !important; }
44
713
  [data-dsh-mobile-header] [class*="_tabs"]::-webkit-scrollbar { display:none; }
@@ -133,8 +802,15 @@ window.__ModuleLoader__.load({
133
802
  [data-dsh-mobile-center] [role="dialog"][aria-label*="Context"] [class*="_rows"] > [class*="_row"] { display:flex !important; align-items:center !important; justify-content:space-between !important; width:100% !important; min-width:0 !important; white-space:nowrap !important; }
134
803
  [data-dsh-mobile-center] [role="dialog"][aria-label*="上下文"] :is(dt,dd),
135
804
  [data-dsh-mobile-center] [role="dialog"][aria-label*="Context"] :is(dt,dd) { white-space:nowrap !important; word-break:keep-all !important; }
136
- .dsh-mobile-branch-toast { position:fixed; z-index:300; top:max(12px,env(safe-area-inset-top)); left:50%; max-width:calc(100vw - 32px); box-sizing:border-box; padding:7px 14px; border:1px solid rgb(15 23 42 / 10%); border-radius:999px; background:rgb(15 23 42 / 92%); color:#fff; font-size:13px; line-height:20px; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:0; pointer-events:none; transform:translate(-50%,-8px); transition:opacity 160ms ease,transform 160ms ease; }
137
- .dsh-mobile-branch-toast[data-visible="true"] { opacity:1; transform:translate(-50%,0); }
805
+ .dsh-mobile-branch-toast,.dsh-mobile-media-toast { position:fixed; z-index:330; top:max(12px,env(safe-area-inset-top)); left:50%; max-width:calc(100vw - 32px); box-sizing:border-box; padding:7px 14px; border:1px solid rgb(15 23 42 / 10%); border-radius:999px; background:rgb(15 23 42 / 92%); color:#fff; font-size:13px; line-height:20px; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:0; pointer-events:none; transform:translate(-50%,-8px); transition:opacity 160ms ease,transform 160ms ease; }
806
+ .dsh-mobile-branch-toast[data-visible="true"],.dsh-mobile-media-toast[data-visible="true"] { opacity:1; transform:translate(-50%,0); }
807
+ .dsh-mobile-media-shortcuts { box-sizing:border-box; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; width:100%; padding:4px 4px 8px; margin-bottom:4px; border-bottom:1px solid var(--dsw-alias-border-inverted,var(--dsw-alias-border-subtle,rgb(148 163 184 / 28%))); }
808
+ .dsh-mobile-media-action { box-sizing:border-box; display:flex; align-items:center; justify-content:flex-start; gap:8px; width:100%; min-width:0; min-height:44px; padding:8px 10px; border:0; border-radius:10px; background:var(--dsw-alias-interactive-bg-hover,rgb(148 163 184 / 12%)); color:var(--dsw-alias-label-primary,inherit); cursor:pointer; font:inherit; font-size:14px; line-height:22px; text-align:left; touch-action:manipulation; }
809
+ .dsh-mobile-media-action:active { opacity:.72; }
810
+ .dsh-mobile-media-action:focus-visible { outline:2px solid var(--dsw-alias-interactive-border-focus,#4c82f7); outline-offset:1px; }
811
+ .dsh-mobile-media-action:disabled { cursor:default; opacity:.38; }
812
+ .dsh-mobile-media-action svg { flex:none; width:16px; height:16px; color:var(--dsw-alias-label-tertiary,currentColor); }
813
+ .dsh-mobile-media-action span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
138
814
  [data-dsh-mobile-center] [class*="_composer"] { padding-left:8px !important; padding-right:8px !important; padding-bottom:max(8px,env(safe-area-inset-bottom)) !important; }
139
815
  /* The desktop composer intentionally wraps whole toolbar groups. On a phone,
140
816
  dynamic model and status labels made that row alternate between one and
@@ -170,13 +846,99 @@ window.__ModuleLoader__.load({
170
846
  return Array.from(root.querySelectorAll("[class]")).find((element) => classToken(element, suffix));
171
847
  }
172
848
  const AUTO_HISTORY_THRESHOLD_PX = 64;
849
+ function controlledFileDragEvent(type, files, initialDropEffect) {
850
+ const event = new Event(type, {
851
+ bubbles: true,
852
+ cancelable: true
853
+ });
854
+ const dataTransfer = {
855
+ dropEffect: initialDropEffect,
856
+ effectAllowed: "copy",
857
+ files: Object.freeze([...files]),
858
+ types: Object.freeze(["Files"])
859
+ };
860
+ Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
861
+ return event;
862
+ }
863
+ /** Ask the official document dragover listener whether the current composer accepts files. */
864
+ function preflightComposerImageDrop(target, files) {
865
+ if (files.length === 0) return false;
866
+ const event = controlledFileDragEvent("dragover", files, "none");
867
+ target.dispatchEvent(event);
868
+ return event.dataTransfer?.dropEffect === "copy";
869
+ }
870
+ /** Dispatch a real drop only after a fresh official-listener preflight; the result is not an attachment ACK. */
871
+ function dispatchComposerImageDrop(target, files) {
872
+ if (!preflightComposerImageDrop(target, files)) return false;
873
+ target.dispatchEvent(controlledFileDragEvent("drop", files, "copy"));
874
+ return true;
875
+ }
876
+ /** Apply the resolved locale independently from the document's possibly different lang attribute. */
877
+ function applyNativeMobileLanguageMarker(root, language) {
878
+ const previous = root.dataset.dshMobileLanguage;
879
+ root.dataset.dshMobileLanguage = language;
880
+ return () => {
881
+ if (root.dataset.dshMobileLanguage !== language) return;
882
+ if (previous === void 0) delete root.dataset.dshMobileLanguage;
883
+ else root.dataset.dshMobileLanguage = previous;
884
+ };
885
+ }
886
+ /** Resolve the supported language used by native-mobile controls. */
887
+ function resolveNativeMobileLanguage(documentLanguage, browserLanguages) {
888
+ return [documentLanguage, ...browserLanguages].map((value) => value.trim().toLowerCase().split(/[-_]/u)[0]).find((value) => value === "it" || value === "en" || value === "zh") ?? "en";
889
+ }
173
890
  /** Whether a user-driven scroll moved upward into the automatic history-loading zone. */
174
891
  function shouldAutoLoadEarlier(previousTop, currentTop) {
175
892
  return currentTop <= AUTO_HISTORY_THRESHOLD_PX && currentTop < previousTop - .5;
176
893
  }
894
+ /** Check that an asynchronous picker result still belongs to its originating session and composer. */
895
+ function isComposerMediaOriginCurrent(origin, current) {
896
+ return !current.disposed && origin.generation === current.generation && origin.href === current.href && origin.composer !== null && current.composerConnected && origin.composer === current.composer && origin.sessionRoot !== null && origin.sessionRoot === current.sessionRoot && origin.sessionId !== null && origin.sessionId === current.sessionId;
897
+ }
177
898
  /** Add mobile semantics without replacing feature trees. */
178
899
  function installNativeMobileSurface() {
179
900
  document.documentElement.classList.add("dsh-native-mobile-active");
901
+ const browserLanguages = navigator.languages.length > 0 ? navigator.languages : [navigator.language];
902
+ const language = resolveNativeMobileLanguage(document.documentElement.lang, browserLanguages);
903
+ const restoreLanguageMarker = applyNativeMobileLanguageMarker(document.documentElement, language);
904
+ const label = (italian, english, chinese) => language === "it" ? italian : language === "zh" ? chinese : english;
905
+ const mediaIcon = (kind) => {
906
+ const namespace = "http://www.w3.org/2000/svg";
907
+ const icon = document.createElementNS(namespace, "svg");
908
+ icon.setAttribute("viewBox", "0 0 16 16");
909
+ icon.setAttribute("fill", "none");
910
+ icon.setAttribute("aria-hidden", "true");
911
+ if (kind === "attachment") {
912
+ const path = document.createElementNS(namespace, "path");
913
+ path.setAttribute("d", "M5.5498 9.75V5H6.9502V9.75C6.9502 10.3299 7.4201 10.7998 8 10.7998C8.5799 10.7998 9.0498 10.3299 9.0498 9.75V4.5C9.0498 2.9536 7.7964 1.7002 6.25 1.7002C4.7036 1.7002 3.4502 2.9536 3.4502 4.5V9.75C3.4502 12.2629 5.4871 14.2998 8 14.2998C10.5129 14.2998 12.5498 12.2629 12.5498 9.75V4H13.9502V9.75C13.9502 13.0361 11.2861 15.7002 8 15.7002C4.71391 15.7002 2.0498 13.0361 2.0498 9.75V4.5C2.04981 2.1804 3.9304 0.299806 6.25 0.299805C8.5696 0.299805 10.4502 2.1804 10.4502 4.5V9.75C10.4502 11.1031 9.3531 12.2002 8 12.2002C6.6469 12.2002 5.5498 11.1031 5.5498 9.75Z");
914
+ path.setAttribute("fill", "currentColor");
915
+ icon.append(path);
916
+ return icon;
917
+ }
918
+ const body = document.createElementNS(namespace, "path");
919
+ body.setAttribute("d", "M5.15 3.2 6.05 2h3.9l.9 1.2h1.45c1.05 0 1.9.85 1.9 1.9v6c0 1.05-.85 1.9-1.9 1.9H3.7a1.9 1.9 0 0 1-1.9-1.9v-6c0-1.05.85-1.9 1.9-1.9h1.45Zm-1.45 1.3a.6.6 0 0 0-.6.6v6c0 .33.27.6.6.6h8.6a.6.6 0 0 0 .6-.6v-6a.6.6 0 0 0-.6-.6h-2.1l-.9-1.2H6.7l-.9 1.2H3.7Z");
920
+ body.setAttribute("fill", "currentColor");
921
+ const lens = document.createElementNS(namespace, "circle");
922
+ lens.setAttribute("cx", "8");
923
+ lens.setAttribute("cy", "8.1");
924
+ lens.setAttribute("r", "2.15");
925
+ lens.setAttribute("stroke", "currentColor");
926
+ lens.setAttribute("stroke-width", "1.3");
927
+ icon.append(body, lens);
928
+ return icon;
929
+ };
930
+ const createMediaAction = (kind, text) => {
931
+ const button = document.createElement("button");
932
+ button.type = "button";
933
+ button.className = "dsh-mobile-media-action";
934
+ button.lang = language;
935
+ button.dataset.dshMobileMediaAction = kind;
936
+ button.append(mediaIcon(kind));
937
+ const caption = document.createElement("span");
938
+ caption.textContent = text;
939
+ button.append(caption);
940
+ return button;
941
+ };
180
942
  const setInputMode = (mode) => {
181
943
  document.documentElement.dataset.dshMobileInput = mode;
182
944
  };
@@ -191,19 +953,221 @@ window.__ModuleLoader__.load({
191
953
  const backdrop = document.createElement("button");
192
954
  backdrop.type = "button";
193
955
  backdrop.className = "dsh-native-mobile-backdrop";
956
+ backdrop.lang = language;
194
957
  backdrop.hidden = true;
195
- backdrop.setAttribute("aria-label", "关闭工作区导航");
958
+ backdrop.setAttribute("aria-label", label("Chiudi navigazione area di lavoro", "Close workspace navigation", "关闭工作区导航"));
196
959
  document.body.append(backdrop);
197
960
  const branchToast = document.createElement("div");
198
961
  branchToast.className = "dsh-mobile-branch-toast";
962
+ branchToast.lang = language;
199
963
  branchToast.setAttribute("role", "status");
200
964
  branchToast.setAttribute("aria-live", "polite");
201
965
  document.body.append(branchToast);
966
+ const mediaToast = document.createElement("div");
967
+ mediaToast.className = "dsh-mobile-media-toast";
968
+ mediaToast.lang = language;
969
+ mediaToast.setAttribute("role", "status");
970
+ mediaToast.setAttribute("aria-live", "polite");
971
+ document.body.append(mediaToast);
972
+ const mediaActions = document.createElement("div");
973
+ mediaActions.className = "dsh-mobile-media-shortcuts";
974
+ mediaActions.lang = language;
975
+ mediaActions.dataset.dshMobileMediaShortcuts = "true";
976
+ mediaActions.setAttribute("role", "group");
977
+ mediaActions.setAttribute("aria-label", label("Aggiungi immagine", "Add image", "添加图片"));
978
+ const fileButton = createMediaAction("attachment", label("Scegli immagine", "Choose image", "选择图片"));
979
+ const cameraButton = createMediaAction("camera", label("Scatta foto", "Take photo", "拍照"));
980
+ fileButton.disabled = true;
981
+ cameraButton.disabled = true;
982
+ mediaActions.append(fileButton, cameraButton);
202
983
  let branchToastTimer = 0;
984
+ let mediaToastTimer = 0;
985
+ const showMediaToast = (message) => {
986
+ mediaToast.textContent = message;
987
+ mediaToast.dataset.visible = "true";
988
+ if (mediaToastTimer !== 0) window.clearTimeout(mediaToastTimer);
989
+ mediaToastTimer = window.setTimeout(() => {
990
+ mediaToast.removeAttribute("data-visible");
991
+ mediaToastTimer = 0;
992
+ }, 2200);
993
+ };
994
+ let mediaRequestGeneration = 0;
995
+ let disposed = false;
996
+ let boundComposer = null;
997
+ let boundSessionRoot = null;
998
+ let boundSessionId = null;
999
+ const preflightFile = new File([], "dsh-mobile-preflight.png", { type: "image/png" });
1000
+ const canAcceptComposerDrop = () => preflightComposerImageDrop(document, [preflightFile]);
1001
+ const mediaPickerAbortController = new AbortController();
1002
+ const browserPickerCleanups = /* @__PURE__ */ new Set();
1003
+ const sessionTokens = /* @__PURE__ */ new WeakMap();
1004
+ let nextSessionToken = 0;
1005
+ const currentSessionOrigin = () => {
1006
+ const dedicatedRoot = boundComposer?.closest("[data-dsh-mobile-session]") ?? null;
1007
+ const dedicatedId = dedicatedRoot?.getAttribute("data-dsh-mobile-session");
1008
+ if (dedicatedRoot !== null && typeof dedicatedId === "string" && dedicatedId !== "") return {
1009
+ sessionRoot: dedicatedRoot,
1010
+ sessionId: dedicatedId
1011
+ };
1012
+ const selectedRow = document.querySelector("[role=\"treeitem\"][aria-selected=\"true\"]");
1013
+ if (selectedRow === null) return {
1014
+ sessionRoot: null,
1015
+ sessionId: null
1016
+ };
1017
+ let token = sessionTokens.get(selectedRow);
1018
+ if (token === void 0) {
1019
+ token = `stock-${String(++nextSessionToken)}`;
1020
+ sessionTokens.set(selectedRow, token);
1021
+ }
1022
+ const identity = selectedRow.getAttribute("data-session-id") ?? selectedRow.getAttribute("aria-label") ?? selectedRow.textContent?.trim() ?? "";
1023
+ return {
1024
+ sessionRoot: selectedRow,
1025
+ sessionId: `${token}:${identity}`
1026
+ };
1027
+ };
1028
+ const mediaRequestContext = () => {
1029
+ const session = currentSessionOrigin();
1030
+ return {
1031
+ generation: ++mediaRequestGeneration,
1032
+ href: window.location.href,
1033
+ composer: boundComposer,
1034
+ ...session
1035
+ };
1036
+ };
1037
+ const mediaRequestIsCurrent = (context) => {
1038
+ const session = currentSessionOrigin();
1039
+ const composer = boundComposer;
1040
+ return isComposerMediaOriginCurrent(context, {
1041
+ generation: mediaRequestGeneration,
1042
+ href: window.location.href,
1043
+ composer,
1044
+ sessionRoot: session.sessionRoot,
1045
+ sessionId: session.sessionId,
1046
+ disposed,
1047
+ composerConnected: context.composer?.isConnected === true
1048
+ });
1049
+ };
1050
+ const deliverImages = (files, context) => {
1051
+ if (files.length === 0 || !mediaRequestIsCurrent(context)) return;
1052
+ dispatchComposerImageDrop(document, files);
1053
+ };
1054
+ const launchBrowserPicker = (camera, context) => {
1055
+ const input = document.createElement("input");
1056
+ input.type = "file";
1057
+ input.accept = "image/png,image/jpeg,image/webp,image/gif";
1058
+ if (camera) input.capture = "environment";
1059
+ input.hidden = true;
1060
+ const signal = mediaPickerAbortController.signal;
1061
+ let cleanupTimer = 0;
1062
+ let watchdogTimer = 0;
1063
+ let cleaned = false;
1064
+ const scheduleCleanup = () => {
1065
+ if (!cleaned && cleanupTimer === 0) cleanupTimer = window.setTimeout(cleanup, 1e3);
1066
+ };
1067
+ const onVisibilityChange = () => {
1068
+ if (document.visibilityState === "visible") scheduleCleanup();
1069
+ };
1070
+ const onChange = () => {
1071
+ if (cleaned) return;
1072
+ const files = input.files === null ? [] : [...input.files];
1073
+ cleanup();
1074
+ deliverImages(files, context);
1075
+ };
1076
+ const cleanup = () => {
1077
+ if (cleaned) return;
1078
+ cleaned = true;
1079
+ if (cleanupTimer !== 0) window.clearTimeout(cleanupTimer);
1080
+ if (watchdogTimer !== 0) window.clearTimeout(watchdogTimer);
1081
+ cleanupTimer = 0;
1082
+ watchdogTimer = 0;
1083
+ input.removeEventListener("change", onChange);
1084
+ input.removeEventListener("cancel", cleanup);
1085
+ window.removeEventListener("focus", scheduleCleanup);
1086
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1087
+ signal.removeEventListener("abort", cleanup);
1088
+ browserPickerCleanups.delete(cleanup);
1089
+ input.remove();
1090
+ };
1091
+ input.addEventListener("change", onChange);
1092
+ input.addEventListener("cancel", cleanup);
1093
+ window.addEventListener("focus", scheduleCleanup);
1094
+ document.addEventListener("visibilitychange", onVisibilityChange);
1095
+ signal.addEventListener("abort", cleanup, { once: true });
1096
+ watchdogTimer = window.setTimeout(cleanup, 3e5);
1097
+ browserPickerCleanups.add(cleanup);
1098
+ if (signal.aborted) {
1099
+ cleanup();
1100
+ return;
1101
+ }
1102
+ try {
1103
+ document.body.append(input);
1104
+ input.click();
1105
+ } catch {
1106
+ cleanup();
1107
+ if (mediaRequestIsCurrent(context)) showMediaToast(label("Impossibile aprire il selettore immagini", "Could not open the image picker", "无法打开图片选择器"));
1108
+ }
1109
+ };
1110
+ const dismissCommandMenu = () => {
1111
+ (boundComposer?.querySelector("button[aria-haspopup=\"listbox\"][aria-expanded=\"true\"]"))?.click();
1112
+ };
1113
+ const setMediaActionsDisabled = (disabled, title) => {
1114
+ for (const button of [fileButton, cameraButton]) {
1115
+ if (button.disabled !== disabled) button.disabled = disabled;
1116
+ if (button.title !== title) button.title = title;
1117
+ }
1118
+ };
1119
+ const quietMediaPointer = (event) => {
1120
+ event.preventDefault();
1121
+ event.stopPropagation();
1122
+ const active = document.activeElement;
1123
+ if (active instanceof HTMLElement && (active.matches("input,textarea") || active.isContentEditable)) active.blur();
1124
+ };
1125
+ const pickImage = (camera) => {
1126
+ if (!canAcceptComposerDrop()) {
1127
+ setMediaActionsDisabled(true, label("Allegati immagine non disponibili", "Image attachments are unavailable", "图片附件不可用"));
1128
+ dismissCommandMenu();
1129
+ return;
1130
+ }
1131
+ const context = mediaRequestContext();
1132
+ dismissCommandMenu();
1133
+ const bridge = window.__DSH_MOBILE_NATIVE__;
1134
+ if (bridge === void 0) {
1135
+ launchBrowserPicker(camera, context);
1136
+ return;
1137
+ }
1138
+ const action = camera ? "camera.capture" : "files.pick";
1139
+ const input = camera ? {} : { accept: [
1140
+ "image/png",
1141
+ "image/jpeg",
1142
+ "image/webp",
1143
+ "image/gif"
1144
+ ] };
1145
+ Promise.resolve().then(() => bridge.invoke(action, input)).then((value) => {
1146
+ if (!mediaRequestIsCurrent(context)) return;
1147
+ if (value instanceof File) deliverImages([value], context);
1148
+ else showMediaToast(label("Il file selezionato non è utilizzabile", "The selected file is unavailable", "所选文件不可用"));
1149
+ }).catch((error) => {
1150
+ if (!mediaRequestIsCurrent(context)) return;
1151
+ const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
1152
+ if (code === "cancelled") return;
1153
+ showMediaToast(code === "payload_too_large" ? label("L’immagine supera il limite di 8 MiB", "The image exceeds the 8 MiB limit", "图片超过 8 MiB 限制") : label("Impossibile aggiungere l’immagine", "Could not attach the image", "无法附加图片"));
1154
+ });
1155
+ };
1156
+ const chooseImage = () => {
1157
+ pickImage(false);
1158
+ };
1159
+ const takePhoto = () => {
1160
+ pickImage(true);
1161
+ };
1162
+ fileButton.addEventListener("pointerdown", quietMediaPointer);
1163
+ cameraButton.addEventListener("pointerdown", quietMediaPointer);
1164
+ fileButton.addEventListener("click", chooseImage);
1165
+ cameraButton.addEventListener("click", takePhoto);
203
1166
  const showBranchToast = () => {
204
1167
  const header = document.querySelector("[data-dsh-mobile-header]");
205
1168
  const title = header === null ? void 0 : header.querySelector("[class*=\"_crumbCurrent\"]")?.textContent?.trim();
206
- branchToast.textContent = title === void 0 ? "当前分支" : `当前分支:${title}`;
1169
+ const prefix = label("Ramo corrente", "Current branch", "当前分支");
1170
+ branchToast.textContent = title === void 0 ? prefix : `${prefix}: ${title}`;
207
1171
  branchToast.dataset.visible = "true";
208
1172
  if (branchToastTimer !== 0) window.clearTimeout(branchToastTimer);
209
1173
  branchToastTimer = window.setTimeout(() => {
@@ -213,7 +1177,7 @@ window.__ModuleLoader__.load({
213
1177
  };
214
1178
  const onBranchClick = (event) => {
215
1179
  if (!(event.target instanceof Element)) return;
216
- const branch = event.target.closest("button[aria-label*=\"分支\"],button[aria-label*=\"Branch\"],button[aria-label*=\"branch\"]");
1180
+ const branch = event.target.closest("button[aria-label*=\"分支\"],button[aria-label*=\"Branch\"],button[aria-label*=\"branch\"],button[aria-label*=\"Ramo\"],button[aria-label*=\"ramo\"]");
217
1181
  if (branch === null || branch.hasAttribute("disabled") || branch.getAttribute("aria-disabled") === "true") return;
218
1182
  window.setTimeout(showBranchToast, 80);
219
1183
  };
@@ -282,6 +1246,15 @@ window.__ModuleLoader__.load({
282
1246
  });
283
1247
  };
284
1248
  document.addEventListener("click", animateNavigation);
1249
+ const syncMediaBinding = (composer) => {
1250
+ const previousComposer = boundComposer;
1251
+ boundComposer = composer;
1252
+ const session = currentSessionOrigin();
1253
+ if (composer === previousComposer && session.sessionRoot === boundSessionRoot && session.sessionId === boundSessionId) return;
1254
+ boundSessionRoot = session.sessionRoot;
1255
+ boundSessionId = session.sessionId;
1256
+ mediaRequestGeneration += 1;
1257
+ };
285
1258
  const sync = () => {
286
1259
  scheduled = 0;
287
1260
  frame = firstByClassSuffix(document, "_frame");
@@ -293,6 +1266,9 @@ window.__ModuleLoader__.load({
293
1266
  const handle = frame === void 0 ? void 0 : firstByClassSuffix(frame, "_handle");
294
1267
  if (center === void 0) {
295
1268
  bindHistoryScroller(void 0);
1269
+ syncMediaBinding(null);
1270
+ setMediaActionsDisabled(true, label("Apri prima una sessione", "Open a session first", "请先打开会话"));
1271
+ mediaActions.remove();
296
1272
  return;
297
1273
  }
298
1274
  if (center !== void 0) {
@@ -326,16 +1302,34 @@ window.__ModuleLoader__.load({
326
1302
  table.before(wrapper);
327
1303
  wrapper.append(table);
328
1304
  }
329
- const composerRow = center.querySelector("[data-composer-card]")?.querySelector(":scope > [data-input-scroll]")?.nextElementSibling;
1305
+ const composerCard = center.querySelector("[data-composer-card]");
1306
+ const composerRow = composerCard?.querySelector(":scope > [data-input-scroll]")?.nextElementSibling;
1307
+ if (!(composerRow instanceof HTMLElement)) {
1308
+ syncMediaBinding(null);
1309
+ setMediaActionsDisabled(true, label("Apri prima una sessione", "Open a session first", "请先打开会话"));
1310
+ mediaActions.remove();
1311
+ }
330
1312
  if (composerRow instanceof HTMLElement) {
331
1313
  composerRow.dataset.dshMobileComposerRow = "true";
332
1314
  const groups = Array.from(composerRow.children).filter((child) => child instanceof HTMLElement);
333
1315
  const composerTools = groups[0];
334
1316
  const composerTrailing = groups.at(-1);
335
- if (composerTools !== void 0) composerTools.dataset.dshMobileComposerTools = "true";
1317
+ if (composerTools !== void 0) {
1318
+ composerTools.dataset.dshMobileComposerTools = "true";
1319
+ syncMediaBinding(composerCard ?? null);
1320
+ const composerInput = composerCard?.querySelector("textarea");
1321
+ const composerEditor = composerCard?.querySelector("[contenteditable=\"true\"],[contenteditable=\"plaintext-only\"]");
1322
+ const composerBusy = composerCard?.getAttribute("aria-busy") === "true" || composerInput?.disabled === true || composerInput?.readOnly === true || composerEditor?.getAttribute("aria-disabled") === "true";
1323
+ const attachmentBlocked = !canAcceptComposerDrop();
1324
+ const mediaDisabled = conversation === null || currentSessionOrigin().sessionId === null || composerBusy || attachmentBlocked;
1325
+ const mediaTitle = composerBusy ? label("Attendi il completamento della risposta", "Wait for the response to finish", "请等待回复完成") : attachmentBlocked ? label("Allegati immagine non disponibili", "Image attachments are unavailable", "图片附件不可用") : mediaDisabled ? label("Apri prima una sessione", "Open a session first", "请先打开会话") : label("Allega screenshot, immagine o foto", "Attach screenshot, image, or photo", "附加截图、图片或照片");
1326
+ setMediaActionsDisabled(mediaDisabled, mediaTitle);
1327
+ const commandMenu = composerCard?.querySelector("[data-trigger-menu]") ?? null;
1328
+ if (commandMenu !== null && (mediaActions.parentElement !== commandMenu || commandMenu.firstElementChild !== mediaActions)) commandMenu.prepend(mediaActions);
1329
+ }
336
1330
  if (composerTrailing !== void 0 && composerTrailing !== composerTools) {
337
1331
  composerTrailing.dataset.dshMobileComposerTrailing = "true";
338
- const modelTrigger = composerTrailing.querySelector("button[aria-label^=\"选择模型\"],button[aria-label^=\"Select model\"]");
1332
+ const modelTrigger = composerTrailing.querySelector("button[aria-label^=\"选择模型\"],button[aria-label^=\"Select model\"],button[aria-label^=\"Seleziona modello\"]");
339
1333
  if (modelTrigger !== null) {
340
1334
  modelTrigger.dataset.dshMobileComposerModelTrigger = "true";
341
1335
  modelTrigger.parentElement?.setAttribute("data-dsh-mobile-composer-model", "true");
@@ -392,7 +1386,11 @@ window.__ModuleLoader__.load({
392
1386
  attributeFilter: [
393
1387
  "class",
394
1388
  "style",
395
- "disabled"
1389
+ "disabled",
1390
+ "readonly",
1391
+ "aria-busy",
1392
+ "aria-selected",
1393
+ "data-dsh-mobile-session"
396
1394
  ]
397
1395
  });
398
1396
  backdrop.addEventListener("click", () => {
@@ -400,10 +1398,22 @@ window.__ModuleLoader__.load({
400
1398
  });
401
1399
  sync();
402
1400
  return () => {
1401
+ disposed = true;
1402
+ mediaRequestGeneration += 1;
1403
+ mediaPickerAbortController.abort();
1404
+ restoreLanguageMarker();
1405
+ for (const cleanup of [...browserPickerCleanups]) cleanup();
403
1406
  observer.disconnect();
404
1407
  document.removeEventListener("click", onBranchClick, true);
1408
+ fileButton.removeEventListener("pointerdown", quietMediaPointer);
1409
+ cameraButton.removeEventListener("pointerdown", quietMediaPointer);
1410
+ fileButton.removeEventListener("click", chooseImage);
1411
+ cameraButton.removeEventListener("click", takePhoto);
405
1412
  if (branchToastTimer !== 0) window.clearTimeout(branchToastTimer);
1413
+ if (mediaToastTimer !== 0) window.clearTimeout(mediaToastTimer);
406
1414
  branchToast.remove();
1415
+ mediaToast.remove();
1416
+ mediaActions.remove();
407
1417
  if (scheduled !== 0) cancelAnimationFrame(scheduled);
408
1418
  if (transitionFrame !== 0) cancelAnimationFrame(transitionFrame);
409
1419
  if (transitionRestartFrame !== 0) cancelAnimationFrame(transitionRestartFrame);
@@ -433,6 +1443,95 @@ window.__ModuleLoader__.load({
433
1443
  function isLoopbackHost(hostname) {
434
1444
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
435
1445
  }
1446
+ function selectMobileControlLocale(documentLanguage = "", navigatorLanguages = []) {
1447
+ for (const value of [documentLanguage, ...navigatorLanguages]) {
1448
+ const language = value.trim().toLowerCase().split(/[-_]/u)[0];
1449
+ if (language === "it" || language === "en" || language === "zh") return language;
1450
+ }
1451
+ return "en";
1452
+ }
1453
+ function selectedMobileControlLocale() {
1454
+ return selectMobileControlLocale(document.documentElement.lang, navigator.languages?.length ? navigator.languages : [navigator.language]);
1455
+ }
1456
+ /** Remount one plugin-owned surface when DSH changes the document language. */
1457
+ function installDshLanguageBoundSurface(install) {
1458
+ let locale = selectedMobileControlLocale();
1459
+ let dispose = install();
1460
+ const observer = new MutationObserver(() => {
1461
+ const next = selectedMobileControlLocale();
1462
+ if (next === locale) return;
1463
+ dispose();
1464
+ locale = next;
1465
+ dispose = install();
1466
+ });
1467
+ observer.observe(document.documentElement, {
1468
+ attributes: true,
1469
+ attributeFilter: ["lang"]
1470
+ });
1471
+ return () => {
1472
+ observer.disconnect();
1473
+ dispose();
1474
+ };
1475
+ }
1476
+ function controlTranslator(locale = selectedMobileControlLocale()) {
1477
+ const messages = MOBILE_CONTROL_MESSAGES;
1478
+ return (key, values = {}) => {
1479
+ return (messages[locale][key] ?? messages.en[key] ?? key).replace(/\{(\w+)\}/gu, (_match, name) => String(values[name] ?? `{${name}}`));
1480
+ };
1481
+ }
1482
+ function normalizeDiagnosticOverall(value) {
1483
+ return value === "ok" ? "ok" : value === "attention" ? "attention" : "error";
1484
+ }
1485
+ function normalizeDiagnosticStatus(value) {
1486
+ return value === "ok" || value === "warning" || value === "error" || value === "info" ? value : "error";
1487
+ }
1488
+ function diagnosticOverallForChecks(value, statuses) {
1489
+ const normalized = statuses.map(normalizeDiagnosticStatus);
1490
+ const payloadOverall = normalizeDiagnosticOverall(value);
1491
+ if (payloadOverall === "error" || normalized.includes("error")) return "error";
1492
+ if (payloadOverall === "attention" || normalized.includes("warning")) return "attention";
1493
+ return "ok";
1494
+ }
1495
+ function validateDiagnosticChecks(value) {
1496
+ if (!Array.isArray(value)) return {
1497
+ entries: [],
1498
+ malformed: true
1499
+ };
1500
+ const entries = [];
1501
+ let malformed = false;
1502
+ for (const candidate of value) {
1503
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
1504
+ malformed = true;
1505
+ continue;
1506
+ }
1507
+ entries.push(candidate);
1508
+ }
1509
+ return {
1510
+ entries,
1511
+ malformed
1512
+ };
1513
+ }
1514
+ function diagnosticEntriesForRender(data) {
1515
+ const overallKnown = data.overall === "ok" || data.overall === "attention" || data.overall === "error";
1516
+ const validated = validateDiagnosticChecks(data.checks);
1517
+ const statusesKnown = validated.entries.every((entry) => entry.status === "ok" || entry.status === "warning" || entry.status === "error" || entry.status === "info");
1518
+ if (!overallKnown || validated.malformed || validated.entries.length === 0 || !statusesKnown) throw new TypeError("diagnostics envelope is unavailable");
1519
+ return validated.entries;
1520
+ }
1521
+ function renderDiagnosticPayloadSafely(data, render, onFailure) {
1522
+ try {
1523
+ render(data);
1524
+ } catch (error) {
1525
+ onFailure(error);
1526
+ }
1527
+ }
1528
+ function diagnosticServerCopy(entry) {
1529
+ return {
1530
+ label: typeof entry.label === "string" ? entry.label : "",
1531
+ detail: typeof entry.detail === "string" ? entry.detail : "",
1532
+ action: typeof entry.action === "string" ? entry.action : ""
1533
+ };
1534
+ }
436
1535
  /**
437
1536
  * Match DSH's client-side privilege hint to the authenticated mobile gateway.
438
1537
  * The gateway authenticates the paired device and forwards allowed requests to
@@ -477,7 +1576,7 @@ window.__ModuleLoader__.load({
477
1576
  if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
478
1577
  return body;
479
1578
  } catch (error) {
480
- if (controller.signal.aborted && upstreamSignal?.aborted !== true) throw new Error("操作超时,请确认 DSH 仍在运行后重试。");
1579
+ if (controller.signal.aborted && upstreamSignal?.aborted !== true) throw new Error(controlTranslator()("requestTimeout"));
481
1580
  throw error;
482
1581
  } finally {
483
1582
  clearTimeout(timer);
@@ -498,37 +1597,47 @@ window.__ModuleLoader__.load({
498
1597
  return url.toString();
499
1598
  }
500
1599
  function installControl() {
1600
+ const locale = selectedMobileControlLocale();
1601
+ const localeTag = locale === "it" ? "it-IT" : locale === "zh" ? "zh-CN" : "en-US";
1602
+ const t = controlTranslator(locale);
1603
+ const lifecycle = new AbortController();
1604
+ const controlRequestJson = (path, init, timeoutMs) => requestJson(path, {
1605
+ ...init,
1606
+ signal: lifecycle.signal
1607
+ }, timeoutMs);
501
1608
  const root = element("div", "dsh-mobile-control");
1609
+ root.lang = locale;
502
1610
  const panel = element("section", "dsh-mobile-control__panel");
503
1611
  panel.hidden = true;
504
- panel.setAttribute("aria-label", "移动访问");
1612
+ panel.lang = locale;
1613
+ panel.setAttribute("aria-label", t("mobileAccess"));
505
1614
  const header = element("header", "dsh-mobile-control__header");
506
1615
  const title = element("h2");
507
- title.textContent = "移动访问";
1616
+ title.textContent = t("mobileAccess");
508
1617
  const headerActions = element("div", "dsh-mobile-control__header-actions");
509
1618
  const diagnosticsEntry = element("button", "dsh-mobile-control__diagnostic-entry");
510
1619
  diagnosticsEntry.type = "button";
511
- diagnosticsEntry.textContent = "诊断";
512
- diagnosticsEntry.setAttribute("aria-label", "打开连接诊断");
1620
+ diagnosticsEntry.textContent = t("diagnostics");
1621
+ diagnosticsEntry.setAttribute("aria-label", t("openDiagnostics"));
513
1622
  diagnosticsEntry.setAttribute("aria-pressed", "false");
514
1623
  const close = element("button", "dsh-mobile-control__close");
515
1624
  close.type = "button";
516
1625
  close.textContent = "×";
517
- close.setAttribute("aria-label", "收起移动访问");
1626
+ close.setAttribute("aria-label", t("collapseMobileAccess"));
518
1627
  headerActions.append(diagnosticsEntry, close);
519
1628
  const appDownload = element("a", "dsh-mobile-control__app-download");
520
1629
  appDownload.href = "https://github.com/saya-ch/dsh-mobile/releases/latest";
521
1630
  appDownload.target = "_blank";
522
1631
  appDownload.rel = "noopener noreferrer";
523
- appDownload.textContent = "下载 Android App";
524
- appDownload.setAttribute("aria-label", "前往 GitHub Releases 下载最新版 Android App");
1632
+ appDownload.textContent = t("downloadAndroid");
1633
+ appDownload.setAttribute("aria-label", t("downloadAndroidAria"));
525
1634
  const switcher = element("div", "dsh-mobile-control__switcher");
526
1635
  const lanTab = element("button", "dsh-mobile-control__tab is-active");
527
1636
  lanTab.type = "button";
528
- lanTab.textContent = "局域网";
1637
+ lanTab.textContent = t("lan");
529
1638
  const remoteTab = element("button", "dsh-mobile-control__tab");
530
1639
  remoteTab.type = "button";
531
- remoteTab.textContent = "远程";
1640
+ remoteTab.textContent = t("remote");
532
1641
  lanTab.setAttribute("aria-pressed", "true");
533
1642
  remoteTab.setAttribute("aria-pressed", "false");
534
1643
  switcher.append(lanTab, remoteTab);
@@ -536,7 +1645,7 @@ window.__ModuleLoader__.load({
536
1645
  const access = element("div", "dsh-mobile-control__access");
537
1646
  access.hidden = true;
538
1647
  const accessLabel = element("span", "dsh-mobile-control__access-label");
539
- accessLabel.textContent = "浏览器访问";
1648
+ accessLabel.textContent = t("browserAccess");
540
1649
  const accessLink = element("a", "dsh-mobile-control__access-link");
541
1650
  accessLink.target = "_blank";
542
1651
  accessLink.rel = "noreferrer";
@@ -544,7 +1653,7 @@ window.__ModuleLoader__.load({
544
1653
  const qrBox = element("div", "dsh-mobile-control__qr");
545
1654
  qrBox.hidden = true;
546
1655
  const status = element("p", "dsh-mobile-control__status");
547
- status.textContent = "正在读取状态…";
1656
+ status.textContent = t("loadingStatus");
548
1657
  const extensionStatus = element("p", "dsh-mobile-control__extensions");
549
1658
  extensionStatus.hidden = true;
550
1659
  const actions = element("div", "dsh-mobile-control__actions");
@@ -552,31 +1661,31 @@ window.__ModuleLoader__.load({
552
1661
  toggle.type = "button";
553
1662
  const pair = element("button", "dsh-mobile-control__primary");
554
1663
  pair.type = "button";
555
- pair.textContent = "生成并复制密钥";
1664
+ pair.textContent = t("generateCopyKey");
556
1665
  const linkPair = element("button", "dsh-mobile-control__secondary");
557
1666
  linkPair.type = "button";
558
- linkPair.textContent = "复制配对链接";
1667
+ linkPair.textContent = t("copyPairLink");
559
1668
  const manageRow = element("div", "dsh-mobile-control__manage-row");
560
1669
  const manageDevices = element("button", "dsh-mobile-control__manage");
561
1670
  manageDevices.type = "button";
562
- manageDevices.textContent = "管理配对设备";
1671
+ manageDevices.textContent = t("managePairedDevices");
563
1672
  const resetAll = element("button", "dsh-mobile-control__manage");
564
1673
  resetAll.type = "button";
565
- resetAll.textContent = "清除所有设备";
1674
+ resetAll.textContent = t("clearAllDevices");
566
1675
  manageRow.append(manageDevices, resetAll);
567
1676
  const devicePanel = element("div", "dsh-mobile-control__devices");
568
1677
  devicePanel.hidden = true;
569
1678
  const remoteView = element("div", "dsh-mobile-control__view is-remote");
570
1679
  remoteView.hidden = true;
571
1680
  const remoteIntro = element("p", "dsh-mobile-control__intro");
572
- remoteIntro.textContent = "选择更适合你的远程通道。切换或关闭远程访问不会影响局域网。";
1681
+ remoteIntro.textContent = t("remoteIntro");
573
1682
  const providerSection = element("section", "dsh-mobile-control__provider-section");
574
1683
  const providerHeading = element("h3", "dsh-mobile-control__section-title");
575
- providerHeading.textContent = "选择连接方式";
1684
+ providerHeading.textContent = t("chooseProvider");
576
1685
  const providerInfo = element("div", "dsh-mobile-control__provider-info");
577
1686
  const providerInfoButton = element("button", "dsh-mobile-control__provider-info-button");
578
1687
  providerInfoButton.type = "button";
579
- providerInfoButton.setAttribute("aria-label", "查看远程连接安全与网络说明");
1688
+ providerInfoButton.setAttribute("aria-label", t("providerInfoAria"));
580
1689
  providerInfoButton.setAttribute("aria-expanded", "false");
581
1690
  providerInfoButton.setAttribute("aria-controls", "dsh-mobile-provider-info");
582
1691
  providerInfoButton.setAttribute("aria-describedby", "dsh-mobile-provider-info");
@@ -588,15 +1697,15 @@ window.__ModuleLoader__.load({
588
1697
  providerInfoPopover.setAttribute("role", "tooltip");
589
1698
  providerInfoPopover.hidden = true;
590
1699
  const providerInfoTitle = element("strong");
591
- providerInfoTitle.textContent = "你始终可以放心";
1700
+ providerInfoTitle.textContent = t("providerSafeTitle");
592
1701
  const providerInfoText = element("span");
593
- providerInfoText.textContent = "只有已配对设备能进入 DSH。cpolar 按需安装并可彻底清理;Tailscale 在中国大陆网络下可能连接缓慢、中断或无法使用,国内网络建议优先尝试 cpolar。";
1702
+ providerInfoText.textContent = t("providerSafeText");
594
1703
  providerInfoButton.append(providerInfoGlyph);
595
1704
  providerInfoPopover.append(providerInfoTitle, providerInfoText);
596
1705
  providerInfo.append(providerInfoButton, providerInfoPopover);
597
1706
  const providerChoices = element("div", "dsh-mobile-control__provider-choices");
598
1707
  providerChoices.setAttribute("role", "radiogroup");
599
- providerChoices.setAttribute("aria-label", "远程连接方式");
1708
+ providerChoices.setAttribute("aria-label", t("providerGroupAria"));
600
1709
  const tailscaleChoice = element("button", "dsh-mobile-control__provider");
601
1710
  tailscaleChoice.type = "button";
602
1711
  tailscaleChoice.setAttribute("role", "radio");
@@ -605,9 +1714,9 @@ window.__ModuleLoader__.load({
605
1714
  const tailscaleChoiceName = element("strong");
606
1715
  tailscaleChoiceName.textContent = "Tailscale Funnel";
607
1716
  const tailscaleChoiceBadge = element("span", "dsh-mobile-control__provider-badge");
608
- tailscaleChoiceBadge.textContent = "内置";
1717
+ tailscaleChoiceBadge.textContent = t("builtIn");
609
1718
  const tailscaleChoiceDescription = element("span", "dsh-mobile-control__provider-description");
610
- tailscaleChoiceDescription.textContent = "覆盖更广;中国大陆网络可能不稳定,首次需登录并允许 Funnel。";
1719
+ tailscaleChoiceDescription.textContent = t("tailscaleDescription");
611
1720
  tailscaleChoiceTop.append(tailscaleChoiceName, tailscaleChoiceBadge);
612
1721
  tailscaleChoice.append(tailscaleChoiceTop, tailscaleChoiceDescription);
613
1722
  const cpolarChoice = element("button", "dsh-mobile-control__provider");
@@ -618,9 +1727,9 @@ window.__ModuleLoader__.load({
618
1727
  const cpolarChoiceName = element("strong");
619
1728
  cpolarChoiceName.textContent = "cpolar";
620
1729
  const cpolarChoiceBadge = element("span", "dsh-mobile-control__provider-badge is-cpolar");
621
- cpolarChoiceBadge.textContent = "国内网络优先";
1730
+ cpolarChoiceBadge.textContent = t("mainlandPreferred");
622
1731
  const cpolarChoiceDescription = element("span", "dsh-mobile-control__provider-description");
623
- cpolarChoiceDescription.textContent = "按需安装官方组件,适合国内网络环境。";
1732
+ cpolarChoiceDescription.textContent = t("cpolarDescription");
624
1733
  cpolarChoiceTop.append(cpolarChoiceName, cpolarChoiceBadge);
625
1734
  cpolarChoice.append(cpolarChoiceTop, cpolarChoiceDescription);
626
1735
  providerChoices.append(cpolarChoice, tailscaleChoice);
@@ -628,27 +1737,27 @@ window.__ModuleLoader__.load({
628
1737
  const cpolarSetup = element("section", "dsh-mobile-control__cpolar-setup");
629
1738
  cpolarSetup.hidden = true;
630
1739
  const cpolarSetupTitle = element("h3", "dsh-mobile-control__section-title");
631
- cpolarSetupTitle.textContent = "准备 cpolar";
1740
+ cpolarSetupTitle.textContent = t("prepareCpolar");
632
1741
  const cpolarComponentStatus = element("p", "dsh-mobile-control__component-status");
633
- cpolarComponentStatus.textContent = "正在检查组件…";
1742
+ cpolarComponentStatus.textContent = t("checkingComponent");
634
1743
  const cpolarInstall = element("button", "dsh-mobile-control__primary");
635
1744
  cpolarInstall.type = "button";
636
- cpolarInstall.textContent = "安装官方组件";
1745
+ cpolarInstall.textContent = t("installOfficial");
637
1746
  const cpolarAccount = element("div", "dsh-mobile-control__cpolar-account");
638
1747
  cpolarAccount.hidden = true;
639
1748
  const cpolarAccountText = element("p", "dsh-mobile-control__component-note");
640
- cpolarAccountText.textContent = "登录 cpolar 官网后复制 Authtoken。令牌只保存在本机插件私有目录,不会显示在页面或日志中。";
1749
+ cpolarAccountText.textContent = t("cpolarAccountNote");
641
1750
  const cpolarAccountLinks = element("div", "dsh-mobile-control__link-row");
642
1751
  const cpolarSignup = element("a", "dsh-mobile-control__text-link");
643
1752
  cpolarSignup.href = "https://dashboard.cpolar.com/signup";
644
1753
  cpolarSignup.target = "_blank";
645
1754
  cpolarSignup.rel = "noopener noreferrer";
646
- cpolarSignup.textContent = "注册 cpolar";
1755
+ cpolarSignup.textContent = t("registerCpolar");
647
1756
  const cpolarDashboard = element("a", "dsh-mobile-control__text-link");
648
1757
  cpolarDashboard.href = "https://dashboard.cpolar.com/auth";
649
1758
  cpolarDashboard.target = "_blank";
650
1759
  cpolarDashboard.rel = "noopener noreferrer";
651
- cpolarDashboard.textContent = "打开控制台获取令牌";
1760
+ cpolarDashboard.textContent = t("openDashboard");
652
1761
  cpolarAccountLinks.append(cpolarSignup, cpolarDashboard);
653
1762
  const cpolarTokenLabel = element("label", "dsh-mobile-control__token-label");
654
1763
  cpolarTokenLabel.textContent = "Authtoken";
@@ -656,48 +1765,48 @@ window.__ModuleLoader__.load({
656
1765
  cpolarToken.type = "password";
657
1766
  cpolarToken.autocomplete = "off";
658
1767
  cpolarToken.spellcheck = false;
659
- cpolarToken.placeholder = "粘贴 cpolar Authtoken";
1768
+ cpolarToken.placeholder = t("tokenPlaceholder");
660
1769
  cpolarTokenLabel.append(cpolarToken);
661
1770
  const cpolarConfigure = element("button", "dsh-mobile-control__primary dsh-mobile-control__cpolar-connect");
662
1771
  cpolarConfigure.type = "button";
663
- cpolarConfigure.textContent = "保存并连接";
1772
+ cpolarConfigure.textContent = t("saveConnect");
664
1773
  cpolarAccount.append(cpolarAccountText, cpolarAccountLinks, cpolarTokenLabel, cpolarConfigure);
665
1774
  const cpolarDetails = element("details", "dsh-mobile-control__details");
666
1775
  const cpolarDetailsSummary = element("summary");
667
- cpolarDetailsSummary.textContent = "组件来源与清理说明";
1776
+ cpolarDetailsSummary.textContent = t("componentDetails");
668
1777
  const cpolarDetailsBody = element("div", "dsh-mobile-control__details-body");
669
1778
  const cpolarDetailsText = element("p");
670
- cpolarDetailsText.textContent = "仅在你点击安装后从 cpolar 官网下载并校验固定版本。不会写入系统服务、开机启动、注册表或 PATH。";
1779
+ cpolarDetailsText.textContent = t("componentDetailsText");
671
1780
  const cpolarStorage = element("code", "dsh-mobile-control__storage");
672
- cpolarStorage.textContent = "插件私有目录";
1781
+ cpolarStorage.textContent = t("pluginPrivateDirectory");
673
1782
  const cpolarOfficial = element("a", "dsh-mobile-control__text-link");
674
1783
  cpolarOfficial.href = "https://www.cpolar.com/download";
675
1784
  cpolarOfficial.target = "_blank";
676
1785
  cpolarOfficial.rel = "noopener noreferrer";
677
- cpolarOfficial.textContent = "官方下载安装页";
1786
+ cpolarOfficial.textContent = t("officialDownload");
678
1787
  const cpolarTerms = element("a", "dsh-mobile-control__text-link");
679
1788
  cpolarTerms.href = "https://www.cpolar.com/tos";
680
1789
  cpolarTerms.target = "_blank";
681
1790
  cpolarTerms.rel = "noopener noreferrer";
682
- cpolarTerms.textContent = "服务条款";
1791
+ cpolarTerms.textContent = t("terms");
683
1792
  const cpolarPurge = element("button", "dsh-mobile-control__danger");
684
1793
  cpolarPurge.type = "button";
685
- cpolarPurge.textContent = "彻底移除 cpolar 组件与配置";
1794
+ cpolarPurge.textContent = t("purgeCpolar");
686
1795
  cpolarDetailsBody.append(cpolarDetailsText, cpolarStorage, cpolarOfficial, cpolarTerms, cpolarPurge);
687
1796
  cpolarDetails.append(cpolarDetailsSummary, cpolarDetailsBody);
688
1797
  cpolarSetup.append(cpolarSetupTitle, cpolarComponentStatus, cpolarInstall, cpolarAccount, cpolarDetails);
689
1798
  const tailscaleInfo = element("details", "dsh-mobile-control__details");
690
1799
  const tailscaleInfoSummary = element("summary");
691
- tailscaleInfoSummary.textContent = "Tailscale 使用说明";
1800
+ tailscaleInfoSummary.textContent = t("tailscaleHelp");
692
1801
  const tailscaleInfoBody = element("div", "dsh-mobile-control__details-body");
693
1802
  const tailscaleInfoText = element("p");
694
- tailscaleInfoText.textContent = "运行组件已随插件提供。首次连接会打开 Tailscale 官方登录和 Funnel 授权页;插件不会接触你的账号密码。";
1803
+ tailscaleInfoText.textContent = t("tailscaleHelpText");
695
1804
  tailscaleInfoBody.append(tailscaleInfoText);
696
1805
  tailscaleInfo.append(tailscaleInfoSummary, tailscaleInfoBody);
697
1806
  const remoteAccess = element("div", "dsh-mobile-control__access");
698
1807
  remoteAccess.hidden = true;
699
1808
  const remoteAccessLabel = element("span", "dsh-mobile-control__access-label");
700
- remoteAccessLabel.textContent = "远程地址";
1809
+ remoteAccessLabel.textContent = t("remoteAddress");
701
1810
  const remoteAccessLink = element("a", "dsh-mobile-control__access-link");
702
1811
  remoteAccessLink.target = "_blank";
703
1812
  remoteAccessLink.rel = "noreferrer";
@@ -705,68 +1814,68 @@ window.__ModuleLoader__.load({
705
1814
  const remoteQr = element("div", "dsh-mobile-control__qr");
706
1815
  remoteQr.hidden = true;
707
1816
  const remoteStatus = element("p", "dsh-mobile-control__status");
708
- remoteStatus.textContent = "正在读取远程状态…";
1817
+ remoteStatus.textContent = t("loadingRemoteStatus");
709
1818
  remoteStatus.setAttribute("aria-live", "polite");
710
1819
  const remoteGuide = element("section", "dsh-mobile-control__guide");
711
1820
  remoteGuide.hidden = true;
712
- remoteGuide.setAttribute("aria-label", "Tailscale Funnel 启用步骤");
1821
+ remoteGuide.setAttribute("aria-label", t("funnelGuideAria"));
713
1822
  const remoteGuideTitle = element("h3", "dsh-mobile-control__guide-title");
714
- remoteGuideTitle.textContent = "远程访问设置 · 第 2 步";
1823
+ remoteGuideTitle.textContent = t("funnelGuideTitle");
715
1824
  const remoteGuideSummary = element("p", "dsh-mobile-control__guide-summary");
716
- remoteGuideSummary.textContent = "Tailscale 登录已完成。还需为这台电脑允许 Funnel,官方页面会同时启用 HTTPS。";
1825
+ remoteGuideSummary.textContent = t("funnelGuideSummary");
717
1826
  const remoteGuideSteps = element("ol", "dsh-mobile-control__guide-steps");
718
1827
  for (const text of [
719
- "打开当前节点的 Tailscale 官方授权页。",
720
- "确认启用 Funnel;无需再次登录 DSH。",
721
- "返回 DSH,插件会自动检查并建立连接。"
1828
+ t("funnelStep1"),
1829
+ t("funnelStep2"),
1830
+ t("funnelStep3")
722
1831
  ]) {
723
1832
  const item = element("li");
724
1833
  item.textContent = text;
725
1834
  remoteGuideSteps.append(item);
726
1835
  }
727
1836
  const remoteGuideNote = element("p", "dsh-mobile-control__guide-note");
728
- remoteGuideNote.textContent = "需要使用 Owner、Admin 或 Network admin 账号。";
1837
+ remoteGuideNote.textContent = t("funnelGuideNote");
729
1838
  const remoteGuideActions = element("div", "dsh-mobile-control__guide-actions");
730
1839
  const remoteSetup = element("button", "dsh-mobile-control__primary");
731
1840
  remoteSetup.type = "button";
732
- remoteSetup.textContent = "继续完成 Funnel 授权";
1841
+ remoteSetup.textContent = t("continueFunnel");
733
1842
  const remoteSetupRetry = element("button", "dsh-mobile-control__secondary");
734
1843
  remoteSetupRetry.type = "button";
735
- remoteSetupRetry.textContent = "已完成,立即重试";
1844
+ remoteSetupRetry.textContent = t("retryNow");
736
1845
  remoteGuideActions.append(remoteSetup, remoteSetupRetry);
737
1846
  remoteGuide.append(remoteGuideTitle, remoteGuideSummary, remoteGuideSteps, remoteGuideNote, remoteGuideActions);
738
1847
  const remoteActions = element("div", "dsh-mobile-control__actions");
739
1848
  const remoteToggle = element("button", "dsh-mobile-control__primary");
740
1849
  remoteToggle.type = "button";
741
- remoteToggle.textContent = "启用远程访问";
1850
+ remoteToggle.textContent = t("enableRemote");
742
1851
  const remoteLogin = element("button", "dsh-mobile-control__primary");
743
1852
  remoteLogin.type = "button";
744
- remoteLogin.textContent = "继续登录";
1853
+ remoteLogin.textContent = t("continueLogin");
745
1854
  remoteLogin.hidden = true;
746
1855
  const remoteReconnect = element("button", "dsh-mobile-control__secondary");
747
1856
  remoteReconnect.type = "button";
748
- remoteReconnect.textContent = "重新连接";
1857
+ remoteReconnect.textContent = t("reconnect");
749
1858
  remoteReconnect.hidden = true;
750
1859
  const remotePair = element("button", "dsh-mobile-control__secondary");
751
1860
  remotePair.type = "button";
752
- remotePair.textContent = "生成远程配对二维码";
1861
+ remotePair.textContent = t("generateRemoteQr");
753
1862
  remotePair.disabled = true;
754
1863
  remoteActions.append(remoteToggle, remoteLogin, remoteReconnect, remotePair);
755
1864
  const remoteManageRow = element("div", "dsh-mobile-control__manage-row");
756
1865
  const remoteDevices = element("button", "dsh-mobile-control__manage");
757
1866
  remoteDevices.type = "button";
758
- remoteDevices.textContent = "管理远程设备";
1867
+ remoteDevices.textContent = t("manageRemoteDevices");
759
1868
  remoteDevices.disabled = true;
760
1869
  const remoteReset = element("button", "dsh-mobile-control__manage");
761
1870
  remoteReset.type = "button";
762
- remoteReset.textContent = "退出并清除远程登录";
1871
+ remoteReset.textContent = t("resetRemoteLogin");
763
1872
  remoteManageRow.append(remoteDevices, remoteReset);
764
1873
  const remoteDevicePanel = element("div", "dsh-mobile-control__devices");
765
1874
  remoteDevicePanel.hidden = true;
766
1875
  const diagnosticsView = element("div", "dsh-mobile-control__view is-diagnostics");
767
1876
  diagnosticsView.hidden = true;
768
1877
  const diagnosticsIntro = element("p", "dsh-mobile-control__intro");
769
- diagnosticsIntro.textContent = "检查版本、网关、网卡、防火墙和远程通道。报告自动脱敏,不读取对话或凭据。";
1878
+ diagnosticsIntro.textContent = t("diagnosticsIntro");
770
1879
  const diagnosticsSummary = element("section", "dsh-mobile-control__diagnostic-summary is-idle");
771
1880
  diagnosticsSummary.setAttribute("aria-live", "polite");
772
1881
  const diagnosticsSummaryMain = element("div", "dsh-mobile-control__diagnostic-summary-main");
@@ -774,21 +1883,21 @@ window.__ModuleLoader__.load({
774
1883
  diagnosticsSummaryIcon.setAttribute("aria-hidden", "true");
775
1884
  const diagnosticsSummaryBody = element("div", "dsh-mobile-control__diagnostic-summary-body");
776
1885
  const diagnosticsSummaryTitle = element("strong");
777
- diagnosticsSummaryTitle.textContent = "尚未检查";
1886
+ diagnosticsSummaryTitle.textContent = t("diagnosticsNotRun");
778
1887
  const diagnosticsSummaryText = element("span");
779
- diagnosticsSummaryText.textContent = "点击下方按钮开始。";
1888
+ diagnosticsSummaryText.textContent = t("diagnosticsStartHint");
780
1889
  const diagnosticsSummaryMeta = element("span", "dsh-mobile-control__diagnostic-summary-meta");
781
- diagnosticsSummaryMeta.textContent = "等待运行 · 仅收集连接状态";
1890
+ diagnosticsSummaryMeta.textContent = t("diagnosticsIdleMeta");
782
1891
  diagnosticsSummaryBody.append(diagnosticsSummaryTitle, diagnosticsSummaryText);
783
1892
  diagnosticsSummaryMain.append(diagnosticsSummaryIcon, diagnosticsSummaryBody);
784
1893
  diagnosticsSummary.append(diagnosticsSummaryMain, diagnosticsSummaryMeta);
785
1894
  const diagnosticsToolbar = element("div", "dsh-mobile-control__diagnostic-toolbar");
786
1895
  const diagnosticsRun = element("button", "dsh-mobile-control__primary dsh-mobile-control__diagnostic-run");
787
1896
  diagnosticsRun.type = "button";
788
- diagnosticsRun.textContent = "开始检查";
1897
+ diagnosticsRun.textContent = t("diagnosticsStart");
789
1898
  const diagnosticsCopy = element("button", "dsh-mobile-control__secondary dsh-mobile-control__diagnostic-copy");
790
1899
  diagnosticsCopy.type = "button";
791
- diagnosticsCopy.textContent = "复制脱敏报告";
1900
+ diagnosticsCopy.textContent = t("diagnosticsCopy");
792
1901
  diagnosticsCopy.disabled = true;
793
1902
  diagnosticsCopy.hidden = true;
794
1903
  diagnosticsToolbar.append(diagnosticsRun, diagnosticsCopy);
@@ -801,7 +1910,7 @@ window.__ModuleLoader__.load({
801
1910
  const diagnosticsDetails = element("details", "dsh-mobile-control__details dsh-mobile-control__diagnostic-details");
802
1911
  diagnosticsDetails.hidden = true;
803
1912
  const diagnosticsDetailsSummary = element("summary");
804
- diagnosticsDetailsSummary.textContent = "高级诊断详情";
1913
+ diagnosticsDetailsSummary.textContent = t("diagnosticsAdvanced");
805
1914
  const diagnosticsReport = element("pre", "dsh-mobile-control__diagnostic-report");
806
1915
  diagnosticsDetails.append(diagnosticsDetailsSummary, diagnosticsReport);
807
1916
  header.append(title, headerActions);
@@ -868,11 +1977,11 @@ window.__ModuleLoader__.load({
868
1977
  lanTab.setAttribute("aria-pressed", String(view === "lan"));
869
1978
  remoteTab.setAttribute("aria-pressed", String(view === "remote"));
870
1979
  diagnosticsEntry.setAttribute("aria-pressed", String(view === "diagnostics"));
871
- diagnosticsEntry.textContent = view === "diagnostics" ? "返回" : "诊断";
872
- diagnosticsEntry.setAttribute("aria-label", view === "diagnostics" ? "返回移动访问" : "打开连接诊断");
1980
+ diagnosticsEntry.textContent = view === "diagnostics" ? t("back") : t("diagnostics");
1981
+ diagnosticsEntry.setAttribute("aria-label", view === "diagnostics" ? t("backToMobile") : t("openDiagnostics"));
873
1982
  appDownload.hidden = view === "diagnostics";
874
1983
  switcher.hidden = view === "diagnostics";
875
- title.textContent = view === "lan" ? "局域网访问" : view === "remote" ? "远程访问" : "连接诊断";
1984
+ title.textContent = view === "lan" ? t("lanAccess") : view === "remote" ? t("remoteAccess") : t("connectionDiagnostics");
876
1985
  };
877
1986
  lanTab.addEventListener("click", () => {
878
1987
  selectView("lan");
@@ -893,16 +2002,19 @@ window.__ModuleLoader__.load({
893
2002
  accessLink.textContent = origin;
894
2003
  accessLink.title = origin;
895
2004
  status.classList.toggle("is-running", running);
896
- status.textContent = running ? "局域网访问已开启。" : "局域网访问已关闭。";
2005
+ status.textContent = running ? t("lanOn") : t("lanOff");
897
2006
  const extensionData = data.extensions;
898
2007
  if (extensionData !== null && typeof extensionData === "object") {
899
2008
  const loaded = typeof extensionData.loaded === "number" ? extensionData.loaded : 0;
900
2009
  const failed = typeof extensionData.failed === "number" ? extensionData.failed : 0;
901
2010
  extensionStatus.hidden = false;
902
- extensionStatus.textContent = failed === 0 ? `扩展:${String(loaded)} 个已加载` : `扩展:${String(loaded)} 个已加载,${String(failed)} 个加载失败`;
2011
+ extensionStatus.textContent = failed === 0 ? t("extensionsLoaded", { loaded }) : t("extensionsFailed", {
2012
+ loaded,
2013
+ failed
2014
+ });
903
2015
  } else extensionStatus.hidden = true;
904
2016
  if (!running) qrBox.hidden = true;
905
- toggle.textContent = running ? "关闭局域网访问" : "开启局域网访问";
2017
+ toggle.textContent = running ? t("disableLan") : t("enableLan");
906
2018
  pair.disabled = !running;
907
2019
  linkPair.disabled = !running;
908
2020
  manageDevices.disabled = !running;
@@ -915,7 +2027,7 @@ window.__ModuleLoader__.load({
915
2027
  return;
916
2028
  }
917
2029
  const image = element("img");
918
- image.alt = "配对二维码";
2030
+ image.alt = t("pairingQr");
919
2031
  image.width = 176;
920
2032
  image.height = 176;
921
2033
  image.src = `data:image/svg+xml;base64,${btoa(svg)}`;
@@ -923,25 +2035,28 @@ window.__ModuleLoader__.load({
923
2035
  target.append(image);
924
2036
  };
925
2037
  const openPairing = (target) => {
926
- requestJson("/api/mobile-access/lan/pairing/open", {
2038
+ controlRequestJson("/api/mobile-access/lan/pairing/open", {
927
2039
  method: "POST",
928
2040
  body: "{}"
929
2041
  }).then(async (data) => {
930
2042
  const value = target === "key" ? typeof data.appKey === "string" ? data.appKey : "" : typeof data.pairUrl === "string" ? data.pairUrl : "";
931
2043
  showQr(typeof data.qrSvg === "string" ? data.qrSvg : "");
932
2044
  if (value === "") {
933
- status.textContent = "无法生成配对密钥。";
2045
+ status.textContent = t("keyGenerationFailed");
934
2046
  return;
935
2047
  }
936
2048
  try {
937
2049
  await navigator.clipboard.writeText(value);
938
- status.textContent = target === "key" ? "配对密钥已复制,请粘贴到 Android App。" : "配对链接已复制,发给手机后 App 粘贴或浏览器打开即可配对。";
2050
+ status.textContent = target === "key" ? t("keyCopied") : t("linkCopied");
939
2051
  } catch {
940
- status.textContent = `请复制${target === "key" ? "配对密钥" : "配对链接"}:${value}`;
2052
+ status.textContent = t("copySecret", {
2053
+ kind: target === "key" ? t("pairingKey") : t("pairingLink"),
2054
+ value
2055
+ });
941
2056
  status.classList.add("is-key");
942
2057
  }
943
2058
  }, (error) => {
944
- status.textContent = String(error);
2059
+ status.textContent = t("requestFailed", { error: String(error) });
945
2060
  }).finally(() => {
946
2061
  pair.disabled = !running;
947
2062
  linkPair.disabled = !running;
@@ -949,41 +2064,45 @@ window.__ModuleLoader__.load({
949
2064
  };
950
2065
  toggle.addEventListener("click", () => {
951
2066
  toggle.disabled = true;
952
- requestJson("/api/mobile-access/lan/control", {
2067
+ controlRequestJson("/api/mobile-access/lan/control", {
953
2068
  method: "POST",
954
2069
  body: JSON.stringify({ running: !running })
955
2070
  }).then(render, (error) => {
956
- status.textContent = String(error);
2071
+ status.textContent = t("requestFailed", { error: String(error) });
957
2072
  }).finally(() => {
958
2073
  toggle.disabled = false;
959
2074
  });
960
2075
  });
961
- const formatTime = (ms) => typeof ms === "number" ? new Date(ms).toLocaleString() : "";
2076
+ const formatTime = (ms) => typeof ms === "number" ? new Date(ms).toLocaleString(localeTag) : "";
2077
+ const formatMegabytes = (bytes) => new Intl.NumberFormat(localeTag, {
2078
+ minimumFractionDigits: 1,
2079
+ maximumFractionDigits: 1
2080
+ }).format(bytes / 1024 / 1024);
962
2081
  const renderDevices = (data) => {
963
2082
  const devices = Array.isArray(data.devices) ? data.devices : [];
964
2083
  devicePanel.replaceChildren();
965
2084
  if (devices.length === 0) {
966
2085
  const empty = element("p", "dsh-mobile-control__device-empty");
967
- empty.textContent = "暂无配对设备。";
2086
+ empty.textContent = t("noDevices");
968
2087
  devicePanel.append(empty);
969
2088
  return;
970
2089
  }
971
2090
  for (const device of devices) {
972
2091
  const row = element("div", "dsh-mobile-control__device");
973
2092
  const label = element("span", "dsh-mobile-control__device-label");
974
- label.textContent = typeof device.label === "string" ? device.label : "设备";
2093
+ label.textContent = typeof device.label === "string" ? device.label : t("device");
975
2094
  const meta = element("span", "dsh-mobile-control__device-meta");
976
- meta.textContent = `到期 ${formatTime(device.expiresAt)}`;
2095
+ meta.textContent = t("expires", { time: formatTime(device.expiresAt) });
977
2096
  const revoke = element("button", "dsh-mobile-control__device-revoke");
978
2097
  revoke.type = "button";
979
- revoke.textContent = "撤销";
2098
+ revoke.textContent = t("revoke");
980
2099
  const id = typeof device.id === "string" ? device.id : "";
981
2100
  revoke.addEventListener("click", () => {
982
- requestJson("/api/mobile-access/lan/devices/revoke", {
2101
+ controlRequestJson("/api/mobile-access/lan/devices/revoke", {
983
2102
  method: "POST",
984
2103
  body: JSON.stringify({ deviceId: id })
985
2104
  }).then(loadDevices, (error) => {
986
- status.textContent = String(error);
2105
+ status.textContent = t("requestFailed", { error: String(error) });
987
2106
  });
988
2107
  });
989
2108
  row.append(label, meta, revoke);
@@ -991,8 +2110,8 @@ window.__ModuleLoader__.load({
991
2110
  }
992
2111
  };
993
2112
  const loadDevices = () => {
994
- requestJson("/api/mobile-access/lan/devices").then(renderDevices, (error) => {
995
- status.textContent = String(error);
2113
+ controlRequestJson("/api/mobile-access/lan/devices").then(renderDevices, (error) => {
2114
+ status.textContent = t("requestFailed", { error: String(error) });
996
2115
  });
997
2116
  };
998
2117
  manageDevices.addEventListener("click", () => {
@@ -1001,12 +2120,12 @@ window.__ModuleLoader__.load({
1001
2120
  if (show) loadDevices();
1002
2121
  });
1003
2122
  resetAll.addEventListener("click", () => {
1004
- if (!window.confirm("确定要移除所有配对设备吗?此操作会立即终止已连接设备。")) return;
1005
- requestJson("/api/mobile-access/lan/devices/reset", {
2123
+ if (!window.confirm(t("confirmResetDevices"))) return;
2124
+ controlRequestJson("/api/mobile-access/lan/devices/reset", {
1006
2125
  method: "POST",
1007
2126
  body: JSON.stringify({ confirm: true })
1008
2127
  }).then(loadDevices, (error) => {
1009
- status.textContent = String(error);
2128
+ status.textContent = t("requestFailed", { error: String(error) });
1010
2129
  });
1011
2130
  });
1012
2131
  const renderRemote = (data) => {
@@ -1021,26 +2140,26 @@ window.__ModuleLoader__.load({
1021
2140
  cpolarChoice.disabled = remoteProviderBusy;
1022
2141
  cpolarSetup.hidden = !cpolar;
1023
2142
  tailscaleInfo.hidden = cpolar;
1024
- remoteReset.textContent = cpolar ? "关闭并清除远程设备" : "退出并清除远程登录";
2143
+ remoteReset.textContent = cpolar ? t("resetRemoteDevices") : t("resetRemoteLogin");
1025
2144
  const providers = data.providers !== null && typeof data.providers === "object" ? data.providers : {};
1026
2145
  const cpolarProvider = providers.cpolar !== null && typeof providers.cpolar === "object" ? providers.cpolar : {};
1027
2146
  const component = cpolarProvider.component !== null && typeof cpolarProvider.component === "object" ? cpolarProvider.component : {};
1028
2147
  cpolarInstalled = component.installed === true;
1029
2148
  cpolarConfigured = component.configured === true;
1030
- cpolarChoiceBadge.textContent = cpolarConfigured ? "已就绪" : cpolarInstalled ? "已安装" : "国内网络优先";
2149
+ cpolarChoiceBadge.textContent = cpolarConfigured ? t("ready") : cpolarInstalled ? t("installed") : t("mainlandPreferred");
1031
2150
  const cpolarSupported = component.supported !== false;
1032
2151
  const componentVersion = typeof component.version === "string" ? component.version : "";
1033
2152
  const componentDownloadBytes = typeof component.downloadBytes === "number" ? component.downloadBytes : 0;
1034
- const componentStorage = typeof component.storagePath === "string" ? component.storagePath : "DSH Mobile 插件私有目录";
2153
+ const componentStorage = typeof component.storagePath === "string" ? component.storagePath : `DSH Mobile ${t("pluginPrivateDirectory")}`;
1035
2154
  cpolarStorage.textContent = componentStorage;
1036
2155
  cpolarStorage.title = componentStorage;
1037
2156
  cpolarInstall.hidden = cpolarInstalled || !cpolarSupported;
1038
- cpolarInstall.textContent = componentDownloadBytes > 0 ? `安装官方组件 · ${(componentDownloadBytes / 1024 / 1024).toFixed(1)} MB` : "安装官方组件";
2157
+ cpolarInstall.textContent = componentDownloadBytes > 0 ? t("installWithSize", { size: formatMegabytes(componentDownloadBytes) }) : t("installOfficial");
1039
2158
  cpolarInstall.disabled = remoteProviderBusy;
1040
2159
  cpolarAccount.hidden = !cpolarInstalled || cpolarConfigured;
1041
2160
  cpolarConfigure.disabled = remoteProviderBusy;
1042
2161
  cpolarPurge.hidden = !cpolarInstalled && !cpolarConfigured;
1043
- cpolarComponentStatus.textContent = !cpolarSupported ? "当前仅支持 Windows x64。你仍可选择内置的 Tailscale Funnel。" : !cpolarInstalled ? "尚未安装。只有点击下方按钮后,才会从 cpolar 官网下载固定版本。" : !cpolarConfigured ? `官方组件 ${componentVersion} 已校验,下一步只需保存账号令牌。` : `官方组件 ${componentVersion} 与本机账号配置已就绪。`;
2162
+ cpolarComponentStatus.textContent = !cpolarSupported ? t("cpolarUnsupported") : !cpolarInstalled ? t("cpolarNotInstalled") : !cpolarConfigured ? t("cpolarNeedsToken", { version: componentVersion }) : t("cpolarReady", { version: componentVersion });
1044
2163
  const state = typeof data.state === "string" ? data.state : "error";
1045
2164
  const errorCode = typeof data.errorCode === "string" ? data.errorCode : "";
1046
2165
  const remoteOrigin = typeof data.origin === "string" ? data.origin : "";
@@ -1059,38 +2178,39 @@ window.__ModuleLoader__.load({
1059
2178
  remoteAccessLink.title = remoteOrigin;
1060
2179
  remoteStatus.classList.toggle("is-running", remoteReady);
1061
2180
  const labels = {
1062
- off: "远程访问未启用。局域网访问不受影响。",
1063
- unavailable: cpolar ? "cpolar 尚未安装或未完成本机账号配置。" : "当前电脑缺少 Funnel 运行组件,请重新安装完整插件包。",
1064
- starting: cpolar ? "正在连接 cpolar 国内节点…" : "正在启动 Tailscale 安全通道…",
1065
- "needs-login": "需要在浏览器完成一次 Tailscale 登录。插件不会读取你的密码。",
1066
- connecting: cpolar ? "公网地址已分配,正在启动 DSH 认证网关…" : "登录完成,正在建立公开 HTTPS 地址…",
1067
- ready: "远程访问已就绪。只有已配对设备可以进入 DSH。",
1068
- error: "远程连接未建立。可重新连接,局域网访问仍可正常使用。"
2181
+ off: t("remoteOff"),
2182
+ unavailable: cpolar ? t("remoteUnavailableCpolar") : t("remoteUnavailableTailscale"),
2183
+ starting: cpolar ? t("remoteStartingCpolar") : t("remoteStartingTailscale"),
2184
+ "needs-login": t("remoteNeedsLogin"),
2185
+ connecting: cpolar ? t("remoteConnectingCpolar") : t("remoteConnectingTailscale"),
2186
+ ready: t("remoteReady"),
2187
+ error: t("remoteError")
1069
2188
  };
1070
- remoteStatus.textContent = remoteSetupPending && needsFunnelSetup ? "Tailscale 官方页面已打开。完成启用后返回 DSH,这里会自动重新连接。" : state === "error" ? {
1071
- funnel_permission_required: "登录已完成。请继续授权 Funnel,完成后会自动建立远程连接。",
1072
- funnel_https_required: "登录已完成。请继续授权 Funnel,官方页面会同时启用 HTTPS。",
1073
- funnel_start_failed: "登录已完成。请继续完成 Tailscale Funnel 的首次授权。",
1074
- funnel_start_timeout: "Tailscale 组件启动超时,请检查网络后重新连接。",
1075
- tailscale_dns_missing: "Tailscale 暂未提供远程地址。请重新连接并确认已完成登录。",
1076
- gateway_start_failed: "远程网关启动失败。请重新连接,局域网访问不受影响。",
1077
- control_channel_failed: "远程组件连接中断。请重新连接。",
1078
- cpolar_component_missing: "cpolar 官方组件尚未安装。请先完成上方准备步骤。",
1079
- cpolar_component_invalid: "cpolar 组件校验失败。请彻底移除后重新安装。",
1080
- cpolar_config_missing: "cpolar 尚未保存账号令牌。请先完成上方准备步骤。",
1081
- cpolar_config_invalid: "cpolar 本机配置无效。请重新保存账号令牌。",
1082
- cpolar_port_unavailable: "无法分配本机远程网关端口,请重试。",
1083
- cpolar_launch_failed: "cpolar 客户端未能启动。",
1084
- cpolar_start_timeout: "连接 cpolar 国内节点超时,请重新连接。",
1085
- cpolar_stopped: "cpolar 连接已停止。",
1086
- cpolar_exited: "cpolar 连接意外退出,请重新连接。",
1087
- cpolar_invalid_output: "cpolar 返回了无法识别的状态。",
1088
- cpolar_invalid_origin: "cpolar 返回的公网地址未通过校验。"
1089
- }[errorCode] ?? labels.error : labels[state] ?? labels.error;
2189
+ const errorLabels = {
2190
+ funnel_permission_required: t("funnelPermission"),
2191
+ funnel_https_required: t("funnelHttps"),
2192
+ funnel_start_failed: t("funnelStart"),
2193
+ funnel_start_timeout: t("funnelTimeout"),
2194
+ tailscale_dns_missing: t("tailscaleDnsMissing"),
2195
+ gateway_start_failed: t("gatewayStartFailed"),
2196
+ control_channel_failed: t("controlChannelFailed"),
2197
+ cpolar_component_missing: t("cpolarMissing"),
2198
+ cpolar_component_invalid: t("cpolarInvalid"),
2199
+ cpolar_config_missing: t("cpolarConfigMissing"),
2200
+ cpolar_config_invalid: t("cpolarConfigInvalid"),
2201
+ cpolar_port_unavailable: t("cpolarPortUnavailable"),
2202
+ cpolar_launch_failed: t("cpolarLaunchFailed"),
2203
+ cpolar_start_timeout: t("cpolarTimeout"),
2204
+ cpolar_stopped: t("cpolarStopped"),
2205
+ cpolar_exited: t("cpolarExited"),
2206
+ cpolar_invalid_output: t("cpolarOutputInvalid"),
2207
+ cpolar_invalid_origin: t("cpolarOriginInvalid")
2208
+ };
2209
+ remoteStatus.textContent = remoteSetupPending && needsFunnelSetup ? t("setupOpened") : state === "error" ? errorLabels[errorCode] ?? labels.error : labels[state] ?? labels.error;
1090
2210
  remoteGuide.hidden = !needsFunnelSetup;
1091
2211
  remoteSetup.disabled = remoteSetupUrl === "" || remoteReconnectBusy;
1092
2212
  remoteSetupRetry.disabled = remoteReconnectBusy;
1093
- remoteToggle.textContent = remoteRunning ? "关闭远程访问" : "启用远程访问";
2213
+ remoteToggle.textContent = remoteRunning ? t("disableRemote") : t("enableRemote");
1094
2214
  remoteToggle.disabled = remoteProviderBusy || cpolar && (!cpolarInstalled || !cpolarConfigured);
1095
2215
  remoteLogin.hidden = cpolar || state !== "needs-login" || remoteLoginUrl === "";
1096
2216
  remoteReconnect.hidden = needsFunnelSetup || state !== "error" && state !== "unavailable" || cpolar && (!cpolarInstalled || !cpolarConfigured);
@@ -1104,24 +2224,24 @@ window.__ModuleLoader__.load({
1104
2224
  const loadRemote = () => {
1105
2225
  if (remoteLoadInFlight) return;
1106
2226
  remoteLoadInFlight = true;
1107
- requestJson("/api/mobile-access/remote/control").then(renderRemote, (error) => {
1108
- remoteStatus.textContent = String(error);
2227
+ controlRequestJson("/api/mobile-access/remote/control").then(renderRemote, (error) => {
2228
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1109
2229
  }).finally(() => {
1110
2230
  remoteLoadInFlight = false;
1111
2231
  });
1112
2232
  };
1113
2233
  const chooseRemoteProvider = (provider) => {
1114
2234
  if (remoteProviderBusy || provider === remoteProvider) return;
1115
- if (remoteRunning && !window.confirm("切换连接方式会先关闭当前远程通道。局域网和配对设备不会受影响,是否继续?")) return;
2235
+ if (remoteRunning && !window.confirm(t("switchProviderConfirm"))) return;
1116
2236
  remoteProviderBusy = true;
1117
2237
  tailscaleChoice.disabled = true;
1118
2238
  cpolarChoice.disabled = true;
1119
- remoteStatus.textContent = provider === "cpolar" ? "正在切换到 cpolar…" : "正在切换到 Tailscale Funnel…";
1120
- requestJson("/api/mobile-access/remote/provider", {
2239
+ remoteStatus.textContent = provider === "cpolar" ? t("switchingCpolar") : t("switchingTailscale");
2240
+ controlRequestJson("/api/mobile-access/remote/provider", {
1121
2241
  method: "POST",
1122
2242
  body: JSON.stringify({ provider })
1123
2243
  }).then(renderRemote, (error) => {
1124
- remoteStatus.textContent = String(error);
2244
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1125
2245
  }).finally(() => {
1126
2246
  remoteProviderBusy = false;
1127
2247
  loadRemote();
@@ -1135,16 +2255,16 @@ window.__ModuleLoader__.load({
1135
2255
  });
1136
2256
  cpolarInstall.addEventListener("click", () => {
1137
2257
  if (remoteProviderBusy) return;
1138
- if (!window.confirm("将从 cpolar 官方网站下载并校验固定版本(约 7.3 MB),仅解压到 DSH Mobile 私有目录。不会安装系统服务、写入 PATH/注册表或设置开机启动。是否继续?")) return;
2258
+ if (!window.confirm(t("installConfirm"))) return;
1139
2259
  remoteProviderBusy = true;
1140
2260
  cpolarInstall.disabled = true;
1141
- cpolarInstall.textContent = "正在下载并校验…";
1142
- remoteStatus.textContent = "正在安装 cpolar 官方组件。完成前请保持 DSH 运行。";
1143
- requestJson("/api/mobile-access/remote/cpolar/component/install", {
2261
+ cpolarInstall.textContent = t("downloading");
2262
+ remoteStatus.textContent = t("installingCpolar");
2263
+ controlRequestJson("/api/mobile-access/remote/cpolar/component/install", {
1144
2264
  method: "POST",
1145
2265
  body: JSON.stringify({ confirm: true })
1146
2266
  }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then(renderRemote, (error) => {
1147
- remoteStatus.textContent = `组件安装失败:${String(error)}`;
2267
+ remoteStatus.textContent = t("installFailed", { error: String(error) });
1148
2268
  }).finally(() => {
1149
2269
  remoteProviderBusy = false;
1150
2270
  loadRemote();
@@ -1154,44 +2274,44 @@ window.__ModuleLoader__.load({
1154
2274
  if (remoteProviderBusy) return;
1155
2275
  const authtoken = cpolarToken.value.trim();
1156
2276
  if (authtoken.length < 20 || /\s/u.test(authtoken)) {
1157
- remoteStatus.textContent = "请粘贴 cpolar 控制台提供的完整 Authtoken。";
2277
+ remoteStatus.textContent = t("invalidToken");
1158
2278
  cpolarToken.focus();
1159
2279
  return;
1160
2280
  }
1161
2281
  remoteProviderBusy = true;
1162
2282
  cpolarConfigure.disabled = true;
1163
2283
  cpolarConfigure.setAttribute("aria-busy", "true");
1164
- cpolarConfigure.textContent = "正在保存…";
1165
- requestJson("/api/mobile-access/remote/cpolar/configure", {
2284
+ cpolarConfigure.textContent = t("saving");
2285
+ controlRequestJson("/api/mobile-access/remote/cpolar/configure", {
1166
2286
  method: "POST",
1167
2287
  body: JSON.stringify({ authtoken })
1168
2288
  }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then(() => {
1169
2289
  cpolarToken.value = "";
1170
- remoteStatus.textContent = "账号配置已保存,正在建立 cpolar 远程通道…";
1171
- return requestJson("/api/mobile-access/remote/control", {
2290
+ remoteStatus.textContent = t("configuredConnecting");
2291
+ return controlRequestJson("/api/mobile-access/remote/control", {
1172
2292
  method: "POST",
1173
2293
  body: JSON.stringify({ running: true })
1174
2294
  });
1175
2295
  }).then(renderRemote, (error) => {
1176
- remoteStatus.textContent = `配置失败:${String(error)}`;
2296
+ remoteStatus.textContent = t("configureFailed", { error: String(error) });
1177
2297
  }).finally(() => {
1178
2298
  remoteProviderBusy = false;
1179
2299
  cpolarConfigure.setAttribute("aria-busy", "false");
1180
- cpolarConfigure.textContent = "保存并连接";
2300
+ cpolarConfigure.textContent = t("saveConnect");
1181
2301
  loadRemote();
1182
2302
  });
1183
2303
  });
1184
2304
  cpolarPurge.addEventListener("click", () => {
1185
2305
  if (remoteProviderBusy) return;
1186
- if (!window.confirm("彻底移除 DSH Mobile 私有目录中的 cpolar 组件、令牌配置和运行日志?不会影响局域网、DSH 数据或系统中的其他程序。")) return;
2306
+ if (!window.confirm(t("purgeConfirm"))) return;
1187
2307
  remoteProviderBusy = true;
1188
2308
  cpolarPurge.disabled = true;
1189
- remoteStatus.textContent = "正在关闭通道并清理 DSH Mobile 管理的 cpolar 文件…";
1190
- requestJson("/api/mobile-access/remote/cpolar/component/purge", {
2309
+ remoteStatus.textContent = t("purging");
2310
+ controlRequestJson("/api/mobile-access/remote/cpolar/component/purge", {
1191
2311
  method: "POST",
1192
2312
  body: JSON.stringify({ confirm: true })
1193
2313
  }, LONG_CONTROL_REQUEST_TIMEOUT_MS).then(renderRemote, (error) => {
1194
- remoteStatus.textContent = `清理失败:${String(error)}`;
2314
+ remoteStatus.textContent = t("purgeFailed", { error: String(error) });
1195
2315
  }).finally(() => {
1196
2316
  remoteProviderBusy = false;
1197
2317
  cpolarPurge.disabled = false;
@@ -1200,11 +2320,11 @@ window.__ModuleLoader__.load({
1200
2320
  });
1201
2321
  remoteToggle.addEventListener("click", () => {
1202
2322
  remoteToggle.disabled = true;
1203
- requestJson("/api/mobile-access/remote/control", {
2323
+ controlRequestJson("/api/mobile-access/remote/control", {
1204
2324
  method: "POST",
1205
2325
  body: JSON.stringify({ running: !remoteRunning })
1206
2326
  }).then(renderRemote, (error) => {
1207
- remoteStatus.textContent = String(error);
2327
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1208
2328
  }).finally(loadRemote);
1209
2329
  });
1210
2330
  remoteLogin.addEventListener("click", () => {
@@ -1216,12 +2336,12 @@ window.__ModuleLoader__.load({
1216
2336
  remoteReconnect.disabled = true;
1217
2337
  remoteSetup.disabled = true;
1218
2338
  remoteSetupRetry.disabled = true;
1219
- remoteStatus.textContent = remoteProvider === "cpolar" ? "正在重新连接 cpolar 国内节点…" : "正在确认 Tailscale 设置并重新连接…";
1220
- requestJson("/api/mobile-access/remote/reconnect", {
2339
+ remoteStatus.textContent = remoteProvider === "cpolar" ? t("reconnectingCpolar") : t("reconnectingTailscale");
2340
+ controlRequestJson("/api/mobile-access/remote/reconnect", {
1221
2341
  method: "POST",
1222
2342
  body: "{}"
1223
2343
  }).then(renderRemote, (error) => {
1224
- remoteStatus.textContent = String(error);
2344
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1225
2345
  }).finally(() => {
1226
2346
  remoteReconnectBusy = false;
1227
2347
  remoteReconnect.disabled = false;
@@ -1238,7 +2358,7 @@ window.__ModuleLoader__.load({
1238
2358
  if (remoteSetupUrl === "") return;
1239
2359
  remoteSetupPending = true;
1240
2360
  remoteSetupOpenedAt = Date.now();
1241
- remoteStatus.textContent = "Tailscale 官方页面已打开。完成启用后返回 DSH,这里会自动重新连接。";
2361
+ remoteStatus.textContent = t("setupOpened");
1242
2362
  window.open(remoteSetupUrl, "_blank", "noopener,noreferrer");
1243
2363
  });
1244
2364
  const retryAfterSetup = () => {
@@ -1250,7 +2370,7 @@ window.__ModuleLoader__.load({
1250
2370
  document.addEventListener("visibilitychange", retryAfterSetup);
1251
2371
  remotePair.addEventListener("click", () => {
1252
2372
  remotePair.disabled = true;
1253
- requestJson("/api/mobile-access/remote/pairing/open", {
2373
+ controlRequestJson("/api/mobile-access/remote/pairing/open", {
1254
2374
  method: "POST",
1255
2375
  body: "{}"
1256
2376
  }).then(async (data) => {
@@ -1259,9 +2379,9 @@ window.__ModuleLoader__.load({
1259
2379
  if (pairUrl !== "") try {
1260
2380
  await navigator.clipboard.writeText(pairUrl);
1261
2381
  } catch {}
1262
- remoteStatus.textContent = "远程配对二维码已生成。请在 App 的“远程访问”中扫描。";
2382
+ remoteStatus.textContent = t("remoteQrReady");
1263
2383
  }, (error) => {
1264
- remoteStatus.textContent = String(error);
2384
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1265
2385
  }).finally(() => {
1266
2386
  remotePair.disabled = !remoteReady;
1267
2387
  });
@@ -1271,26 +2391,26 @@ window.__ModuleLoader__.load({
1271
2391
  remoteDevicePanel.replaceChildren();
1272
2392
  if (devices.length === 0) {
1273
2393
  const empty = element("p", "dsh-mobile-control__device-empty");
1274
- empty.textContent = "暂无远程配对设备。";
2394
+ empty.textContent = t("noRemoteDevices");
1275
2395
  remoteDevicePanel.append(empty);
1276
2396
  return;
1277
2397
  }
1278
2398
  for (const device of devices) {
1279
2399
  const row = element("div", "dsh-mobile-control__device");
1280
2400
  const label = element("span", "dsh-mobile-control__device-label");
1281
- label.textContent = typeof device.label === "string" ? device.label : "设备";
2401
+ label.textContent = typeof device.label === "string" ? device.label : t("device");
1282
2402
  const meta = element("span", "dsh-mobile-control__device-meta");
1283
- meta.textContent = `到期 ${formatTime(device.expiresAt)}`;
2403
+ meta.textContent = t("expires", { time: formatTime(device.expiresAt) });
1284
2404
  const revoke = element("button", "dsh-mobile-control__device-revoke");
1285
2405
  revoke.type = "button";
1286
- revoke.textContent = "撤销";
2406
+ revoke.textContent = t("revoke");
1287
2407
  const id = typeof device.id === "string" ? device.id : "";
1288
2408
  revoke.addEventListener("click", () => {
1289
- requestJson("/api/mobile-access/remote/devices/revoke", {
2409
+ controlRequestJson("/api/mobile-access/remote/devices/revoke", {
1290
2410
  method: "POST",
1291
2411
  body: JSON.stringify({ deviceId: id })
1292
2412
  }).then(loadRemoteDevices, (error) => {
1293
- remoteStatus.textContent = String(error);
2413
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1294
2414
  });
1295
2415
  });
1296
2416
  row.append(label, meta, revoke);
@@ -1298,8 +2418,8 @@ window.__ModuleLoader__.load({
1298
2418
  }
1299
2419
  };
1300
2420
  const loadRemoteDevices = () => {
1301
- requestJson("/api/mobile-access/remote/devices").then(renderRemoteDevices, (error) => {
1302
- remoteStatus.textContent = String(error);
2421
+ controlRequestJson("/api/mobile-access/remote/devices").then(renderRemoteDevices, (error) => {
2422
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1303
2423
  });
1304
2424
  };
1305
2425
  remoteDevices.addEventListener("click", () => {
@@ -1308,29 +2428,89 @@ window.__ModuleLoader__.load({
1308
2428
  if (show) loadRemoteDevices();
1309
2429
  });
1310
2430
  remoteReset.addEventListener("click", () => {
1311
- const prompt = remoteProvider === "cpolar" ? "关闭 cpolar 远程通道并移除所有远程配对设备?不会修改你的 cpolar 账号或其他隧道。" : "退出电脑上的 Tailscale 登录并移除所有远程配对设备?局域网配置不会改变。";
2431
+ const prompt = remoteProvider === "cpolar" ? t("resetCpolarConfirm") : t("resetTailscaleConfirm");
1312
2432
  if (!window.confirm(prompt)) return;
1313
- requestJson("/api/mobile-access/remote/reset", {
2433
+ controlRequestJson("/api/mobile-access/remote/reset", {
1314
2434
  method: "POST",
1315
2435
  body: JSON.stringify({ confirm: true })
1316
2436
  }).then(renderRemote, (error) => {
1317
- remoteStatus.textContent = String(error);
2437
+ remoteStatus.textContent = t("requestFailed", { error: String(error) });
1318
2438
  });
1319
2439
  });
1320
2440
  const renderDiagnostics = (data) => {
1321
- const overall = data.overall === "error" ? "error" : data.overall === "attention" ? "attention" : "ok";
2441
+ const entries = [...diagnosticEntriesForRender(data)];
2442
+ const overall = diagnosticOverallForChecks(data.overall, entries.map((entry) => entry.status));
1322
2443
  diagnosticsSummary.className = `dsh-mobile-control__diagnostic-summary is-${overall}`;
1323
- diagnosticsSummaryTitle.textContent = overall === "ok" ? "检查完成" : overall === "attention" ? "有项目需要留意" : "发现连接问题";
1324
- diagnosticsSummaryText.textContent = typeof data.summary === "string" ? data.summary : "检查已完成。";
1325
- const entries = Array.isArray(data.checks) ? data.checks : [];
2444
+ diagnosticsSummaryTitle.textContent = overall === "ok" ? t("diagnosticsComplete") : overall === "attention" ? t("diagnosticsAttention") : t("diagnosticsProblem");
2445
+ diagnosticsSummaryText.textContent = locale === "zh" && data.overall === overall && typeof data.summary === "string" ? data.summary : t("diagnosticsCompleteFallback");
1326
2446
  diagnosticsChecks.replaceChildren();
1327
2447
  const statusLabels = {
1328
- ok: "正常",
1329
- warning: "注意",
1330
- error: "问题",
1331
- info: "说明"
2448
+ ok: t("diagnosticStatusOk"),
2449
+ warning: t("diagnosticStatusWarning"),
2450
+ error: t("diagnosticStatusError"),
2451
+ info: t("diagnosticStatusInfo")
2452
+ };
2453
+ const diagnosticLabelKeys = {
2454
+ versions: "diagnosticLabelVersions",
2455
+ network: "diagnosticLabelNetwork",
2456
+ lan: "diagnosticLabelLan",
2457
+ firewall: "diagnosticLabelFirewall",
2458
+ remote: "diagnosticLabelRemote",
2459
+ "phone-network": "diagnosticLabelPhone"
2460
+ };
2461
+ const statusOf = (entry) => normalizeDiagnosticStatus(entry.status);
2462
+ const localizedEntryCopy = (entry) => {
2463
+ const serverCopy = diagnosticServerCopy(entry);
2464
+ const reason = typeof entry.reason === "string" ? entry.reason : "";
2465
+ const templates = DIAGNOSTIC_REASON_MESSAGES[locale][reason];
2466
+ if (templates === void 0) return serverCopy;
2467
+ const facts = entry.facts !== null && typeof entry.facts === "object" ? entry.facts : {};
2468
+ const values = {
2469
+ provider: facts.provider === "tailscale" || facts.provider === "cpolar" ? facts.provider : "",
2470
+ latencyMs: typeof facts.latencyMs === "number" && Number.isFinite(facts.latencyMs) ? new Intl.NumberFormat(localeTag).format(facts.latencyMs) : "",
2471
+ interfaceName: typeof facts.interfaceName === "string" ? facts.interfaceName : "",
2472
+ endpointSuffix: typeof facts.endpointSuffix === "string" ? facts.endpointSuffix : "",
2473
+ controllerCode: typeof facts.controllerCode === "string" ? facts.controllerCode : ""
2474
+ };
2475
+ const interpolate = (template) => template.replace(/\{(\w+)\}/gu, (_match, key) => values[key] ?? "");
2476
+ let detail = interpolate(templates[0]);
2477
+ let action = interpolate(templates[1]);
2478
+ if (reason === "versions-current") {
2479
+ const versions = data.versions !== null && typeof data.versions === "object" ? data.versions : {};
2480
+ if ([
2481
+ versions.plugin,
2482
+ versions.dsh,
2483
+ versions.minimumAndroidApp
2484
+ ].every((value) => typeof value === "string")) detail += ` plugin ${String(versions.plugin)}, DSH ${String(versions.dsh)}, Android ${String(versions.minimumAndroidApp)}.`;
2485
+ }
2486
+ if (reason === "remote-controller-error") {
2487
+ const actionKey = {
2488
+ component_missing: "remoteUnavailableTailscale",
2489
+ funnel_permission_required: "funnelPermission",
2490
+ funnel_https_required: "funnelHttps",
2491
+ funnel_start_failed: "funnelStart",
2492
+ funnel_start_timeout: "funnelTimeout",
2493
+ tailscale_dns_missing: "tailscaleDnsMissing",
2494
+ sidecar_launch_failed: "remoteUnavailableTailscale",
2495
+ sidecar_stopped: "controlChannelFailed",
2496
+ sidecar_exited: "controlChannelFailed",
2497
+ control_channel_failed: "controlChannelFailed",
2498
+ cpolar_component_missing: "cpolarMissing",
2499
+ cpolar_component_invalid: "cpolarInvalid",
2500
+ cpolar_config_missing: "cpolarConfigMissing",
2501
+ cpolar_config_invalid: "cpolarConfigInvalid",
2502
+ cpolar_start_timeout: "cpolarTimeout",
2503
+ cpolar_stopped: "cpolarStopped",
2504
+ cpolar_exited: "cpolarExited",
2505
+ gateway_start_failed: "gatewayStartFailed"
2506
+ }[values.controllerCode ?? ""];
2507
+ if (actionKey !== void 0) action = t(actionKey);
2508
+ }
2509
+ return {
2510
+ detail,
2511
+ action
2512
+ };
1332
2513
  };
1333
- const statusOf = (entry) => entry.status === "ok" || entry.status === "warning" || entry.status === "error" ? entry.status : "info";
1334
2514
  const appendGroup = (label, groupEntries) => {
1335
2515
  if (groupEntries.length === 0) return;
1336
2516
  const group = element("section", "dsh-mobile-control__diagnostic-group");
@@ -1338,7 +2518,7 @@ window.__ModuleLoader__.load({
1338
2518
  const groupTitle = element("h3");
1339
2519
  groupTitle.textContent = label;
1340
2520
  const groupCount = element("span");
1341
- groupCount.textContent = `${String(groupEntries.length)} 项`;
2521
+ groupCount.textContent = t("diagnosticItems", { count: groupEntries.length });
1342
2522
  const list = element("div", "dsh-mobile-control__diagnostic-list");
1343
2523
  list.setAttribute("role", "list");
1344
2524
  groupHeader.append(groupTitle, groupCount);
@@ -1350,19 +2530,21 @@ window.__ModuleLoader__.load({
1350
2530
  marker.setAttribute("aria-hidden", "true");
1351
2531
  const rowBody = element("div", "dsh-mobile-control__diagnostic-check-body");
1352
2532
  const rowHeader = element("div", "dsh-mobile-control__diagnostic-check-header");
2533
+ const labelKey = typeof entry.id === "string" ? diagnosticLabelKeys[entry.id] : void 0;
1353
2534
  const rowTitle = element("strong");
1354
- rowTitle.textContent = typeof entry.label === "string" ? entry.label : "检查项";
2535
+ rowTitle.textContent = locale !== "zh" && labelKey !== void 0 ? t(labelKey) : typeof entry.label === "string" ? entry.label : t("diagnosticCheck");
1355
2536
  const badge = element("span", "dsh-mobile-control__diagnostic-badge");
1356
2537
  badge.textContent = statusLabels[state];
2538
+ const localizedCopy = localizedEntryCopy(entry);
1357
2539
  const detail = element("p");
1358
- detail.textContent = typeof entry.detail === "string" ? entry.detail : "";
2540
+ detail.textContent = localizedCopy.detail;
1359
2541
  rowHeader.append(rowTitle, badge);
1360
2542
  rowBody.append(rowHeader, detail);
1361
- if (typeof entry.action === "string" && entry.action !== "") {
2543
+ if (localizedCopy.action !== "") {
1362
2544
  const action = element("p", "dsh-mobile-control__diagnostic-action");
1363
2545
  const actionLabel = element("span");
1364
- actionLabel.textContent = "建议";
1365
- action.append(actionLabel, document.createTextNode(entry.action));
2546
+ actionLabel.textContent = t("diagnosticAction");
2547
+ action.append(actionLabel, document.createTextNode(localizedCopy.action));
1366
2548
  rowBody.append(action);
1367
2549
  }
1368
2550
  row.append(marker, rowBody);
@@ -1373,40 +2555,67 @@ window.__ModuleLoader__.load({
1373
2555
  };
1374
2556
  const issues = entries.filter((entry) => statusOf(entry) === "error" || statusOf(entry) === "warning");
1375
2557
  const otherChecks = entries.filter((entry) => statusOf(entry) !== "error" && statusOf(entry) !== "warning");
1376
- appendGroup("需要处理", issues);
1377
- appendGroup(issues.length === 0 ? "检查详情" : "其他检查", otherChecks);
1378
- diagnosticsSummaryMeta.textContent = issues.length === 0 ? `${String(entries.length)} 项检查 · 未发现阻断问题` : `${String(entries.length)} 项检查 · ${String(issues.length)} 项需要处理`;
2558
+ appendGroup(t("diagnosticNeedsAction"), issues);
2559
+ appendGroup(issues.length === 0 ? t("diagnosticDetails") : t("diagnosticOther"), otherChecks);
2560
+ diagnosticsSummaryMeta.textContent = issues.length === 0 ? t("diagnosticNoBlockers", { count: entries.length }) : t("diagnosticNeedsCount", {
2561
+ count: entries.length,
2562
+ issues: issues.length
2563
+ });
1379
2564
  diagnosticsChecks.hidden = entries.length === 0;
1380
- copiedDiagnosticReport = typeof data.report === "string" ? data.report : "";
2565
+ const reportCopy = LOCALIZED_DIAGNOSTIC_COPY[locale];
2566
+ const generatedAt = typeof data.generatedAt === "number" ? new Date(data.generatedAt).toLocaleString(localeTag) : (/* @__PURE__ */ new Date()).toLocaleString(localeTag);
2567
+ const lines = entries.map((entry) => {
2568
+ const state = statusOf(entry);
2569
+ const labelKey = typeof entry.id === "string" ? diagnosticLabelKeys[entry.id] : void 0;
2570
+ const label = labelKey === void 0 && typeof entry.label === "string" ? entry.label : labelKey === void 0 ? t("diagnosticCheck") : t(labelKey);
2571
+ const localizedCopy = localizedEntryCopy(entry);
2572
+ return `[${statusLabels[state]}] ${label}: ${localizedCopy.detail}${localizedCopy.action === "" ? "" : ` ${localizedCopy.action}`}`;
2573
+ });
2574
+ copiedDiagnosticReport = [
2575
+ reportCopy.reportTitle,
2576
+ `${reportCopy.generated}: ${generatedAt}`,
2577
+ ...lines
2578
+ ].join("\n");
1381
2579
  diagnosticsReport.textContent = copiedDiagnosticReport;
1382
2580
  diagnosticsDetails.hidden = copiedDiagnosticReport === "";
1383
2581
  diagnosticsCopy.disabled = copiedDiagnosticReport === "";
1384
2582
  diagnosticsCopy.hidden = copiedDiagnosticReport === "";
1385
2583
  diagnosticsToolbar.classList.toggle("has-report", copiedDiagnosticReport !== "");
1386
2584
  };
2585
+ const showDiagnosticsFailure = (error) => {
2586
+ diagnosticsSummary.className = "dsh-mobile-control__diagnostic-summary is-error";
2587
+ diagnosticsSummaryTitle.textContent = t("diagnosticsIncomplete");
2588
+ diagnosticsSummaryText.textContent = t("diagnosticsReadFailed", { error: String(error) });
2589
+ diagnosticsSummaryMeta.textContent = t("diagnosticsUnavailable");
2590
+ diagnosticsChecks.replaceChildren();
2591
+ diagnosticsChecks.hidden = true;
2592
+ copiedDiagnosticReport = "";
2593
+ diagnosticsReport.textContent = "";
2594
+ diagnosticsDetails.hidden = true;
2595
+ diagnosticsCopy.disabled = true;
2596
+ diagnosticsCopy.hidden = true;
2597
+ diagnosticsToolbar.classList.remove("has-report");
2598
+ };
1387
2599
  const loadDiagnostics = () => {
1388
2600
  if (diagnosticsBusy) return;
1389
2601
  diagnosticsBusy = true;
1390
2602
  diagnosticsRun.disabled = true;
1391
2603
  diagnosticsRun.setAttribute("aria-busy", "true");
1392
- diagnosticsRun.textContent = "正在检查…";
2604
+ diagnosticsRun.textContent = t("diagnosticsChecking");
1393
2605
  diagnosticsSummary.className = "dsh-mobile-control__diagnostic-summary is-running";
1394
- diagnosticsSummaryTitle.textContent = "正在检查连接";
1395
- diagnosticsSummaryText.textContent = "通常几秒内完成。远程通道会执行一次真实可达性测试。";
1396
- diagnosticsSummaryMeta.textContent = "正在运行 · 请保持 DSH 在线";
2606
+ diagnosticsSummaryTitle.textContent = t("diagnosticsCheckingTitle");
2607
+ diagnosticsSummaryText.textContent = t("diagnosticsCheckingText");
2608
+ diagnosticsSummaryMeta.textContent = t("diagnosticsRunningMeta");
1397
2609
  diagnosticsFeedback.hidden = true;
1398
2610
  diagnosticsChecks.classList.add("is-refreshing");
1399
2611
  diagnosticsChecks.setAttribute("aria-busy", "true");
1400
- requestJson("/api/mobile-access/diagnostics").then(renderDiagnostics, (error) => {
1401
- diagnosticsSummary.className = "dsh-mobile-control__diagnostic-summary is-error";
1402
- diagnosticsSummaryTitle.textContent = "检查未完成";
1403
- diagnosticsSummaryText.textContent = `无法读取诊断结果:${String(error)}`;
1404
- diagnosticsSummaryMeta.textContent = "诊断服务暂不可用 · 请稍后重试";
1405
- }).finally(() => {
2612
+ controlRequestJson("/api/mobile-access/diagnostics").then((data) => {
2613
+ renderDiagnosticPayloadSafely(data, renderDiagnostics, showDiagnosticsFailure);
2614
+ }, showDiagnosticsFailure).finally(() => {
1406
2615
  diagnosticsBusy = false;
1407
2616
  diagnosticsRun.disabled = false;
1408
2617
  diagnosticsRun.setAttribute("aria-busy", "false");
1409
- diagnosticsRun.textContent = "重新检查";
2618
+ diagnosticsRun.textContent = t("diagnosticsRetry");
1410
2619
  diagnosticsChecks.classList.remove("is-refreshing");
1411
2620
  diagnosticsChecks.setAttribute("aria-busy", "false");
1412
2621
  });
@@ -1423,11 +2632,11 @@ window.__ModuleLoader__.load({
1423
2632
  diagnosticsCopy.addEventListener("click", () => {
1424
2633
  if (copiedDiagnosticReport === "") return;
1425
2634
  navigator.clipboard.writeText(copiedDiagnosticReport).then(() => {
1426
- diagnosticsFeedback.textContent = "脱敏报告已复制,可直接粘贴到 Issue。";
2635
+ diagnosticsFeedback.textContent = t("diagnosticsCopied");
1427
2636
  diagnosticsFeedback.hidden = false;
1428
2637
  }, () => {
1429
2638
  diagnosticsDetails.open = true;
1430
- diagnosticsFeedback.textContent = "浏览器未允许复制,已展开详情,请手动复制。";
2639
+ diagnosticsFeedback.textContent = t("diagnosticsCopyManual");
1431
2640
  diagnosticsFeedback.hidden = false;
1432
2641
  });
1433
2642
  });
@@ -1452,8 +2661,8 @@ window.__ModuleLoader__.load({
1452
2661
  if (!panel.contains(event.target) && !document.querySelector(".dsh-mobile-control__trigger")?.contains(event.target)) setOpen(false);
1453
2662
  };
1454
2663
  document.addEventListener("pointerdown", dismiss);
1455
- requestJson("/api/mobile-access/lan/control").then(render, (error) => {
1456
- status.textContent = String(error);
2664
+ controlRequestJson("/api/mobile-access/lan/control").then(render, (error) => {
2665
+ status.textContent = t("requestFailed", { error: String(error) });
1457
2666
  });
1458
2667
  loadRemote();
1459
2668
  const remotePoll = window.setInterval(() => {
@@ -1461,6 +2670,7 @@ window.__ModuleLoader__.load({
1461
2670
  }, 1500);
1462
2671
  return {
1463
2672
  remove: () => {
2673
+ lifecycle.abort();
1464
2674
  window.clearInterval(remotePoll);
1465
2675
  window.removeEventListener("focus", retryAfterSetup);
1466
2676
  document.removeEventListener("visibilitychange", retryAfterSetup);
@@ -1489,24 +2699,512 @@ window.__ModuleLoader__.load({
1489
2699
  redirect: "error"
1490
2700
  });
1491
2701
  }
2702
+ /** Combine extension and caller abort lifetimes and expose deterministic listener cleanup. */
2703
+ function combineClientSignalLifetime(first, second) {
2704
+ if (first.aborted || second.aborted) {
2705
+ const aborted = new AbortController();
2706
+ aborted.abort(first.aborted ? first.reason : second.reason);
2707
+ return {
2708
+ signal: aborted.signal,
2709
+ cleanup: () => void 0
2710
+ };
2711
+ }
2712
+ const controller = new AbortController();
2713
+ const cleanup = () => {
2714
+ first.removeEventListener("abort", abortFirst);
2715
+ second.removeEventListener("abort", abortSecond);
2716
+ };
2717
+ const abortFirst = () => {
2718
+ cleanup();
2719
+ controller.abort(first.reason);
2720
+ };
2721
+ const abortSecond = () => {
2722
+ cleanup();
2723
+ controller.abort(second.reason);
2724
+ };
2725
+ first.addEventListener("abort", abortFirst, { once: true });
2726
+ second.addEventListener("abort", abortSecond, { once: true });
2727
+ return {
2728
+ signal: controller.signal,
2729
+ cleanup
2730
+ };
2731
+ }
2732
+ /** Combine extension and caller abort lifetimes on WebViews without AbortSignal.any. */
2733
+ function combineClientSignals(first, second) {
2734
+ return combineClientSignalLifetime(first, second).signal;
2735
+ }
2736
+ /** Keep a combined request lifetime until a streamed response is consumed or cancelled. */
2737
+ function bindClientResponseLifetime(response, cleanup) {
2738
+ if (response.body === null) {
2739
+ cleanup();
2740
+ return response;
2741
+ }
2742
+ const reader = response.body.getReader();
2743
+ let released = false;
2744
+ const release = () => {
2745
+ if (released) return;
2746
+ released = true;
2747
+ cleanup();
2748
+ };
2749
+ const body = new ReadableStream({
2750
+ async pull(controller) {
2751
+ try {
2752
+ const result = await reader.read();
2753
+ if (result.done) {
2754
+ release();
2755
+ controller.close();
2756
+ } else controller.enqueue(result.value);
2757
+ } catch (error) {
2758
+ release();
2759
+ controller.error(error);
2760
+ }
2761
+ },
2762
+ async cancel(reason) {
2763
+ try {
2764
+ await reader.cancel(reason);
2765
+ } finally {
2766
+ release();
2767
+ }
2768
+ }
2769
+ });
2770
+ try {
2771
+ const retained = new Response(body, {
2772
+ headers: response.headers,
2773
+ status: response.status,
2774
+ statusText: response.statusText
2775
+ });
2776
+ Object.defineProperties(retained, {
2777
+ redirected: {
2778
+ configurable: true,
2779
+ value: response.redirected
2780
+ },
2781
+ type: {
2782
+ configurable: true,
2783
+ value: response.type
2784
+ },
2785
+ url: {
2786
+ configurable: true,
2787
+ value: response.url
2788
+ }
2789
+ });
2790
+ return retained;
2791
+ } catch (error) {
2792
+ reader.cancel(error);
2793
+ release();
2794
+ throw error;
2795
+ }
2796
+ }
2797
+ /** Dispose an old UI when its replacement Host generation cannot be activated. */
2798
+ function failClosedExtensionGenerationReplacement(hasActive, activeGeneration, replacementGeneration, dispose) {
2799
+ if (!hasActive || activeGeneration === replacementGeneration) return false;
2800
+ dispose();
2801
+ return true;
2802
+ }
2803
+ /** Resolve one SDK route and prove its normalized path remains in the current extension namespace. */
2804
+ function extensionRouteUrl(id, path, baseUrl) {
2805
+ const rawPathname = path.split(/[?#]/u, 1)[0] ?? "";
2806
+ if (!/^[a-z][a-z0-9-]{0,63}$/u.test(id) || !path.startsWith("/") || path.startsWith("//") || rawPathname.includes("\\") || rawPathname.includes("\0") || /%(?:2f|5c)/iu.test(rawPathname)) throw new TypeError("extension routes must be relative");
2807
+ const origin = new URL(baseUrl).origin;
2808
+ const prefix = `/mobile-access/extensions/${id}/routes`;
2809
+ let target;
2810
+ try {
2811
+ target = new URL(`${prefix}${path}`, origin);
2812
+ } catch {
2813
+ throw new TypeError("extension routes must be relative");
2814
+ }
2815
+ let decodedPathname;
2816
+ try {
2817
+ decodedPathname = decodeURIComponent(target.pathname);
2818
+ } catch {
2819
+ throw new TypeError("extension routes must be relative");
2820
+ }
2821
+ if (target.origin !== origin || target.hash !== "" || decodedPathname.includes("\\") || decodedPathname !== prefix && !decodedPathname.startsWith(`${prefix}/`)) throw new TypeError("extension routes must be relative");
2822
+ if (decodedPathname.slice(prefix.length).split("/").some((part) => part === "." || part === "..")) throw new TypeError("extension routes must be relative");
2823
+ return target;
2824
+ }
2825
+ /** Resolve one generation-pinned static asset URL in the current extension namespace. */
2826
+ function extensionAssetUrl(id, generation, path, baseUrl) {
2827
+ if (!/^[a-z][a-z0-9-]{0,63}$/u.test(id) || generation !== void 0 && !/^[a-f\d]{64}$/u.test(generation)) throw new TypeError("extension asset path is invalid");
2828
+ const normalized = path.replaceAll("\\", "/");
2829
+ if (normalized.length === 0 || normalized.startsWith("/") || normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new TypeError("extension asset path is invalid");
2830
+ const target = new URL(`/mobile-access/extensions/${id}/assets/${normalized.split("/").map(encodeURIComponent).join("/")}`, new URL(baseUrl).origin);
2831
+ if (generation !== void 0) target.searchParams.set("generation", generation);
2832
+ return target;
2833
+ }
2834
+ /** Add the immutable Host generation selected for one activated mobile UI. */
2835
+ function extensionGenerationHeaders(generation, headers) {
2836
+ const result = new Headers(headers);
2837
+ if (generation !== void 0) result.set("x-dsh-mobile-extension-generation", generation);
2838
+ return result;
2839
+ }
2840
+ function registerUniqueDisposable(entries, claimedIds, id, mount) {
2841
+ if (claimedIds.has(id)) throw new Error(`duplicate lifecycle id: ${id}`);
2842
+ claimedIds.add(id);
2843
+ let mounted;
2844
+ try {
2845
+ mounted = mount();
2846
+ } catch (error) {
2847
+ claimedIds.delete(id);
2848
+ throw error;
2849
+ }
2850
+ let disposed = false;
2851
+ const dispose = () => {
2852
+ if (disposed) return;
2853
+ disposed = true;
2854
+ mounted.dispose();
2855
+ };
2856
+ const entry = {
2857
+ ...mounted,
2858
+ dispose
2859
+ };
2860
+ entries.set(id, entry);
2861
+ return () => {
2862
+ if (entries.get(id) === entry) {
2863
+ entries.delete(id);
2864
+ claimedIds.delete(id);
2865
+ }
2866
+ dispose();
2867
+ };
2868
+ }
2869
+ /** Dispose resources omitted by a successfully fetched authoritative manifest. */
2870
+ function reconcileRemovedExtensions(currentIds, seen, dispose) {
2871
+ for (const id of new Set(currentIds)) if (!seen.has(id)) dispose(id);
2872
+ }
2873
+ /** Reconcile managed resources against one validated authoritative id set. */
2874
+ function publishAuthoritativeExtensionIds(authoritativeIds, seen, managedIdSources, dispose) {
2875
+ const current = new Set(authoritativeIds);
2876
+ for (const source of managedIdSources) for (const id of source) current.add(id);
2877
+ reconcileRemovedExtensions(current, seen, dispose);
2878
+ authoritativeIds.clear();
2879
+ for (const id of seen) authoritativeIds.add(id);
2880
+ }
2881
+ function validManifestResourceUrl(value) {
2882
+ return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") && !value.includes("://") && !value.split("/").some((part) => part === ".." || part === ".");
2883
+ }
2884
+ /** Validate the manifest before treating it as authoritative state. */
2885
+ function parseMobileExtensionManifest(payload) {
2886
+ if (typeof payload !== "object" || payload === null) return void 0;
2887
+ const candidate = payload;
2888
+ if (candidate.protocol !== 1 || !Array.isArray(candidate.extensions) || typeof candidate.legacy !== "object" || candidate.legacy === null) return void 0;
2889
+ const legacy = candidate.legacy;
2890
+ if (typeof legacy.scriptRevision !== "string" || typeof legacy.styleRevision !== "string") return void 0;
2891
+ const ids = /* @__PURE__ */ new Set();
2892
+ const extensions = [];
2893
+ for (const value of candidate.extensions) {
2894
+ if (typeof value !== "object" || value === null) return void 0;
2895
+ const entry = value;
2896
+ if (typeof entry.id !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(entry.id) || ids.has(entry.id)) return void 0;
2897
+ if (entry.generation !== void 0 && (typeof entry.generation !== "string" || !/^[a-f\d]{64}$/u.test(entry.generation))) return void 0;
2898
+ if (entry.scriptUrl !== void 0 && !validManifestResourceUrl(entry.scriptUrl)) return void 0;
2899
+ if (entry.styleUrl !== void 0 && !validManifestResourceUrl(entry.styleUrl)) return void 0;
2900
+ if (entry.assetsUrl !== void 0 && !validManifestResourceUrl(entry.assetsUrl)) return void 0;
2901
+ ids.add(entry.id);
2902
+ extensions.push({
2903
+ id: entry.id,
2904
+ ...entry.generation === void 0 ? {} : { generation: entry.generation },
2905
+ ...entry.scriptUrl === void 0 ? {} : { scriptUrl: entry.scriptUrl },
2906
+ ...entry.styleUrl === void 0 ? {} : { styleUrl: entry.styleUrl },
2907
+ ...entry.assetsUrl === void 0 ? {} : { assetsUrl: entry.assetsUrl }
2908
+ });
2909
+ }
2910
+ return {
2911
+ extensions,
2912
+ legacy: {
2913
+ scriptRevision: legacy.scriptRevision,
2914
+ styleRevision: legacy.styleRevision
2915
+ }
2916
+ };
2917
+ }
2918
+ /** A missing manifest is authoritative before optional legacy resources finish refreshing. */
2919
+ async function handleMissingExtensionManifest(clearManifestResources, refreshLegacyResources, signal) {
2920
+ clearManifestResources();
2921
+ const results = await refreshLegacyResources();
2922
+ return !signal.aborted && results.every(Boolean);
2923
+ }
2924
+ function refreshAborted(signal) {
2925
+ return signal.aborted;
2926
+ }
2927
+ /** Run one coalesced refresh cycle, slowing down when the page is hidden. */
2928
+ function startLifecycleRefreshScheduler(refresh, options = {}, runtime = {
2929
+ document,
2930
+ window
2931
+ }) {
2932
+ const visibleIntervalMs = options.visibleIntervalMs ?? 45e3;
2933
+ const hiddenIntervalMs = options.hiddenIntervalMs ?? 3e5;
2934
+ const cycleTimeoutMs = options.cycleTimeoutMs ?? 3e4;
2935
+ let timer;
2936
+ let cycleTimer;
2937
+ let running = false;
2938
+ let queued = false;
2939
+ let disposed = false;
2940
+ let controller;
2941
+ const clearTimer = () => {
2942
+ if (timer === void 0) return;
2943
+ runtime.window.clearTimeout(timer);
2944
+ timer = void 0;
2945
+ };
2946
+ const clearCycleTimer = () => {
2947
+ if (cycleTimer === void 0) return;
2948
+ runtime.window.clearTimeout(cycleTimer);
2949
+ cycleTimer = void 0;
2950
+ };
2951
+ const schedule = () => {
2952
+ if (disposed) return;
2953
+ clearTimer();
2954
+ const delay = runtime.document.visibilityState === "hidden" ? hiddenIntervalMs : visibleIntervalMs;
2955
+ timer = runtime.window.setTimeout(run, delay);
2956
+ };
2957
+ const run = () => {
2958
+ if (disposed) return;
2959
+ clearTimer();
2960
+ if (running) {
2961
+ queued = true;
2962
+ return;
2963
+ }
2964
+ running = true;
2965
+ const current = new AbortController();
2966
+ controller = current;
2967
+ const refreshPromise = Promise.resolve().then(() => refresh(current.signal));
2968
+ const timeoutPromise = new Promise((resolve) => {
2969
+ cycleTimer = runtime.window.setTimeout(() => {
2970
+ cycleTimer = void 0;
2971
+ current.abort(new DOMException("mobile extension refresh timed out", "TimeoutError"));
2972
+ resolve();
2973
+ }, cycleTimeoutMs);
2974
+ });
2975
+ Promise.race([refreshPromise, timeoutPromise]).catch(() => {}).finally(() => {
2976
+ clearCycleTimer();
2977
+ if (controller === current) controller = void 0;
2978
+ running = false;
2979
+ if (disposed) return;
2980
+ if (queued) {
2981
+ queued = false;
2982
+ run();
2983
+ } else schedule();
2984
+ });
2985
+ refreshPromise.catch(() => {});
2986
+ };
2987
+ const onVisibilityChange = () => {
2988
+ if (runtime.document.visibilityState === "hidden") schedule();
2989
+ else run();
2990
+ };
2991
+ runtime.document.addEventListener("visibilitychange", onVisibilityChange);
2992
+ runtime.window.addEventListener("focus", run);
2993
+ runtime.window.addEventListener("online", run);
2994
+ run();
2995
+ const stop = () => {
2996
+ disposed = true;
2997
+ queued = false;
2998
+ controller?.abort();
2999
+ controller = void 0;
3000
+ clearCycleTimer();
3001
+ clearTimer();
3002
+ runtime.document.removeEventListener("visibilitychange", onVisibilityChange);
3003
+ runtime.window.removeEventListener("focus", run);
3004
+ runtime.window.removeEventListener("online", run);
3005
+ };
3006
+ return Object.assign(stop, { refresh: run });
3007
+ }
3008
+ /** Maintain one authenticated same-origin extension event stream with bounded reconnect backoff. */
3009
+ function startExtensionChangeStream(changed, runtime = {
3010
+ window,
3011
+ create: (url) => new EventSource(url, { withCredentials: true })
3012
+ }) {
3013
+ let source;
3014
+ let timer;
3015
+ let disposed = false;
3016
+ let retryMs = 1e3;
3017
+ const clearTimer = () => {
3018
+ if (timer === void 0) return;
3019
+ runtime.window.clearTimeout(timer);
3020
+ timer = void 0;
3021
+ };
3022
+ const connect = () => {
3023
+ if (disposed || source !== void 0) return;
3024
+ clearTimer();
3025
+ const next = runtime.create("/mobile-access/extensions/events");
3026
+ source = next;
3027
+ next.onopen = () => {
3028
+ retryMs = 1e3;
3029
+ };
3030
+ next.addEventListener("extensions-changed", changed);
3031
+ next.onerror = () => {
3032
+ if (source !== next) return;
3033
+ next.close();
3034
+ source = void 0;
3035
+ if (disposed) return;
3036
+ const delay = retryMs;
3037
+ retryMs = Math.min(3e4, retryMs * 2);
3038
+ timer = runtime.window.setTimeout(connect, delay);
3039
+ };
3040
+ };
3041
+ const reconnectNow = () => {
3042
+ if (disposed) return;
3043
+ source?.close();
3044
+ source = void 0;
3045
+ retryMs = 1e3;
3046
+ connect();
3047
+ };
3048
+ runtime.window.addEventListener("online", reconnectNow);
3049
+ connect();
3050
+ return () => {
3051
+ disposed = true;
3052
+ clearTimer();
3053
+ source?.close();
3054
+ source = void 0;
3055
+ runtime.window.removeEventListener("online", reconnectNow);
3056
+ };
3057
+ }
3058
+ /** Keep at most one activation in flight per id and commit only its latest generation. */
3059
+ var PerIdActivationLifecycle = class {
3060
+ active = /* @__PURE__ */ new Map();
3061
+ pending = /* @__PURE__ */ new Map();
3062
+ generations = /* @__PURE__ */ new Map();
3063
+ disposed = false;
3064
+ hasActive(id) {
3065
+ return this.active.has(id);
3066
+ }
3067
+ getActive(id) {
3068
+ return this.active.get(id)?.value;
3069
+ }
3070
+ pendingCount() {
3071
+ return this.pending.size;
3072
+ }
3073
+ async activate(id, key, cycleSignal, create) {
3074
+ if (this.disposed || cycleSignal?.aborted === true) return false;
3075
+ const existing = this.pending.get(id);
3076
+ if (existing !== void 0) {
3077
+ if (existing.key === key && !existing.controller.signal.aborted) return this.waitFor(existing, cycleSignal);
3078
+ existing.cancel();
3079
+ }
3080
+ const generation = (this.generations.get(id) ?? 0) + 1;
3081
+ this.generations.set(id, generation);
3082
+ const controller = new AbortController();
3083
+ let work;
3084
+ try {
3085
+ work = create(controller, generation);
3086
+ } catch {
3087
+ controller.abort(new DOMException("mobile extension activation failed to start", "AbortError"));
3088
+ return false;
3089
+ }
3090
+ let cancelled = false;
3091
+ let pending = {};
3092
+ const cancel = () => {
3093
+ if (cancelled) return;
3094
+ cancelled = true;
3095
+ if (this.pending.get(id) === pending) this.pending.delete(id);
3096
+ if (this.generations.get(id) === generation) this.generations.set(id, generation + 1);
3097
+ controller.abort(new DOMException("mobile extension activation cancelled", "AbortError"));
3098
+ work.cancel();
3099
+ };
3100
+ const completion = Promise.resolve(work.result).then((value) => {
3101
+ const current = this.pending.get(id);
3102
+ if (!(!this.disposed && current === pending && current.generation === this.generations.get(id) && !controller.signal.aborted)) {
3103
+ try {
3104
+ work.dispose(value);
3105
+ } catch {}
3106
+ return false;
3107
+ }
3108
+ try {
3109
+ work.commit?.(value);
3110
+ } catch {
3111
+ try {
3112
+ work.dispose(value);
3113
+ } catch {}
3114
+ return false;
3115
+ }
3116
+ const previous = this.active.get(id);
3117
+ let valueDisposed = false;
3118
+ const dispose = () => {
3119
+ if (valueDisposed) return;
3120
+ valueDisposed = true;
3121
+ controller.abort(new DOMException("mobile extension deactivated", "AbortError"));
3122
+ try {
3123
+ work.dispose(value);
3124
+ } catch {}
3125
+ };
3126
+ this.active.set(id, {
3127
+ value,
3128
+ dispose
3129
+ });
3130
+ previous?.dispose();
3131
+ return true;
3132
+ }, () => {
3133
+ cancel();
3134
+ return false;
3135
+ }).finally(() => {
3136
+ if (this.pending.get(id) === pending) this.pending.delete(id);
3137
+ });
3138
+ Object.assign(pending, {
3139
+ key,
3140
+ generation,
3141
+ controller,
3142
+ cancel,
3143
+ completion
3144
+ });
3145
+ this.pending.set(id, pending);
3146
+ return this.waitFor(pending, cycleSignal);
3147
+ }
3148
+ remove(id) {
3149
+ this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
3150
+ this.active.get(id)?.dispose();
3151
+ this.active.delete(id);
3152
+ this.pending.get(id)?.cancel();
3153
+ }
3154
+ dispose() {
3155
+ if (this.disposed) return;
3156
+ this.disposed = true;
3157
+ for (const current of this.active.values()) current.dispose();
3158
+ this.active.clear();
3159
+ for (const current of this.pending.values()) current.cancel();
3160
+ }
3161
+ waitFor(pending, signal) {
3162
+ if (signal === void 0) return pending.completion;
3163
+ if (signal.aborted) {
3164
+ pending.cancel();
3165
+ return Promise.resolve(false);
3166
+ }
3167
+ return new Promise((resolve) => {
3168
+ let settled = false;
3169
+ const finish = (value) => {
3170
+ if (settled) return;
3171
+ settled = true;
3172
+ signal.removeEventListener("abort", onAbort);
3173
+ resolve(value);
3174
+ };
3175
+ const onAbort = () => {
3176
+ pending.cancel();
3177
+ finish(false);
3178
+ };
3179
+ signal.addEventListener("abort", onAbort, { once: true });
3180
+ pending.completion.then(finish);
3181
+ });
3182
+ }
3183
+ };
1492
3184
  function installCustomAssets() {
1493
3185
  const legacyStyle = element("style");
1494
3186
  legacyStyle.dataset.plugin = "dsh-mobile-custom";
1495
3187
  document.head.append(legacyStyle);
3188
+ const legacyCssState = {
3189
+ etag: "",
3190
+ modified: ""
3191
+ };
1496
3192
  const previous = window.dshMobile;
1497
3193
  let legacyMount = queuedLegacyMount;
1498
3194
  let legacySource = "";
1499
3195
  let legacyRoot;
1500
3196
  let legacyDispose;
1501
3197
  const definitions = /* @__PURE__ */ new Map();
1502
- const active = /* @__PURE__ */ new Map();
3198
+ const activations = new PerIdActivationLifecycle();
1503
3199
  const styleNodes = /* @__PURE__ */ new Map();
1504
3200
  const styleEtags = /* @__PURE__ */ new Map();
1505
3201
  const scriptDigests = /* @__PURE__ */ new Map();
3202
+ const activeHostGenerations = /* @__PURE__ */ new Map();
3203
+ const activationKeys = /* @__PURE__ */ new Map();
1506
3204
  const manifestExtensionIds = /* @__PURE__ */ new Set();
1507
3205
  const managedDefinitionIds = /* @__PURE__ */ new Set();
1508
- const activationQueues = /* @__PURE__ */ new Map();
1509
3206
  let manifestEtag = "";
3207
+ let disposed = false;
1510
3208
  let expectedDefinitionId;
1511
3209
  const SURFACE_HOST_STYLES = {
1512
3210
  "sidebar-action": "position:fixed;z-index:1100;top:calc(env(safe-area-inset-top) + 8px);left:8px;display:flex;flex-direction:column;gap:6px;pointer-events:none",
@@ -1553,9 +3251,34 @@ window.__ModuleLoader__.load({
1553
3251
  return value;
1554
3252
  }
1555
3253
  };
1556
- const invokeNative = async (action, input) => {
3254
+ const invokeNative = async (action, input, signal) => {
3255
+ const abortReason = () => signal.reason ?? new DOMException("mobile extension disposed", "AbortError");
3256
+ if (signal.aborted) throw abortReason();
1557
3257
  const bridge = window.__DSH_MOBILE_NATIVE__;
1558
- if (bridge !== void 0) return materializeNativeFile(await bridge.invoke(action, input));
3258
+ if (bridge !== void 0) return new Promise((resolve, reject) => {
3259
+ let settled = false;
3260
+ const finish = (callback) => {
3261
+ if (settled) return;
3262
+ settled = true;
3263
+ signal.removeEventListener("abort", onAbort);
3264
+ callback();
3265
+ };
3266
+ const onAbort = () => {
3267
+ finish(() => {
3268
+ reject(abortReason());
3269
+ });
3270
+ };
3271
+ signal.addEventListener("abort", onAbort, { once: true });
3272
+ Promise.resolve().then(() => bridge.invoke(action, input)).then((value) => {
3273
+ finish(() => {
3274
+ resolve(materializeNativeFile(value));
3275
+ });
3276
+ }, (error) => {
3277
+ finish(() => {
3278
+ reject(error);
3279
+ });
3280
+ });
3281
+ });
1559
3282
  if (action === "share" && typeof navigator.share === "function") {
1560
3283
  await navigator.share(input ?? {});
1561
3284
  return { ok: true };
@@ -1570,65 +3293,156 @@ window.__ModuleLoader__.load({
1570
3293
  inputElement.type = "file";
1571
3294
  inputElement.accept = action === "camera.capture" ? "image/*" : "*/*";
1572
3295
  if (action === "camera.capture") inputElement.capture = "environment";
1573
- return new Promise((resolve) => {
1574
- inputElement.onchange = () => resolve(inputElement.files?.[0]);
1575
- inputElement.click();
3296
+ inputElement.hidden = true;
3297
+ return new Promise((resolve, reject) => {
3298
+ let settled = false;
3299
+ let cleaned = false;
3300
+ let cleanupTimer = 0;
3301
+ let watchdogTimer = 0;
3302
+ const cleanup = () => {
3303
+ if (cleaned) return;
3304
+ cleaned = true;
3305
+ if (cleanupTimer !== 0) window.clearTimeout(cleanupTimer);
3306
+ if (watchdogTimer !== 0) window.clearTimeout(watchdogTimer);
3307
+ window.removeEventListener("focus", scheduleCancel);
3308
+ document.removeEventListener("visibilitychange", onVisibilityChange);
3309
+ signal.removeEventListener("abort", onAbort);
3310
+ inputElement.removeEventListener("change", onChange);
3311
+ inputElement.removeEventListener("cancel", onCancel);
3312
+ inputElement.remove();
3313
+ };
3314
+ const finish = (callback) => {
3315
+ if (settled) return;
3316
+ settled = true;
3317
+ cleanup();
3318
+ callback();
3319
+ };
3320
+ const onChange = () => {
3321
+ const file = inputElement.files?.[0];
3322
+ finish(() => {
3323
+ resolve(file);
3324
+ });
3325
+ };
3326
+ const onCancel = () => {
3327
+ finish(() => {
3328
+ resolve(void 0);
3329
+ });
3330
+ };
3331
+ const onAbort = () => {
3332
+ finish(() => {
3333
+ reject(abortReason());
3334
+ });
3335
+ };
3336
+ const scheduleCancel = () => {
3337
+ if (!settled && cleanupTimer === 0) cleanupTimer = window.setTimeout(onCancel, 1e3);
3338
+ };
3339
+ const onVisibilityChange = () => {
3340
+ if (document.visibilityState === "visible") scheduleCancel();
3341
+ };
3342
+ inputElement.addEventListener("change", onChange, { once: true });
3343
+ inputElement.addEventListener("cancel", onCancel, { once: true });
3344
+ window.addEventListener("focus", scheduleCancel);
3345
+ document.addEventListener("visibilitychange", onVisibilityChange);
3346
+ signal.addEventListener("abort", onAbort, { once: true });
3347
+ watchdogTimer = window.setTimeout(onCancel, 3e5);
3348
+ try {
3349
+ document.body.append(inputElement);
3350
+ inputElement.click();
3351
+ } catch (error) {
3352
+ finish(() => {
3353
+ reject(error);
3354
+ });
3355
+ }
1576
3356
  });
1577
3357
  }
1578
3358
  throw new Error("native capability is unavailable");
1579
3359
  };
1580
- const makeApi = (id, controller) => {
1581
- const surfaces = active.get(id)?.surfaces ?? /* @__PURE__ */ new Map();
3360
+ const makeApi = (id, hostGeneration, controller, surfaces, surfaceIds) => {
3361
+ const ensureCurrent = () => {
3362
+ if (controller.signal.aborted) throw controller.signal.reason;
3363
+ };
3364
+ const requestSignal = (signal) => signal === void 0 || signal === null ? {
3365
+ signal: controller.signal,
3366
+ cleanup: () => void 0
3367
+ } : combineClientSignalLifetime(controller.signal, signal);
1582
3368
  const mountSurface = (surface) => {
3369
+ ensureCurrent();
1583
3370
  if (!/^[a-z][a-z0-9-]{0,63}$/u.test(surface.id) || surface.label.length > 120) throw new Error("invalid mobile surface");
1584
- const container = element("section");
1585
- container.dataset.dshMobileSurface = surface.id;
1586
- container.hidden = surface.placement === "page" || surface.placement === "overlay";
1587
- container.style.cssText = surface.placement === "page" || surface.placement === "overlay" ? "position:absolute;inset:0;overflow:auto;background:var(--dsw-alias-bg-layer-1,#fff);padding:16px;pointer-events:auto" : "pointer-events:auto";
1588
- (surface.placement === "page" || surface.placement === "overlay" ? shellLayer() : surfaceHost(surface.placement) ?? shellLayer()).append(container);
1589
- const mounted = surface.mount(container);
1590
- const dispose = () => {
1591
- if (typeof mounted === "function") mounted();
1592
- container.remove();
1593
- };
1594
- surfaces.set(surface.id, {
1595
- dispose,
1596
- container
3371
+ return registerUniqueDisposable(surfaces, surfaceIds, surface.id, () => {
3372
+ const container = element("section");
3373
+ container.dataset.dshMobileSurface = surface.id;
3374
+ container.hidden = surface.placement === "page" || surface.placement === "overlay";
3375
+ container.style.cssText = surface.placement === "page" || surface.placement === "overlay" ? "position:absolute;inset:0;overflow:auto;background:var(--dsw-alias-bg-layer-1,#fff);padding:16px;pointer-events:auto" : "pointer-events:auto";
3376
+ const host = () => surface.placement === "page" || surface.placement === "overlay" ? shellLayer() : surfaceHost(surface.placement) ?? shellLayer();
3377
+ const mounted = surface.mount(container);
3378
+ const dispose = () => {
3379
+ try {
3380
+ if (typeof mounted === "function") mounted();
3381
+ } finally {
3382
+ container.remove();
3383
+ }
3384
+ };
3385
+ return {
3386
+ dispose,
3387
+ container,
3388
+ host
3389
+ };
1597
3390
  });
1598
- return () => {
1599
- if (surfaces.get(surface.id)?.dispose === dispose) surfaces.delete(surface.id);
1600
- dispose();
1601
- };
1602
3391
  };
1603
3392
  return {
1604
3393
  host: {
1605
- invoke: (action, input) => mobileRequest(`/mobile-access/extensions/${encodeURIComponent(id)}/actions/${encodeURIComponent(action)}`, {
1606
- method: "POST",
1607
- body: JSON.stringify(input ?? {})
1608
- }).then(async (response) => {
1609
- const value = await response.json();
1610
- if (!response.ok) throw new Error(typeof value === "object" && value !== null && "error" in value ? String(value.error) : `HTTP ${String(response.status)}`);
1611
- return value;
1612
- }),
3394
+ invoke: (action, input) => {
3395
+ ensureCurrent();
3396
+ return mobileRequest(`/mobile-access/extensions/${encodeURIComponent(id)}/actions/${encodeURIComponent(action)}`, {
3397
+ method: "POST",
3398
+ headers: extensionGenerationHeaders(hostGeneration),
3399
+ body: JSON.stringify(input ?? {}),
3400
+ signal: controller.signal
3401
+ }).then(async (response) => {
3402
+ const value = await response.json();
3403
+ if (!response.ok) throw new Error(typeof value === "object" && value !== null && "error" in value ? String(value.error) : `HTTP ${String(response.status)}`);
3404
+ return value;
3405
+ });
3406
+ },
1613
3407
  fetch: (path, init) => {
1614
- if (!path.startsWith("/") || path.startsWith("//") || path.includes("://") || path.split("/").some((part) => part === ".." || part === ".")) throw new TypeError("extension routes must be relative");
1615
- return mobileRequest(`/mobile-access/extensions/${encodeURIComponent(id)}/routes${path}`, init);
3408
+ ensureCurrent();
3409
+ const target = extensionRouteUrl(id, path, location.href);
3410
+ const headers = extensionGenerationHeaders(hostGeneration, init?.headers);
3411
+ const lifetime = requestSignal(init?.signal);
3412
+ return mobileRequest(target.href, {
3413
+ ...init,
3414
+ headers,
3415
+ signal: lifetime.signal
3416
+ }).then((response) => bindClientResponseLifetime(response, lifetime.cleanup), (error) => {
3417
+ lifetime.cleanup();
3418
+ throw error;
3419
+ });
3420
+ },
3421
+ assetUrl: (path) => {
3422
+ ensureCurrent();
3423
+ return extensionAssetUrl(id, hostGeneration, path, location.href).href;
1616
3424
  }
1617
3425
  },
1618
3426
  ui: {
1619
3427
  registerSurface: mountSurface,
1620
- open: (id) => {
1621
- const entry = surfaces.get(id);
3428
+ open: (surfaceId) => {
3429
+ ensureCurrent();
3430
+ const entry = surfaces.get(surfaceId);
1622
3431
  if (entry !== void 0) entry.container.hidden = false;
1623
3432
  },
1624
- close: (id) => {
1625
- const entry = surfaces.get(id);
3433
+ close: (surfaceId) => {
3434
+ ensureCurrent();
3435
+ const entry = surfaces.get(surfaceId);
1626
3436
  if (entry !== void 0) entry.container.hidden = true;
1627
3437
  },
1628
- toast
3438
+ toast: (message) => {
3439
+ ensureCurrent();
3440
+ toast(message);
3441
+ }
1629
3442
  },
1630
3443
  native: {
1631
3444
  capabilities: async () => {
3445
+ ensureCurrent();
1632
3446
  const bridge = window.__DSH_MOBILE_NATIVE__;
1633
3447
  return bridge === void 0 ? [
1634
3448
  "files.pick",
@@ -1638,63 +3452,59 @@ window.__ModuleLoader__.load({
1638
3452
  "clipboard.write"
1639
3453
  ] : bridge.capabilities();
1640
3454
  },
1641
- invoke: invokeNative
3455
+ invoke: (action, input) => invokeNative(action, input, controller.signal)
1642
3456
  },
1643
3457
  signal: controller.signal,
1644
3458
  document,
1645
3459
  window
1646
3460
  };
1647
3461
  };
1648
- const activateDefinitionNow = async (definition) => {
1649
- const previousActive = active.get(definition.id);
1650
- const controller = new AbortController();
1651
- const surfaces = /* @__PURE__ */ new Map();
1652
- active.set(definition.id, {
1653
- controller,
1654
- surfaces,
1655
- dispose: () => {
1656
- controller.abort();
1657
- for (const surface of surfaces.values()) surface.dispose();
3462
+ const activateDefinition = (definition, cycleSignal, commitGeneration, hostGeneration) => {
3463
+ const previousKey = activationKeys.get(definition.id);
3464
+ const key = previousKey?.definition === definition && previousKey.generation === hostGeneration ? previousKey : {
3465
+ definition,
3466
+ ...hostGeneration === void 0 ? {} : { generation: hostGeneration }
3467
+ };
3468
+ activationKeys.set(definition.id, key);
3469
+ return activations.activate(definition.id, key, cycleSignal, (controller) => {
3470
+ const surfaces = /* @__PURE__ */ new Map();
3471
+ const surfaceIds = /* @__PURE__ */ new Set();
3472
+ let pendingDisposed = false;
3473
+ const disposePending = () => {
3474
+ if (pendingDisposed) return;
3475
+ pendingDisposed = true;
3476
+ for (const surface of surfaces.values()) try {
3477
+ surface.dispose();
3478
+ } catch {}
1658
3479
  surfaces.clear();
1659
- }
1660
- });
1661
- try {
1662
- const cleanup = await definition.activate(makeApi(definition.id, controller));
1663
- const current = active.get(definition.id);
1664
- if (current === void 0 || current.controller !== controller) {
1665
- if (typeof cleanup === "function") cleanup();
1666
- return false;
1667
- }
1668
- const oldDispose = current.dispose;
1669
- active.set(definition.id, {
1670
- ...current,
1671
- dispose: () => {
1672
- if (typeof cleanup === "function") cleanup();
1673
- oldDispose();
3480
+ surfaceIds.clear();
3481
+ };
3482
+ return {
3483
+ result: Promise.resolve().then(() => definition.activate(makeApi(definition.id, hostGeneration, controller, surfaces, surfaceIds))).then((cleanup) => ({
3484
+ controller,
3485
+ surfaces,
3486
+ ...typeof cleanup === "function" ? { cleanup } : {}
3487
+ })),
3488
+ cancel: disposePending,
3489
+ commit: (value) => {
3490
+ if (definitions.get(definition.id) !== definition || controller.signal.aborted) throw new Error("stale mobile extension activation");
3491
+ for (const surface of value.surfaces.values()) surface.host().append(surface.container);
3492
+ activeHostGenerations.set(definition.id, hostGeneration);
3493
+ commitGeneration?.();
3494
+ },
3495
+ dispose: (value) => {
3496
+ controller.abort(new DOMException("mobile extension disposed", "AbortError"));
3497
+ try {
3498
+ value.cleanup?.();
3499
+ } finally {
3500
+ disposePending();
3501
+ }
1674
3502
  }
1675
- });
1676
- previousActive?.dispose();
1677
- return true;
1678
- } catch {
1679
- const current = active.get(definition.id);
1680
- if (current?.controller === controller) {
1681
- current.dispose();
1682
- active.delete(definition.id);
1683
- if (previousActive !== void 0) active.set(definition.id, previousActive);
1684
- }
1685
- return false;
1686
- }
1687
- };
1688
- const activateDefinition = (definition) => {
1689
- const task = (activationQueues.get(definition.id) ?? Promise.resolve(true)).then(() => definitions.get(definition.id) === definition ? activateDefinitionNow(definition) : false, () => definitions.get(definition.id) === definition ? activateDefinitionNow(definition) : false);
1690
- activationQueues.set(definition.id, task);
1691
- task.finally(() => {
1692
- if (activationQueues.get(definition.id) === task) activationQueues.delete(definition.id);
3503
+ };
1693
3504
  });
1694
- return task;
1695
3505
  };
1696
3506
  const define = (definition) => {
1697
- if (definition.apiVersion !== 1 || !/^[a-z][a-z0-9-]{0,63}$/u.test(definition.id) || typeof definition.activate !== "function") return;
3507
+ if (disposed || definition.apiVersion !== 1 || !/^[a-z][a-z0-9-]{0,63}$/u.test(definition.id) || typeof definition.activate !== "function") return;
1698
3508
  if (expectedDefinitionId !== void 0 && definition.id !== expectedDefinitionId) return;
1699
3509
  definitions.set(definition.id, definition);
1700
3510
  if (started && expectedDefinitionId === void 0) activateDefinition(definition);
@@ -1720,22 +3530,37 @@ window.__ModuleLoader__.load({
1720
3530
  credentials: "same-origin",
1721
3531
  cache: "no-store",
1722
3532
  headers,
1723
- ...signal === void 0 ? {} : { signal }
3533
+ signal
1724
3534
  });
1725
3535
  if (response.status === 304) return true;
1726
3536
  if (!response.ok) return false;
1727
- legacyJsEtag = response.headers.get("etag") ?? "";
1728
- legacyJsModified = response.headers.get("last-modified") ?? "";
3537
+ const nextEtag = response.headers.get("etag") ?? "";
3538
+ const nextModified = response.headers.get("last-modified") ?? "";
1729
3539
  const next = await response.text();
1730
- if (next === legacySource) return true;
3540
+ if (refreshAborted(signal)) return false;
3541
+ if (next === legacySource) {
3542
+ legacyJsEtag = nextEtag;
3543
+ legacyJsModified = nextModified;
3544
+ return true;
3545
+ }
1731
3546
  legacyMount = void 0;
1732
3547
  const script = element("script");
1733
3548
  script.textContent = `${next}\n//# sourceURL=dsh-mobile-custom.js`;
1734
3549
  document.head.append(script);
1735
3550
  script.remove();
3551
+ if (refreshAborted(signal)) {
3552
+ legacyMount = previousMount;
3553
+ return false;
3554
+ }
1736
3555
  const mount = legacyMount;
1737
3556
  if (mount === void 0) {
3557
+ legacyDispose?.();
3558
+ legacyDispose = void 0;
3559
+ legacyRoot?.remove();
3560
+ legacyRoot = void 0;
1738
3561
  legacySource = next;
3562
+ legacyJsEtag = nextEtag;
3563
+ legacyJsModified = nextModified;
1739
3564
  return true;
1740
3565
  }
1741
3566
  const nextRoot = element("div");
@@ -1748,12 +3573,21 @@ window.__ModuleLoader__.load({
1748
3573
  root: nextRoot,
1749
3574
  window
1750
3575
  });
3576
+ if (refreshAborted(signal)) {
3577
+ if (typeof nextDispose === "function") nextDispose();
3578
+ pendingRoot.remove();
3579
+ pendingRoot = void 0;
3580
+ legacyMount = previousMount;
3581
+ return false;
3582
+ }
1751
3583
  legacyDispose?.();
1752
3584
  legacyRoot?.remove();
1753
3585
  legacyRoot = nextRoot;
1754
3586
  pendingRoot = void 0;
1755
3587
  legacyDispose = typeof nextDispose === "function" ? nextDispose : void 0;
1756
3588
  legacySource = next;
3589
+ legacyJsEtag = nextEtag;
3590
+ legacyJsModified = nextModified;
1757
3591
  return true;
1758
3592
  } catch {
1759
3593
  pendingRoot?.remove();
@@ -1763,6 +3597,26 @@ window.__ModuleLoader__.load({
1763
3597
  };
1764
3598
  let legacyScriptRevision = "";
1765
3599
  let legacyStyleRevision = "";
3600
+ const disposeManifestExtension = (id) => {
3601
+ activations.remove(id);
3602
+ styleNodes.get(id)?.remove();
3603
+ styleNodes.delete(id);
3604
+ styleEtags.delete(id);
3605
+ scriptDigests.delete(id);
3606
+ activeHostGenerations.delete(id);
3607
+ activationKeys.delete(id);
3608
+ if (managedDefinitionIds.delete(id)) definitions.delete(id);
3609
+ };
3610
+ const managedManifestIdSources = () => [
3611
+ styleNodes.keys(),
3612
+ styleEtags.keys(),
3613
+ scriptDigests.keys(),
3614
+ managedDefinitionIds
3615
+ ];
3616
+ const clearManifestExtensions = () => {
3617
+ publishAuthoritativeExtensionIds(manifestExtensionIds, /* @__PURE__ */ new Set(), managedManifestIdSources(), disposeManifestExtension);
3618
+ manifestEtag = "";
3619
+ };
1766
3620
  const refreshExtensions = async (signal) => {
1767
3621
  try {
1768
3622
  const headers = {};
@@ -1771,30 +3625,57 @@ window.__ModuleLoader__.load({
1771
3625
  credentials: "same-origin",
1772
3626
  cache: "no-store",
1773
3627
  headers,
1774
- ...signal === void 0 ? {} : { signal }
3628
+ signal
1775
3629
  });
1776
- if (response.status === 404) return (await Promise.all([refreshLegacy(signal), refreshCssLegacy(legacyStyle, signal)])).every(Boolean);
3630
+ if (response.status === 404) {
3631
+ legacyScriptRevision = "";
3632
+ legacyStyleRevision = "";
3633
+ return handleMissingExtensionManifest(clearManifestExtensions, () => Promise.all([refreshLegacy(signal), refreshCssLegacy(legacyStyle, signal, legacyCssState)]), signal);
3634
+ }
1777
3635
  if (response.status === 304) return true;
1778
3636
  if (!response.ok) return false;
1779
3637
  const nextManifestEtag = response.headers.get("etag") ?? "";
1780
- const payload = await response.json();
1781
- const scriptRevision = typeof payload.legacy?.scriptRevision === "string" ? payload.legacy.scriptRevision : "";
1782
- const styleRevision = typeof payload.legacy?.styleRevision === "string" ? payload.legacy.styleRevision : "";
3638
+ const payload = parseMobileExtensionManifest(await response.json());
3639
+ if (refreshAborted(signal) || payload === void 0) return false;
3640
+ const entries = payload.extensions;
3641
+ const seen = new Set(entries.map((entry) => entry.id));
3642
+ const scriptRevision = payload.legacy.scriptRevision;
3643
+ const styleRevision = payload.legacy.styleRevision;
1783
3644
  let refreshComplete = true;
1784
3645
  if (scriptRevision === "" || scriptRevision !== legacyScriptRevision) {
1785
3646
  if (await refreshLegacy(signal)) legacyScriptRevision = scriptRevision;
1786
3647
  else refreshComplete = false;
1787
3648
  }
1788
3649
  if (styleRevision === "" || styleRevision !== legacyStyleRevision) {
1789
- if (await refreshCssLegacy(legacyStyle, signal)) legacyStyleRevision = styleRevision;
3650
+ if (await refreshCssLegacy(legacyStyle, signal, legacyCssState)) legacyStyleRevision = styleRevision;
1790
3651
  else refreshComplete = false;
1791
3652
  }
1792
- const entries = Array.isArray(payload.extensions) ? payload.extensions : [];
1793
- const seen = /* @__PURE__ */ new Set();
3653
+ const commitStyle = (id, change, css, etag) => {
3654
+ if (change === "retain") return;
3655
+ const oldStyle = styleNodes.get(id);
3656
+ if (change === "remove") {
3657
+ oldStyle?.remove();
3658
+ styleNodes.delete(id);
3659
+ styleEtags.delete(id);
3660
+ return;
3661
+ }
3662
+ if (oldStyle?.textContent !== css) {
3663
+ const node = element("style");
3664
+ node.dataset.dshMobileExtensionStyle = id;
3665
+ node.textContent = css ?? "";
3666
+ document.head.append(node);
3667
+ styleNodes.set(id, node);
3668
+ oldStyle?.remove();
3669
+ }
3670
+ if (etag !== void 0 && etag !== "") styleEtags.set(id, etag);
3671
+ };
1794
3672
  for (const entry of entries) {
1795
- if (typeof entry.id !== "string") continue;
1796
- seen.add(entry.id);
3673
+ const hadActiveGeneration = activations.hasActive(entry.id);
3674
+ const previousHostGeneration = activeHostGenerations.get(entry.id);
3675
+ let previousDefinition;
3676
+ let evaluatedDefinition = false;
1797
3677
  try {
3678
+ let styleChange = entry.styleUrl === void 0 ? "remove" : "retain";
1798
3679
  let pendingCss;
1799
3680
  let pendingStyleEtag;
1800
3681
  const cssUrl = typeof entry.styleUrl === "string" ? entry.styleUrl : void 0;
@@ -1806,16 +3687,25 @@ window.__ModuleLoader__.load({
1806
3687
  credentials: "same-origin",
1807
3688
  cache: "no-store",
1808
3689
  headers: cssHeaders,
1809
- ...signal === void 0 ? {} : { signal }
3690
+ signal
1810
3691
  });
1811
3692
  if (cssResponse.status !== 304) {
1812
3693
  if (!cssResponse.ok) throw new Error("mobile extension style failed to load");
1813
3694
  pendingStyleEtag = cssResponse.headers.get("etag") ?? void 0;
1814
3695
  pendingCss = await cssResponse.text();
3696
+ styleChange = "replace";
3697
+ if (refreshAborted(signal)) return false;
1815
3698
  }
1816
3699
  }
1817
3700
  const scriptUrl = typeof entry.scriptUrl === "string" ? entry.scriptUrl : void 0;
1818
- if (scriptUrl !== void 0) {
3701
+ if (scriptUrl === void 0) {
3702
+ if (refreshAborted(signal)) return false;
3703
+ commitStyle(entry.id, styleChange, pendingCss, pendingStyleEtag);
3704
+ activations.remove(entry.id);
3705
+ activeHostGenerations.delete(entry.id);
3706
+ scriptDigests.delete(entry.id);
3707
+ if (managedDefinitionIds.delete(entry.id)) definitions.delete(entry.id);
3708
+ } else {
1819
3709
  const scriptHeaders = {};
1820
3710
  const storedDigest = scriptDigests.get(entry.id);
1821
3711
  if (storedDigest !== void 0) scriptHeaders["if-none-match"] = storedDigest;
@@ -1823,15 +3713,18 @@ window.__ModuleLoader__.load({
1823
3713
  credentials: "same-origin",
1824
3714
  cache: "no-store",
1825
3715
  headers: scriptHeaders,
1826
- ...signal === void 0 ? {} : { signal }
3716
+ signal
1827
3717
  });
3718
+ let nextDigest;
1828
3719
  if (scriptResponse.status !== 304) {
1829
3720
  if (!scriptResponse.ok) throw new Error("mobile extension script failed to load");
1830
3721
  const source = await scriptResponse.text();
3722
+ if (refreshAborted(signal)) return false;
1831
3723
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
3724
+ if (refreshAborted(signal)) return false;
1832
3725
  const key = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
1833
3726
  if (scriptDigests.get(entry.id) !== key) {
1834
- const previousDefinition = definitions.get(entry.id);
3727
+ previousDefinition = definitions.get(entry.id);
1835
3728
  try {
1836
3729
  expectedDefinitionId = entry.id;
1837
3730
  try {
@@ -1844,9 +3737,8 @@ window.__ModuleLoader__.load({
1844
3737
  }
1845
3738
  const nextDefinition = definitions.get(entry.id);
1846
3739
  if (nextDefinition === void 0 || nextDefinition === previousDefinition) throw new Error("mobile extension did not define its manifest id");
1847
- if (!await activateDefinition(nextDefinition)) throw new Error("mobile extension activation failed");
1848
- managedDefinitionIds.add(entry.id);
1849
- scriptDigests.set(entry.id, key);
3740
+ evaluatedDefinition = true;
3741
+ nextDigest = key;
1850
3742
  } catch (error) {
1851
3743
  if (previousDefinition === void 0) definitions.delete(entry.id);
1852
3744
  else definitions.set(entry.id, previousDefinition);
@@ -1855,37 +3747,35 @@ window.__ModuleLoader__.load({
1855
3747
  }
1856
3748
  }
1857
3749
  const definition = definitions.get(entry.id);
1858
- if (definition !== void 0 && !active.has(entry.id) && !await activateDefinition(definition)) throw new Error("mobile extension activation failed");
1859
- }
1860
- if (pendingCss !== void 0) {
1861
- const oldStyle = styleNodes.get(entry.id);
1862
- if (oldStyle?.textContent !== pendingCss) {
1863
- const node = element("style");
1864
- node.dataset.dshMobileExtensionStyle = entry.id;
1865
- node.textContent = pendingCss;
1866
- document.head.append(node);
1867
- styleNodes.set(entry.id, node);
1868
- oldStyle?.remove();
3750
+ const commitGeneration = () => {
3751
+ commitStyle(entry.id, styleChange, pendingCss, pendingStyleEtag);
3752
+ };
3753
+ if (definition === void 0) throw new Error("mobile extension definition is unavailable");
3754
+ if (evaluatedDefinition || !activations.hasActive(entry.id) || activeHostGenerations.get(entry.id) !== entry.generation) {
3755
+ if (!await activateDefinition(definition, signal, commitGeneration, entry.generation)) throw new Error("mobile extension activation failed");
3756
+ } else {
3757
+ if (refreshAborted(signal)) return false;
3758
+ commitGeneration();
1869
3759
  }
1870
- if (pendingStyleEtag !== void 0 && pendingStyleEtag !== "") styleEtags.set(entry.id, pendingStyleEtag);
3760
+ managedDefinitionIds.add(entry.id);
3761
+ if (nextDigest !== void 0) scriptDigests.set(entry.id, nextDigest);
1871
3762
  }
1872
3763
  } catch {
3764
+ if (evaluatedDefinition) {
3765
+ if (previousDefinition === void 0) definitions.delete(entry.id);
3766
+ else definitions.set(entry.id, previousDefinition);
3767
+ }
3768
+ failClosedExtensionGenerationReplacement(hadActiveGeneration, previousHostGeneration, entry.generation, () => {
3769
+ disposeManifestExtension(entry.id);
3770
+ });
1873
3771
  refreshComplete = false;
1874
3772
  }
1875
3773
  }
1876
- for (const id of manifestExtensionIds) {
1877
- if (seen.has(id)) continue;
1878
- active.get(id)?.dispose();
1879
- active.delete(id);
1880
- styleNodes.get(id)?.remove();
1881
- styleNodes.delete(id);
1882
- styleEtags.delete(id);
1883
- scriptDigests.delete(id);
1884
- if (managedDefinitionIds.delete(id)) definitions.delete(id);
1885
- }
1886
- manifestExtensionIds.clear();
1887
- for (const id of seen) manifestExtensionIds.add(id);
1888
- manifestEtag = refreshComplete ? nextManifestEtag : "";
3774
+ if (refreshAborted(signal)) return false;
3775
+ if (refreshComplete) {
3776
+ publishAuthoritativeExtensionIds(manifestExtensionIds, seen, managedManifestIdSources(), disposeManifestExtension);
3777
+ manifestEtag = nextManifestEtag;
3778
+ } else manifestEtag = "";
1889
3779
  return refreshComplete;
1890
3780
  } catch {
1891
3781
  return false;
@@ -1893,76 +3783,60 @@ window.__ModuleLoader__.load({
1893
3783
  };
1894
3784
  started = true;
1895
3785
  for (const definition of definitions.values()) activateDefinition(definition);
1896
- const remoteOrigin = window.location.hostname.endsWith(".ts.net") || window.location.hostname.includes(".cpolar.");
1897
- const refreshIntervalMs = remoteOrigin ? 15e3 : 3e3;
1898
- const refreshTimeoutMs = remoteOrigin ? 2e4 : 8e3;
1899
- let stopped = false;
1900
- let refreshTimer;
1901
- let refreshFailures = 0;
1902
- let refreshController;
1903
- const scheduleRefresh = (delayMs) => {
1904
- if (stopped || document.visibilityState === "hidden" || !navigator.onLine) return;
1905
- if (refreshTimer !== void 0) clearTimeout(refreshTimer);
1906
- refreshTimer = window.setTimeout(runRefresh, delayMs);
1907
- };
1908
- const runRefresh = () => {
1909
- if (stopped || refreshController !== void 0) return;
1910
- refreshController = new AbortController();
1911
- const controller = refreshController;
1912
- const timeout = window.setTimeout(() => {
1913
- controller.abort();
1914
- }, refreshTimeoutMs);
1915
- refreshExtensions(controller.signal).then((success) => {
1916
- refreshFailures = success ? 0 : refreshFailures + 1;
1917
- }).finally(() => {
1918
- clearTimeout(timeout);
1919
- if (refreshController === controller) refreshController = void 0;
1920
- const backoff = Math.min(6e4, refreshIntervalMs * 2 ** Math.min(refreshFailures, 3));
1921
- scheduleRefresh(backoff);
1922
- });
1923
- };
1924
- const resumeRefresh = () => {
1925
- if (document.visibilityState !== "hidden" && navigator.onLine) scheduleRefresh(0);
1926
- };
1927
- document.addEventListener("visibilitychange", resumeRefresh);
1928
- window.addEventListener("online", resumeRefresh);
1929
- scheduleRefresh(0);
3786
+ const stopRefresh = startLifecycleRefreshScheduler(async (signal) => {
3787
+ await refreshExtensions(signal);
3788
+ });
3789
+ const stopEvents = startExtensionChangeStream(() => {
3790
+ stopRefresh.refresh();
3791
+ });
1930
3792
  return () => {
1931
- stopped = true;
1932
- if (refreshTimer !== void 0) clearTimeout(refreshTimer);
1933
- refreshController?.abort();
1934
- document.removeEventListener("visibilitychange", resumeRefresh);
1935
- window.removeEventListener("online", resumeRefresh);
3793
+ disposed = true;
3794
+ stopEvents();
3795
+ stopRefresh();
1936
3796
  started = false;
1937
3797
  legacyDispose?.();
3798
+ legacyDispose = void 0;
1938
3799
  legacyRoot?.remove();
3800
+ legacyRoot = void 0;
1939
3801
  legacyStyle.remove();
1940
- for (const current of active.values()) current.dispose();
3802
+ activations.dispose();
1941
3803
  for (const node of styleNodes.values()) node.remove();
3804
+ styleNodes.clear();
1942
3805
  document.querySelector("[data-dsh-mobile-extension-layer]")?.remove();
1943
3806
  for (const host of document.querySelectorAll("[data-dsh-mobile-surface-host]")) host.remove();
1944
3807
  if (previous === void 0) delete window.dshMobile;
1945
3808
  else window.dshMobile = previous;
1946
3809
  };
1947
3810
  }
1948
- let cssEtag = "";
1949
- let cssModified = "";
1950
- async function refreshCssLegacy(style, signal) {
3811
+ async function refreshCssLegacy(style, signal, state) {
1951
3812
  try {
1952
3813
  const headers = {};
1953
- if (cssEtag !== "") headers["if-none-match"] = cssEtag;
1954
- if (cssModified !== "") headers["if-modified-since"] = cssModified;
1955
- const response = await fetch("/mobile-access/custom.css", {
3814
+ if (state.etag !== "") headers["if-none-match"] = state.etag;
3815
+ if (state.modified !== "") headers["if-modified-since"] = state.modified;
3816
+ let response = await fetch("/mobile-access/custom.css", {
1956
3817
  credentials: "same-origin",
1957
3818
  cache: "no-store",
1958
3819
  headers,
1959
- ...signal === void 0 ? {} : { signal }
3820
+ signal
1960
3821
  });
3822
+ if (response.status === 304 && style.textContent === "") {
3823
+ state.etag = "";
3824
+ state.modified = "";
3825
+ response = await fetch("/mobile-access/custom.css", {
3826
+ credentials: "same-origin",
3827
+ cache: "no-store",
3828
+ signal
3829
+ });
3830
+ }
1961
3831
  if (response.status === 304) return true;
1962
3832
  if (response.ok) {
1963
- cssEtag = response.headers.get("etag") ?? "";
1964
- cssModified = response.headers.get("last-modified") ?? "";
1965
- style.textContent = await response.text();
3833
+ const nextEtag = response.headers.get("etag") ?? "";
3834
+ const nextModified = response.headers.get("last-modified") ?? "";
3835
+ const css = await response.text();
3836
+ if (refreshAborted(signal)) return false;
3837
+ style.textContent = css;
3838
+ state.etag = nextEtag;
3839
+ state.modified = nextModified;
1966
3840
  return true;
1967
3841
  }
1968
3842
  return false;
@@ -2008,31 +3882,39 @@ window.__ModuleLoader__.load({
2008
3882
  document.head.append(style);
2009
3883
  if (!loopback) {
2010
3884
  const removeCustom = installCustomAssets();
2011
- const removeSurface = installNativeMobileSurface();
3885
+ const removeSurface = installDshLanguageBoundSurface(installNativeMobileSurface);
2012
3886
  return () => {
2013
3887
  removeCustom();
2014
3888
  removeSurface();
2015
3889
  style.remove();
2016
3890
  };
2017
3891
  }
2018
- const control = installControl();
2019
- const disposeSlot = ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
2020
- name: "sidebar.footer.action",
2021
- id: "dsh-mobile"
2022
- }, ({ wide }) => (0, react.createElement)("button", {
2023
- "aria-expanded": false,
2024
- "aria-label": "移动访问",
2025
- className: `dsh-mobile-control__trigger${wide ? "" : " is-rail"}`,
2026
- type: "button",
2027
- title: "移动访问",
2028
- onClick: control.toggle
2029
- }, (0, react.createElement)("span", {
2030
- "aria-hidden": true,
2031
- className: "dsh-mobile-control__trigger-icon"
2032
- }), wide ? (0, react.createElement)("span", { className: "dsh-mobile-control__trigger-label" }, "移动访问") : void 0)));
3892
+ const removeControl = installDshLanguageBoundSurface(() => {
3893
+ const control = installControl();
3894
+ const triggerLocale = selectedMobileControlLocale();
3895
+ const t = controlTranslator(triggerLocale);
3896
+ const disposeSlot = ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
3897
+ name: "sidebar.footer.action",
3898
+ id: "dsh-mobile"
3899
+ }, ({ wide }) => (0, react.createElement)("button", {
3900
+ "aria-expanded": false,
3901
+ "aria-label": t("mobileAccess"),
3902
+ className: `dsh-mobile-control__trigger${wide ? "" : " is-rail"}`,
3903
+ lang: triggerLocale,
3904
+ type: "button",
3905
+ title: t("mobileAccess"),
3906
+ onClick: control.toggle
3907
+ }, (0, react.createElement)("span", {
3908
+ "aria-hidden": true,
3909
+ className: "dsh-mobile-control__trigger-icon"
3910
+ }), wide ? (0, react.createElement)("span", { className: "dsh-mobile-control__trigger-label" }, t("mobileAccess")) : void 0)));
3911
+ return () => {
3912
+ disposeSlot();
3913
+ control.remove();
3914
+ };
3915
+ });
2033
3916
  return () => {
2034
- disposeSlot();
2035
- control.remove();
3917
+ removeControl();
2036
3918
  style.remove();
2037
3919
  };
2038
3920
  }, "dsh-mobile: stock mobile adaptation and local control");
@@ -2040,9 +3922,36 @@ window.__ModuleLoader__.load({
2040
3922
  /** Client services required by the mobile adaptation. */
2041
3923
  const inject = ["slots"];
2042
3924
  //#endregion
3925
+ exports.DIAGNOSTIC_REASON_MESSAGES = DIAGNOSTIC_REASON_MESSAGES;
3926
+ exports.MOBILE_CONTROL_MESSAGES = MOBILE_CONTROL_MESSAGES;
3927
+ exports.PerIdActivationLifecycle = PerIdActivationLifecycle;
2043
3928
  exports.apply = apply;
3929
+ exports.bindClientResponseLifetime = bindClientResponseLifetime;
3930
+ exports.combineClientSignalLifetime = combineClientSignalLifetime;
3931
+ exports.combineClientSignals = combineClientSignals;
3932
+ exports.diagnosticEntriesForRender = diagnosticEntriesForRender;
3933
+ exports.diagnosticOverallForChecks = diagnosticOverallForChecks;
3934
+ exports.diagnosticServerCopy = diagnosticServerCopy;
3935
+ exports.extensionAssetUrl = extensionAssetUrl;
3936
+ exports.extensionGenerationHeaders = extensionGenerationHeaders;
3937
+ exports.extensionRouteUrl = extensionRouteUrl;
3938
+ exports.failClosedExtensionGenerationReplacement = failClosedExtensionGenerationReplacement;
3939
+ exports.handleMissingExtensionManifest = handleMissingExtensionManifest;
2044
3940
  exports.inject = inject;
3941
+ exports.installDshLanguageBoundSurface = installDshLanguageBoundSurface;
3942
+ exports.normalizeDiagnosticOverall = normalizeDiagnosticOverall;
3943
+ exports.normalizeDiagnosticStatus = normalizeDiagnosticStatus;
3944
+ exports.parseMobileExtensionManifest = parseMobileExtensionManifest;
3945
+ exports.publishAuthoritativeExtensionIds = publishAuthoritativeExtensionIds;
3946
+ exports.reconcileRemovedExtensions = reconcileRemovedExtensions;
3947
+ exports.registerUniqueDisposable = registerUniqueDisposable;
3948
+ exports.renderDiagnosticPayloadSafely = renderDiagnosticPayloadSafely;
3949
+ exports.selectMobileControlLocale = selectMobileControlLocale;
3950
+ exports.selectedMobileControlLocale = selectedMobileControlLocale;
3951
+ exports.startExtensionChangeStream = startExtensionChangeStream;
3952
+ exports.startLifecycleRefreshScheduler = startLifecycleRefreshScheduler;
2045
3953
  exports.trustAuthenticatedGatewayConnection = trustAuthenticatedGatewayConnection;
3954
+ exports.validateDiagnosticChecks = validateDiagnosticChecks;
2046
3955
  return module.exports;
2047
3956
  }
2048
3957
  });