dsh-device-deck 0.0.0-stage → 0.3.1

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/client.js ADDED
@@ -0,0 +1,2647 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-device-deck',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const ReactDOM = require('react-dom');
6
+ const h = React.createElement;
7
+ const { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } = React;
8
+
9
+ const API = '/api/device-deck';
10
+ const STORE_KEY = ['dsh-device-deck', 'state', 'v1'].join('.');
11
+ const T_META = 1;
12
+ const T_CONFIG = 2;
13
+ const T_DATA = 3;
14
+ const T_ERROR = 4;
15
+ const T_END = 5;
16
+ const HEADER_HEIGHT = 36;
17
+ const STRIP_HEIGHT = 50;
18
+
19
+ const COPY = {
20
+ zh: {
21
+ title: '设备台',
22
+ live: '实时',
23
+ connecting: '连接中',
24
+ offline: '离线',
25
+ noDevice: '未检测到 Android 设备',
26
+ noDeviceHint: '请连接 USB 并开启调试,或启动模拟器。',
27
+ adbMissing: '未找到 adb',
28
+ refresh: '刷新设备',
29
+ capture: '截图',
30
+ menuShot: '设备镜像截图',
31
+ menuShotHint: '选择截图并加入输入框',
32
+ pickerTitle: '选择截图',
33
+ pickerHint: '点击截图加入当前输入框',
34
+ pickerLoading: '正在读取截图…',
35
+ pickerEmpty: '还没有截图。先在设备镜像中截图。',
36
+ pickerLoadFailed: '读取截图失败:{error}',
37
+ pickerAttachFailed: '加入失败:{error}',
38
+ pickerBusy: '正在加入…',
39
+ attachUnavailable: '当前输入框不可用',
40
+ history: '截图历史',
41
+ close: '收起',
42
+ back: '返回键',
43
+ home: '主屏幕',
44
+ text: '文本输入',
45
+ link: '打开链接',
46
+ textHint: '英文、数字与常见符号;中文请用系统输入法。',
47
+ sendText: '发送',
48
+ open: '打开',
49
+ urlHint: 'https://… 或 app://…',
50
+ retry: '重新连接',
51
+ streamError: '未能连接到设备实时画面',
52
+ disconnected: '设备画面已断开',
53
+ emptyShot: '还没有截图',
54
+ selected: '已选定',
55
+ shotSaved: '截图已保存',
56
+ fileSending: '正在发送 {name}',
57
+ fileSent: '已发送到 {path}',
58
+ fileFailed: '发送失败:{error}',
59
+ connectingHint: '正在连接设备画面…',
60
+ switchDevice: '切换设备',
61
+ screenshotStrip: '截图',
62
+ enterText: '先在手机上点选输入框,再在此输入',
63
+ enterUrl: '输入地址后回车',
64
+ menuRunning: '运行中',
65
+ menuNoOnline: '没有设备在线',
66
+ menuReady: '已就绪',
67
+ menuStarting: '启动中',
68
+ menuEmulator: '模拟器',
69
+ menuReal: '真机',
70
+ menuIosEmulator: 'iOS 模拟器',
71
+ menuNotStarted: '未启动 · 无窗口运行',
72
+ menuAndroidEmulators: 'Android 模拟器',
73
+ menuIosEmulators: 'iOS 模拟器',
74
+ menuIosDevices: 'iOS 真机',
75
+ menuIosMirrorOpen: '打开镜像',
76
+ menuIosWdaHint: '首次启动需要构建 WebDriverAgent,约 2–5 分钟。',
77
+ iosHome: '主屏幕',
78
+ iosPreparing: '正在准备 iOS 镜像…',
79
+ iosStarting: '正在启动 WebDriverAgent…',
80
+ iosBuilding: '正在构建 WebDriverAgent…',
81
+ iosReady: '已连接',
82
+ iosFailed: 'iOS 镜像启动失败',
83
+ iosStop: '停止镜像',
84
+ menuShutdown: '已关机',
85
+ menuStart: '启动',
86
+ menuStartingBtn: '启动中',
87
+ menuStop: '停止模拟器',
88
+ menuOpenMirror: '打开镜像',
89
+ menuOpenSimulator: '打开 Simulator',
90
+ menuWifiPlaceholder: '192.168.1.102:5555',
91
+ menuConnect: '连接',
92
+ menuConnecting: '连接中',
93
+ menuRefresh: '重新检测设备',
94
+ menuAll: '全部',
95
+ menuCollapse: '收起',
96
+ menuEmpty: '没有可用模拟器',
97
+ menuEmptyHint: '可连接无线 ADB 真机,或安装 Android AVD。',
98
+ menuIosUnsupported: '已打开 Simulator;iOS 画面暂不支持镜像。',
99
+ menuJustStarted: '刚刚启动',
100
+ menuMinutes: '分',
101
+ menuSeconds: '秒',
102
+ menuHours: '小时',
103
+ menuDeviceUnit: '台',
104
+ menuItemUnit: '个',
105
+ menuStartingWait: '模拟器正在启动,就绪后可打开镜像',
106
+ menuStopRequested: '已请求停止',
107
+ menuOnline: '台在线',
108
+ menuNoDevice: '没有设备在线,点击启动模拟器',
109
+ railBack: '返回',
110
+ railHome: '主页',
111
+ railRecents: '最近任务',
112
+ railText: '输入',
113
+ railLink: '链接',
114
+ railFile: '文件',
115
+ railHistory: '历史',
116
+ railCapture: '截图',
117
+ railRefresh: '重连',
118
+ railClose: '收起',
119
+ },
120
+ en: {
121
+ title: 'Device Deck',
122
+ live: 'Live',
123
+ connecting: 'Connecting',
124
+ offline: 'Offline',
125
+ noDevice: 'No Android device found',
126
+ noDeviceHint: 'Connect USB with debugging on, or start an emulator.',
127
+ adbMissing: 'adb not found',
128
+ refresh: 'Refresh devices',
129
+ capture: 'Screenshot',
130
+ menuShot: 'Device screenshot',
131
+ menuShotHint: 'Pick a screenshot and add it to the composer',
132
+ pickerTitle: 'Choose a screenshot',
133
+ pickerHint: 'Click a screenshot to add it to the composer',
134
+ pickerLoading: 'Loading screenshots…',
135
+ pickerEmpty: 'No screenshots yet. Capture one in the device mirror first.',
136
+ pickerLoadFailed: 'Cannot load screenshots: {error}',
137
+ pickerAttachFailed: 'Cannot add the screenshot: {error}',
138
+ pickerBusy: 'Adding…',
139
+ attachUnavailable: 'The composer is not available',
140
+ history: 'Screenshot history',
141
+ close: 'Minimize',
142
+ back: 'Back',
143
+ home: 'Home',
144
+ text: 'Text input',
145
+ link: 'Open link',
146
+ textHint: 'ASCII only; use the device IME for other scripts.',
147
+ sendText: 'Send',
148
+ open: 'Open',
149
+ urlHint: 'https://… or app://…',
150
+ retry: 'Reconnect',
151
+ streamError: 'Cannot connect to the device stream',
152
+ disconnected: 'Device stream disconnected',
153
+ emptyShot: 'No screenshots yet',
154
+ selected: 'Selected',
155
+ shotSaved: 'Screenshot saved',
156
+ fileSending: 'Sending {name}',
157
+ fileSent: 'Sent to {path}',
158
+ fileFailed: 'Send failed: {error}',
159
+ connectingHint: 'Connecting to the device…',
160
+ switchDevice: 'Switch device',
161
+ screenshotStrip: 'Shots',
162
+ enterText: 'Focus a field on the device, then type here',
163
+ enterUrl: 'Type a URL and press Enter',
164
+ menuRunning: 'Running',
165
+ menuNoOnline: 'No device online',
166
+ menuReady: 'Ready',
167
+ menuStarting: 'Starting',
168
+ menuEmulator: 'Emulator',
169
+ menuReal: 'Device',
170
+ menuIosEmulator: 'iOS simulator',
171
+ menuNotStarted: 'Stopped · headless',
172
+ menuAndroidEmulators: 'Android emulators',
173
+ menuIosEmulators: 'iOS simulators',
174
+ menuIosDevices: 'iOS devices',
175
+ menuIosMirrorOpen: 'Open mirror',
176
+ menuIosWdaHint: 'The first launch builds WebDriverAgent and takes 2–5 minutes.',
177
+ iosHome: 'Home',
178
+ iosPreparing: 'Preparing the iOS mirror…',
179
+ iosStarting: 'Starting WebDriverAgent…',
180
+ iosBuilding: 'Building WebDriverAgent…',
181
+ iosReady: 'Connected',
182
+ iosFailed: 'Cannot start the iOS mirror',
183
+ iosStop: 'Stop mirror',
184
+ menuShutdown: 'Shutdown',
185
+ menuStart: 'Start',
186
+ menuStartingBtn: 'Starting',
187
+ menuStop: 'Stop emulator',
188
+ menuOpenMirror: 'Open mirror',
189
+ menuOpenSimulator: 'Open Simulator',
190
+ menuWifiPlaceholder: '192.168.1.102:5555',
191
+ menuConnect: 'Connect',
192
+ menuConnecting: 'Connecting',
193
+ menuRefresh: 'Refresh devices',
194
+ menuAll: 'All',
195
+ menuCollapse: 'Collapse',
196
+ menuEmpty: 'No simulator available',
197
+ menuEmptyHint: 'Connect a wireless ADB device, or install an Android AVD.',
198
+ menuIosUnsupported: 'Simulator opened; the iOS mirror is not supported yet.',
199
+ menuJustStarted: 'just started',
200
+ menuMinutes: 'min',
201
+ menuSeconds: 's',
202
+ menuHours: 'h',
203
+ menuDeviceUnit: 'devices',
204
+ menuItemUnit: 'AVDs',
205
+ menuStartingWait: 'The emulator is starting; open the mirror once it is ready.',
206
+ menuStopRequested: 'Stop requested',
207
+ menuOnline: 'online',
208
+ menuNoDevice: 'No device online. Click to start an emulator.',
209
+ railBack: 'Back',
210
+ railHome: 'Home',
211
+ railRecents: 'Recents',
212
+ railText: 'Text',
213
+ railLink: 'Link',
214
+ railFile: 'File',
215
+ railHistory: 'History',
216
+ railCapture: 'Screenshot',
217
+ railRefresh: 'Reconnect',
218
+ railClose: 'Close',
219
+ },
220
+ };
221
+
222
+ function loadStore() {
223
+ try {
224
+ const raw = window.localStorage.getItem(STORE_KEY);
225
+ const parsed = raw ? JSON.parse(raw) : null;
226
+ return {
227
+ open: Boolean(parsed?.open),
228
+ platform: parsed?.platform === 'ios' ? 'ios' : 'android',
229
+ iosKind: parsed?.iosKind === 'simulator' ? 'simulator' : 'device',
230
+ serial: typeof parsed?.serial === 'string' ? parsed.serial : '',
231
+ offset: {
232
+ x: Number.isFinite(parsed?.offset?.x) ? parsed.offset.x : 0,
233
+ y: Number.isFinite(parsed?.offset?.y) ? parsed.offset.y : 0,
234
+ },
235
+ };
236
+ } catch {
237
+ return { open: false, platform: 'android', iosKind: 'device', serial: '', offset: { x: 0, y: 0 } };
238
+ }
239
+ }
240
+
241
+ const shared = loadStore();
242
+ const openListeners = new Set();
243
+
244
+ function saveStore() {
245
+ try {
246
+ window.localStorage.setItem(STORE_KEY, JSON.stringify(shared));
247
+ } catch {
248
+ /* ignore */
249
+ }
250
+ }
251
+
252
+ function setSharedOpen(next) {
253
+ if (shared.open === next) return;
254
+ shared.open = next;
255
+ saveStore();
256
+ for (const listener of openListeners) listener();
257
+ }
258
+
259
+ function notifyStore() {
260
+ for (const listener of openListeners) listener();
261
+ }
262
+
263
+ function subscribeStore(listener) {
264
+ openListeners.add(listener);
265
+ return () => openListeners.delete(listener);
266
+ }
267
+
268
+ function useSharedOpen() {
269
+ return useSyncExternalStore(subscribeStore, () => shared.open);
270
+ }
271
+
272
+ function useSharedPlatform() {
273
+ return useSyncExternalStore(subscribeStore, () => shared.platform);
274
+ }
275
+
276
+ function currentCopy(ctx) {
277
+ const active = ctx.locale?.getLocale?.().active || 'en';
278
+ return String(active).toLowerCase().startsWith('zh') ? COPY.zh : COPY.en;
279
+ }
280
+
281
+ function useCopy(ctx) {
282
+ const [copy, setCopy] = useState(() => currentCopy(ctx));
283
+ useEffect(() => {
284
+ if (!ctx.locale?.subscribe) return undefined;
285
+ return ctx.locale.subscribe(() => setCopy(currentCopy(ctx)));
286
+ }, [ctx]);
287
+ return copy;
288
+ }
289
+
290
+ async function apiJson(path, init) {
291
+ const response = await fetch(`${API}${path}`, {
292
+ cache: 'no-store',
293
+ ...init,
294
+ });
295
+ const data = await response.json().catch(() => null);
296
+ if (!response.ok) throw new Error(data?.error || `HTTP ${response.status}`);
297
+ return data;
298
+ }
299
+
300
+ function postJson(path, value) {
301
+ return apiJson(path, {
302
+ method: 'POST',
303
+ headers: { 'Content-Type': 'application/json' },
304
+ body: JSON.stringify(value),
305
+ });
306
+ }
307
+
308
+ function shotUrl(shotPath) {
309
+ return `${API}/file?path=${encodeURIComponent(shotPath)}`;
310
+ }
311
+
312
+ const shotPicker = {
313
+ sessionId: null,
314
+ listeners: new Set(),
315
+ };
316
+
317
+ function notifyShotPicker() {
318
+ for (const listener of shotPicker.listeners) listener();
319
+ }
320
+
321
+ function openShotPicker(sessionId) {
322
+ if (!sessionId) return;
323
+ shotPicker.sessionId = sessionId;
324
+ notifyShotPicker();
325
+ }
326
+
327
+ function closeShotPicker(sessionId) {
328
+ if (sessionId !== undefined && shotPicker.sessionId !== sessionId) return;
329
+ shotPicker.sessionId = null;
330
+ notifyShotPicker();
331
+ }
332
+
333
+ function useShotPickerOpen(sessionId) {
334
+ const [open, setOpen] = useState(shotPicker.sessionId === sessionId);
335
+ useEffect(() => {
336
+ const sync = () => setOpen(shotPicker.sessionId === sessionId);
337
+ shotPicker.listeners.add(sync);
338
+ sync();
339
+ return () => {
340
+ shotPicker.listeners.delete(sync);
341
+ if (shotPicker.sessionId === sessionId) closeShotPicker(sessionId);
342
+ };
343
+ }, [sessionId]);
344
+ return open;
345
+ }
346
+
347
+ async function attachShot(ctx, sessionId, inputActions, shot, t) {
348
+ const conversation = ctx.conversation || ctx.get?.('conversation');
349
+ if (!conversation?.createDrafts) throw new Error(t.attachUnavailable);
350
+ const binding = ctx.sessions?.binding?.(sessionId);
351
+ if (!binding) throw new Error(t.attachUnavailable);
352
+ let actions = inputActions;
353
+ if (!actions?.addAttachments) {
354
+ try {
355
+ actions = conversation.input?.for?.(binding.ctx)?.actions;
356
+ } catch {
357
+ /* fall through to the unavailable error */
358
+ }
359
+ }
360
+ if (!actions?.addAttachments) throw new Error(t.attachUnavailable);
361
+ const response = await fetch(shotUrl(shot.path), { cache: 'no-store' });
362
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
363
+ const blob = await response.blob();
364
+ const file = new File([blob], shot.name || `device-shot-${Date.now()}.png`, { type: 'image/png' });
365
+ const ordinary = binding.session.getSnapshot().subagent === null;
366
+ const drafts = conversation.createDrafts(sessionId, [file], ordinary);
367
+ if (!actions.addAttachments(drafts.map((draft) => draft.id))) {
368
+ conversation.releaseDraftAttachments?.(drafts);
369
+ throw new Error(t.attachUnavailable);
370
+ }
371
+ try {
372
+ conversation.input?.for?.(binding.ctx)?.focus?.();
373
+ } catch {
374
+ /* focus is best-effort */
375
+ }
376
+ }
377
+
378
+ function shotTimeText(at) {
379
+ const date = new Date(at);
380
+ if (Number.isNaN(date.getTime())) return '';
381
+ return date.toLocaleString(undefined, {
382
+ month: 'numeric',
383
+ day: 'numeric',
384
+ hour: '2-digit',
385
+ minute: '2-digit',
386
+ });
387
+ }
388
+
389
+ function ShotPicker({ ctx, sessionId, inputActions, onClose }) {
390
+ const t = useCopy(ctx);
391
+ const [shots, setShots] = useState([]);
392
+ const [loading, setLoading] = useState(true);
393
+ const [error, setError] = useState('');
394
+ const [busy, setBusy] = useState('');
395
+
396
+ useEffect(() => {
397
+ let alive = true;
398
+ apiJson('/screenshots')
399
+ .then((data) => {
400
+ if (alive) setShots(Array.isArray(data.items) ? data.items : []);
401
+ })
402
+ .catch((loadError) => {
403
+ if (alive) setError(t.pickerLoadFailed.replace('{error}', loadError.message));
404
+ })
405
+ .finally(() => {
406
+ if (alive) setLoading(false);
407
+ });
408
+ return () => {
409
+ alive = false;
410
+ };
411
+ }, [t]);
412
+
413
+ useEffect(() => {
414
+ const onKeyDown = (event) => {
415
+ if (event.key === 'Escape') {
416
+ event.preventDefault();
417
+ onClose();
418
+ }
419
+ };
420
+ window.addEventListener('keydown', onKeyDown);
421
+ return () => window.removeEventListener('keydown', onKeyDown);
422
+ }, [onClose]);
423
+
424
+ const choose = async (shot) => {
425
+ if (busy) return;
426
+ setBusy(shot.path);
427
+ setError('');
428
+ try {
429
+ await attachShot(ctx, sessionId, inputActions, shot, t);
430
+ onClose();
431
+ } catch (attachError) {
432
+ setError(t.pickerAttachFailed.replace('{error}', attachError.message));
433
+ } finally {
434
+ setBusy('');
435
+ }
436
+ };
437
+
438
+ return ReactDOM.createPortal(
439
+ h('div', {
440
+ className: 'dd-shot-mask',
441
+ onMouseDown: (event) => {
442
+ if (event.target === event.currentTarget) onClose();
443
+ },
444
+ },
445
+ h('div', {
446
+ className: 'dd-shot-panel',
447
+ role: 'dialog',
448
+ 'aria-modal': 'true',
449
+ 'aria-label': t.pickerTitle,
450
+ },
451
+ h('div', { className: 'dd-shot-head' },
452
+ h('div', { className: 'dd-shot-titles' },
453
+ h('strong', null, t.pickerTitle),
454
+ h('span', null, t.pickerHint),
455
+ ),
456
+ h('button', {
457
+ type: 'button',
458
+ className: 'dd-shot-close',
459
+ title: t.close,
460
+ 'aria-label': t.close,
461
+ onClick: onClose,
462
+ }, h(Icon, { name: 'close', size: 12 })),
463
+ ),
464
+
465
+ loading
466
+ ? h('div', { className: 'dd-shot-state' }, h('span', { className: 'dd-spinner' }), t.pickerLoading)
467
+ : error && shots.length === 0
468
+ ? h('div', { className: 'dd-shot-state' }, error)
469
+ : shots.length === 0
470
+ ? h('div', { className: 'dd-shot-state' }, t.pickerEmpty)
471
+ : h(Fragment, null,
472
+ error && h('div', { className: 'dd-shot-error' }, error),
473
+ h('div', { className: 'dd-shot-grid' },
474
+ shots.map((shot) => h('button', {
475
+ key: shot.path,
476
+ type: 'button',
477
+ className: 'dd-shot-item',
478
+ 'data-busy': busy === shot.path ? 'true' : 'false',
479
+ disabled: Boolean(busy),
480
+ title: new Date(shot.at).toLocaleString(),
481
+ onClick: () => void choose(shot),
482
+ },
483
+ h('img', { src: shotUrl(shot.path), alt: shot.name, draggable: false }),
484
+ h('span', { className: 'dd-shot-time' }, busy === shot.path ? t.pickerBusy : shotTimeText(shot.at)),
485
+ )),
486
+ ),
487
+ ),
488
+ ),
489
+ ),
490
+ document.body,
491
+ );
492
+ }
493
+
494
+ function concatBytes(left, right) {
495
+ if (left.length === 0) return right;
496
+ const out = new Uint8Array(left.length + right.length);
497
+ out.set(left, 0);
498
+ out.set(right, left.length);
499
+ return out;
500
+ }
501
+
502
+ function avcCodecString(data) {
503
+ let nal = -1;
504
+ for (let index = 0; index + 3 < data.length; index += 1) {
505
+ if (data[index] !== 0 || data[index + 1] !== 0) continue;
506
+ if (data[index + 2] === 1) {
507
+ nal = index + 3;
508
+ break;
509
+ }
510
+ if (data[index + 2] === 0 && data[index + 3] === 1) {
511
+ nal = index + 4;
512
+ break;
513
+ }
514
+ }
515
+ if (nal < 0 || nal + 3 >= data.length) return 'avc1.42E01E';
516
+ const hex = (value) => Number(value || 0).toString(16).toUpperCase().padStart(2, '0');
517
+ return `avc1.${hex(data[nal + 1])}${hex(data[nal + 2])}${hex(data[nal + 3])}`;
518
+ }
519
+
520
+ function statusLabel(t, status) {
521
+ if (status === 'live') return t.live;
522
+ if (status === 'connecting') return t.connecting;
523
+ return t.offline;
524
+ }
525
+
526
+ function Icon({ name, size = 15 }) {
527
+ const common = {
528
+ width: size,
529
+ height: size,
530
+ viewBox: '0 0 24 24',
531
+ fill: 'none',
532
+ stroke: 'currentColor',
533
+ strokeWidth: 1.8,
534
+ strokeLinecap: 'round',
535
+ strokeLinejoin: 'round',
536
+ 'aria-hidden': true,
537
+ style: { display: 'block' },
538
+ };
539
+ switch (name) {
540
+ case 'phone':
541
+ return h('svg', common, h('rect', { x: 6, y: 2.5, width: 12, height: 19, rx: 2.6 }), h('path', { d: 'M10 5.5h4' }), h('circle', { cx: 12, cy: 18.4, r: 0.7, fill: 'currentColor', stroke: 'none' }));
542
+ case 'camera':
543
+ return h('svg', common, h('path', { d: 'M3 8.5h3.2l1.4-2h8.8l1.4 2H21v11H3z' }), h('circle', { cx: 12, cy: 13.5, r: 3.4 }));
544
+ case 'refresh':
545
+ return h('svg', common, h('path', { d: 'M20 11a8 8 0 1 0-2.3 6.1' }), h('path', { d: 'M20 4v7h-7' }));
546
+ case 'history':
547
+ return h('svg', common,
548
+ h('path', { d: 'M18 22H4a2 2 0 0 1-2-2V6' }),
549
+ h('path', { d: 'm22 13-1.296-1.296a2.41 2.41 0 0 0-3.408 0L11 18' }),
550
+ h('circle', { cx: 12, cy: 8, r: 2 }),
551
+ h('rect', { x: 6, y: 2, width: 16, height: 16, rx: 2 }),
552
+ );
553
+ case 'close':
554
+ return h('svg', common, h('path', { d: 'M6 6l12 12M18 6 6 18' }));
555
+ case 'minimize':
556
+ return h('svg', common, h('path', { d: 'M5 12h14' }));
557
+ case 'back':
558
+ return h('svg', common, h('path', { d: 'M15 5l-7 7 7 7' }));
559
+ case 'home':
560
+ return h('svg', common, h('path', { d: 'M4 11 12 4l8 7' }), h('path', { d: 'M6.5 10v9.5h11V10' }));
561
+ case 'recents':
562
+ return h('svg', common, h('rect', { x: 8, y: 3.5, width: 12.5, height: 12.5, rx: 2.2 }), h('rect', { x: 3.5, y: 8, width: 12.5, height: 12.5, rx: 2.2 }));
563
+ case 'keyboard':
564
+ return h('svg', common, h('rect', { x: 2.5, y: 6, width: 19, height: 12, rx: 2.2 }), h('path', { d: 'M6 10h1M10 10h1M14 10h1M18 10h.5M7 14h10' }));
565
+ case 'link':
566
+ return h('svg', common, h('path', { d: 'M10 14a4 4 0 0 0 5.7 0l2.6-2.6a4 4 0 1 0-5.7-5.7L11.5 6.8' }), h('path', { d: 'M14 10a4 4 0 0 0-5.7 0l-2.6 2.6a4 4 0 1 0 5.7 5.7l1.1-1.1' }));
567
+ case 'paperclip':
568
+ return h('svg', common, h('path', { d: 'm21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48' }));
569
+ case 'chevron':
570
+ return h('svg', common, h('path', { d: 'M6 9l6 6 6-6' }));
571
+ case 'alert':
572
+ return h('svg', common, h('path', { d: 'M12 3 2.5 20h19z' }), h('path', { d: 'M12 9v5M12 17.2v.1' }));
573
+ case 'android':
574
+ return h('svg', common, h('path', { d: 'M7 9h10v7.5a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 7 16.5z' }), h('path', { d: 'M9 9 7.5 5.5M15 9l1.5-3.5' }), h('path', { d: 'M5 10.5v5M19 10.5v5' }));
575
+ case 'monitorplay':
576
+ return h('svg', common, h('rect', { x: 3, y: 4, width: 18, height: 13, rx: 2 }), h('path', { d: 'M10 8.5l5 2.5-5 2.5z' }), h('path', { d: 'M12 17v3M9 20h6' }));
577
+ case 'power':
578
+ return h('svg', common, h('path', { d: 'M12 3v8' }), h('path', { d: 'M6.7 6.7a8 8 0 1 0 10.6 0' }));
579
+ case 'wifi':
580
+ return h('svg', common, h('path', { d: 'M3.5 9.5a13 13 0 0 1 17 0' }), h('path', { d: 'M6.5 13a8.5 8.5 0 0 1 11 0' }), h('path', { d: 'M9.5 16.5a4 4 0 0 1 5 0' }), h('circle', { cx: 12, cy: 19.5, r: 0.7, fill: 'currentColor', stroke: 'none' }));
581
+ case 'play':
582
+ return h('svg', common, h('path', { d: 'M8 5.5l10 6.5-10 6.5z' }));
583
+ case 'external':
584
+ return h('svg', common, h('path', { d: 'M14 4h6v6' }), h('path', { d: 'M20 4 11 13' }), h('path', { d: 'M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5' }));
585
+ default:
586
+ return null;
587
+ }
588
+ }
589
+
590
+ function LauncherIcon({ size = 16 }) {
591
+ return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, style: { display: 'block' } },
592
+ h('rect', { x: 6.5, y: 2.5, width: 11, height: 19, rx: 2.5 }),
593
+ h('path', { d: 'M10.5 5.5h3' }),
594
+ h('circle', { cx: 12, cy: 18.2, r: 0.6, fill: 'currentColor', stroke: 'none' }),
595
+ );
596
+ }
597
+
598
+ const CSS = `
599
+ .dd-launcher{position:relative;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:background .15s ease,color .15s ease}
600
+ .dd-launcher:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
601
+ .dd-launcher[data-active="true"]{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
602
+ .dd-launcher[data-count="true"]{width:auto;padding:0 4px;gap:3px}
603
+ .dd-launcher-count{font-size:10px;font-weight:500;line-height:1;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
604
+ .dd-launcher:hover .dd-launcher-count{color:var(--dsw-alias-label-primary)}
605
+ .dd-panel{position:fixed;right:18px;top:64px;display:flex;align-items:center;z-index:9000;font-family:inherit;color:#e4e4e7;pointer-events:none}
606
+ .dd-frame{position:relative;flex:none;display:flex;flex-direction:column;height:min(70vh,840px);max-height:calc(100vh - 72px);background:#18181b;border:1px solid #27272a;border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.14);overflow:visible;color:#f4f4f5;pointer-events:auto;transition:width .2s ease;animation:dd-in .18s ease-out}
607
+ .dd-head{height:36px;flex:none;display:flex;align-items:center;gap:4px;padding:0 8px;background:#27272a;border-bottom:1px solid rgba(63,63,70,.6);border-radius:10px 10px 0 0;user-select:none;cursor:grab}
608
+ .dd-head:active{cursor:grabbing}
609
+ .dd-head-left{display:flex;align-items:center;justify-content:space-between;gap:5px;min-width:0;flex:1}
610
+ .dd-device{display:flex;align-items:center;gap:4px;min-width:0;height:22px;padding:0 4px;margin-left:-4px;border:0;border-radius:5px;background:transparent;color:#f4f4f5;cursor:pointer;font:inherit}
611
+ .dd-device:hover{background:rgba(255,255,255,.07)}
612
+ .dd-device svg:first-child{color:#10b981;flex:none}
613
+ .dd-device svg:last-child{color:#71717a;flex:none;transition:transform .15s ease}
614
+ .dd-device[data-open="true"] svg:last-child{transform:rotate(180deg)}
615
+ .dd-device-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:500}
616
+ .dd-status{display:inline-flex;align-items:center;gap:3px;height:18px;padding:0 5px;border-radius:4px;font-size:10px;font-weight:500;white-space:nowrap;border:1px solid transparent;flex:none}
617
+ .dd-status[data-compact="true"]{padding:0 4px}
618
+ .dd-status[data-status="live"]{background:rgba(16,185,129,.12);color:#34d399;border-color:rgba(16,185,129,.2)}
619
+ .dd-status[data-status="connecting"]{background:rgba(234,179,8,.12);color:#facc15;border-color:rgba(234,179,8,.2)}
620
+ .dd-status[data-status="error"],.dd-status[data-status="offline"]{background:rgba(239,68,68,.12);color:#f87171;border-color:rgba(239,68,68,.2)}
621
+ .dd-dot{width:4px;height:4px;border-radius:50%;background:currentColor}
622
+ .dd-head-right{display:flex;align-items:center;gap:2px;flex:none}
623
+ .dd-head-refresh{display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:transparent;color:#a1a1aa;cursor:pointer;transition:background .15s ease,color .15s ease}
624
+ .dd-head-refresh:hover{background:rgba(255,255,255,.1);color:#f4f4f5}
625
+ .dd-body{position:relative;flex:1;min-height:0;display:flex}
626
+ .dd-rail{position:absolute;right:calc(100% + 2px);top:50%;transform:translateY(-50%);width:32px;display:flex;flex-direction:column;background:#fff;border:1px solid #e3e3e8;border-right:0;border-radius:9px 0 0 9px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.06);pointer-events:auto;z-index:2}
627
+ .dd-rail button{display:flex;align-items:center;justify-content:center;width:100%;height:38px;border:0;border-bottom:1px solid #f1f1f4;background:transparent;color:#63636b;cursor:pointer;transition:background .15s ease,color .15s ease}
628
+ .dd-rail button:last-child{border-bottom:0}
629
+ .dd-rail-sep{height:1px;flex:none;background:#f1f1f4}
630
+ .dd-rail-tip{position:fixed;z-index:9600;transform:translateY(-50%);padding:3px 7px;border-radius:5px;background:rgba(24,24,27,.95);border:1px solid rgba(255,255,255,.14);color:#f4f4f5;font-size:10.5px;line-height:1.4;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.2)}
631
+ .dd-rail button:hover:not([data-disabled="true"]){background:#fafafc;color:#1c1c21}
632
+ .dd-rail button[data-disabled="true"]{opacity:.35;cursor:default}
633
+ .dd-rail button[data-active="true"]{background:#eeedfd;color:#4f46e5}
634
+ .dd-viewport{position:relative;flex:1;min-width:0;background:#000;overflow:hidden;touch-action:none;cursor:crosshair;border-radius:0 0 10px 10px}
635
+ .dd-canvas{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block}
636
+ .dd-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:rgba(0,0,0,.82);color:#e4e4e7;text-align:center;padding:18px;font-size:12px;line-height:1.6;z-index:3}
637
+ .dd-overlay strong{font-size:12.5px;font-weight:600;color:#fafafa}
638
+ .dd-overlay p{margin:0;color:#a1a1aa;max-width:250px}
639
+ .dd-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:28px;padding:0 12px;border:0;border-radius:6px;background:#3f3f46;color:#fafafa;font:inherit;font-size:11.5px;font-weight:500;cursor:pointer;transition:background .15s ease}
640
+ .dd-btn:hover{background:#52525b}
641
+ .dd-spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.2);border-top-color:#a5b4fc;border-radius:50%;animation:dd-spin .9s linear infinite}
642
+ @keyframes dd-spin{to{transform:rotate(360deg)}}
643
+ .dd-ripple{position:absolute;width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;border:1px solid rgba(255,255,255,.9);background:rgba(255,255,255,.35);pointer-events:none;animation:dd-ripple .28s ease-out forwards}
644
+ @keyframes dd-ripple{from{transform:scale(.5);opacity:1}to{transform:scale(1.6);opacity:0}}
645
+ @keyframes dd-in{from{opacity:0}to{opacity:1}}
646
+ .dd-strip{height:50px;flex:none;display:flex;align-items:center;gap:5px;padding:0 8px;background:#1c1c21;border-top:1px solid rgba(63,63,70,.6);border-radius:0 0 10px 10px;overflow-x:auto;overflow-y:hidden}
647
+ .dd-strip-label{flex:none;font-size:10px;color:#71717a;padding-right:2px}
648
+ .dd-thumb{flex:none;height:38px;padding:0;border:1px solid rgba(255,255,255,.14);border-radius:4px;background:#000;overflow:hidden;cursor:pointer;opacity:.75;transition:opacity .15s ease,border-color .15s ease}
649
+ .dd-thumb:hover{opacity:1}
650
+ .dd-thumb[data-selected="true"]{border-color:#818cf8;box-shadow:0 0 0 1px rgba(129,140,248,.7);opacity:1}
651
+ .dd-thumb img{height:100%;width:auto;display:block;object-fit:cover}
652
+ .dd-pop{position:absolute;right:42px;top:50%;transform:translateY(-50%);width:240px;display:flex;flex-direction:column;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.26);z-index:6;overflow:hidden;color:var(--dsw-alias-label-primary);pointer-events:auto}
653
+ .dd-pop-head{display:flex;align-items:center;justify-content:space-between;gap:8px;height:34px;padding:0 8px 0 10px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:11.5px;font-weight:500;flex:none}
654
+ .dd-pop-head button{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
655
+ .dd-pop-head button:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
656
+ .dd-pop textarea{width:100%;height:92px;resize:none;border:0;outline:none;background:transparent;color:var(--dsw-alias-label-primary);padding:9px 10px;font:inherit;font-size:11.5px;line-height:1.6}
657
+ .dd-pop input{width:100%;height:36px;border:0;outline:none;background:transparent;color:var(--dsw-alias-label-primary);padding:0 10px;font:inherit;font-size:11.5px}
658
+ .dd-pop-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-top:1px solid var(--dsw-alias-border-l1)}
659
+ .dd-pop-hint{font-size:10px;color:var(--dsw-alias-label-secondary);line-height:1.45;min-width:0}
660
+ .dd-send{border:0;border-radius:6px;height:24px;padding:0 10px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base);font:inherit;font-size:11px;font-weight:500;cursor:pointer;flex:none}
661
+ .dd-send:disabled{opacity:.35;cursor:default}
662
+ .dd-menu{position:absolute;left:8px;top:40px;width:238px;max-height:300px;overflow-y:auto;padding:4px;background:#232327;border:1px solid rgba(255,255,255,.12);border-radius:8px;box-shadow:0 18px 40px rgba(0,0,0,.45);z-index:8}
663
+ .dd-menu-title{padding:5px 7px 4px;font-size:9.5px;letter-spacing:.04em;text-transform:uppercase;color:#71717a}
664
+ .dd-menu-item{display:flex;align-items:center;gap:7px;width:100%;height:30px;padding:0 7px;border:0;border-radius:6px;background:transparent;color:#e4e4e7;cursor:pointer;font:inherit;text-align:left}
665
+ .dd-menu-item:hover{background:rgba(255,255,255,.07)}
666
+ .dd-menu-item[data-active="true"]{background:rgba(99,102,241,.18)}
667
+ .dd-menu-item span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11.5px}
668
+ .dd-menu-item em{flex:none;margin-left:auto;font-style:normal;font-size:9.5px;color:#71717a}
669
+ .dd-notice{position:absolute;left:50%;bottom:68px;transform:translateX(-50%);max-width:300px;padding:6px 11px;border-radius:7px;background:rgba(24,24,27,.94);border:1px solid rgba(255,255,255,.14);color:#e4e4e7;font-size:11px;line-height:1.45;text-align:center;z-index:9;box-shadow:0 8px 20px rgba(0,0,0,.3);pointer-events:auto}
670
+ .dd-picker{position:fixed;z-index:9500;width:min(300px,calc(100vw - 16px));display:flex;flex-direction:column;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.14);overflow:hidden;color:var(--dsw-alias-label-primary);font-family:inherit;pointer-events:auto}
671
+ .dd-picker-scroll{flex:1;min-height:0;overflow-y:auto}
672
+ .dd-picker-section{height:26px;padding:0 12px;display:flex;align-items:center;justify-content:space-between;background:var(--dsw-alias-bg-layer-2);border-top:1px solid var(--dsw-alias-border-l1);border-bottom:1px solid var(--dsw-alias-border-l1);font-size:10.5px;color:var(--dsw-alias-label-secondary)}
673
+ .dd-picker-section:first-child{border-top:0}
674
+ .dd-picker-row{display:flex;align-items:center;gap:7px;padding:6px 12px;border-bottom:1px solid var(--dsw-alias-border-l1)}
675
+ .dd-picker-row:last-child{border-bottom:0}
676
+ .dd-picker-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;border:0;background:transparent;color:inherit;font:inherit;text-align:left;padding:0;cursor:pointer}
677
+ .dd-picker-line{display:flex;align-items:center;gap:6px;min-width:0}
678
+ .dd-picker-name{font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
679
+ .dd-picker-main:hover .dd-picker-name{color:var(--dsw-alias-brand-primary)}
680
+ .dd-picker-detail{font-size:10.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
681
+ .dd-picker-status{flex:none;display:inline-flex;align-items:center;gap:3px;height:16px;padding:0 5px;border-radius:4px;font-size:9.5px;font-weight:500}
682
+ .dd-picker-status[data-state="booted"]{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent);color:var(--dsw-alias-state-success-primary)}
683
+ .dd-picker-status[data-state="starting"]{background:color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);color:var(--dsw-alias-brand-primary)}
684
+ .dd-picker-icon{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
685
+ .dd-picker-icon:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
686
+ .dd-picker-icon[data-danger="true"]:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary)}
687
+ .dd-picker-icon:disabled{opacity:.4;cursor:default}
688
+ .dd-picker-start{flex:none;display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 8px;border:0;border-radius:5px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base);font:inherit;font-size:10.5px;font-weight:500;cursor:pointer}
689
+ .dd-picker-start:hover:not(:disabled){opacity:.9}
690
+ .dd-picker-start:disabled{opacity:.4;cursor:default}
691
+ .dd-picker-more{width:100%;height:24px;padding:0 12px;display:flex;align-items:center;gap:4px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:10.5px;cursor:pointer}
692
+ .dd-picker-more:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-brand-primary)}
693
+ .dd-picker-empty{padding:16px 12px;text-align:center;font-size:11px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
694
+ .dd-picker-notice{padding:0 12px 7px;font-size:10.5px;line-height:1.45;color:var(--dsw-alias-label-secondary)}
695
+ .dd-picker-wifi{border-top:1px solid var(--dsw-alias-border-l1);padding:8px 12px;display:flex;flex-direction:column;gap:5px;background:var(--dsw-alias-bg-base)}
696
+ .dd-picker-wifi-row{display:flex;align-items:center;gap:6px}
697
+ .dd-picker-input{flex:1;min-width:0;height:24px;padding:0 7px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;outline:none}
698
+ .dd-picker-input:focus{border-color:var(--dsw-alias-brand-primary)}
699
+ .dd-picker-result{display:flex;align-items:center;gap:4px;font-size:10.5px;color:var(--dsw-alias-label-secondary);overflow:hidden}
700
+ .dd-picker-result[data-ok="false"]{color:var(--dsw-alias-state-error-primary)}
701
+ .dd-picker-result[data-ok="true"]{color:var(--dsw-alias-state-success-primary)}
702
+ .dd-picker-spinner{width:10px;height:10px;border:1.5px solid color-mix(in srgb, currentColor 25%, transparent);border-top-color:currentColor;border-radius:50%;animation:dd-spin .8s linear infinite;display:inline-block;flex:none}
703
+ .dd-shot-mask{position:fixed;inset:0;z-index:9450;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(0,0,0,.5);backdrop-filter:blur(2px)}
704
+ .dd-shot-panel{width:min(560px,calc(100vw - 32px));max-height:min(72vh,640px);display:flex;flex-direction:column;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.35);overflow:hidden;color:var(--dsw-alias-label-primary);font-family:inherit;animation:dd-in .16s ease-out}
705
+ .dd-shot-head{flex:none;display:flex;align-items:flex-start;gap:10px;padding:12px 12px 10px 14px;border-bottom:1px solid var(--dsw-alias-border-l1)}
706
+ .dd-shot-titles{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
707
+ .dd-shot-titles strong{font-size:12.5px;font-weight:600}
708
+ .dd-shot-titles span{font-size:10.5px;line-height:1.45;color:var(--dsw-alias-label-secondary)}
709
+ .dd-shot-close{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
710
+ .dd-shot-close:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
711
+ .dd-shot-grid{flex:1;min-height:0;overflow-y:auto;padding:10px;display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px;align-content:start}
712
+ .dd-shot-item{position:relative;display:flex;flex-direction:column;gap:0;padding:0;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:#000;overflow:hidden;cursor:pointer;color:var(--dsw-alias-label-primary);font:inherit;text-align:center;transition:border-color .15s ease,transform .15s ease}
713
+ .dd-shot-item:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary)}
714
+ .dd-shot-item:disabled{cursor:default}
715
+ .dd-shot-item[data-busy="true"]{border-color:var(--dsw-alias-brand-primary)}
716
+ .dd-shot-item img{display:block;width:100%;aspect-ratio:9/16;object-fit:contain;background:#000;opacity:.92}
717
+ .dd-shot-item:hover:not(:disabled) img{opacity:1}
718
+ .dd-shot-time{display:block;padding:4px 2px;font-size:9.5px;line-height:1.3;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:var(--dsw-alias-bg-base)}
719
+ .dd-shot-item[data-busy="true"] .dd-shot-time{color:var(--dsw-alias-brand-primary)}
720
+ .dd-shot-state{flex:1;min-height:160px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:24px 18px;text-align:center;font-size:11px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
721
+ .dd-shot-state .dd-spinner{border-color:color-mix(in srgb, var(--dsw-alias-label-secondary) 25%, transparent);border-top-color:var(--dsw-alias-brand-primary)}
722
+ .dd-shot-error{flex:none;padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:10.5px;line-height:1.45;color:var(--dsw-alias-state-error-primary)}
723
+ `;
724
+
725
+ function formatDuration(seconds, t) {
726
+ const total = Math.max(0, Math.floor(Number(seconds) || 0));
727
+ if (total < 60) return t.menuJustStarted;
728
+ const minutes = Math.floor(total / 60);
729
+ if (minutes < 60) return `${minutes} ${t.menuMinutes} ${total % 60} ${t.menuSeconds}`;
730
+ return `${Math.floor(minutes / 60)} ${t.menuHours} ${minutes % 60} ${t.menuMinutes}`;
731
+ }
732
+
733
+ function DevicePicker({
734
+ inventory,
735
+ rect,
736
+ menuRef,
737
+ t,
738
+ onOpenAndroid,
739
+ onOpenIos,
740
+ onOpenIosDevice,
741
+ onStartAvd,
742
+ onStartIos,
743
+ onStop,
744
+ onRefresh,
745
+ onNotice,
746
+ launching,
747
+ stopping,
748
+ wifiAddress,
749
+ setWifiAddress,
750
+ onConnectWifi,
751
+ wifiBusy,
752
+ wifiResult,
753
+ notice,
754
+ }) {
755
+ const [expanded, setExpanded] = useState({});
756
+ const android = inventory?.android || { running: [], avds: { available: false, items: [], pending: [], message: '' } };
757
+ const ios = inventory?.ios || { available: false, running: [], pending: [], devices: { available: false, items: [] } };
758
+ const iosDevices = ios.devices?.items || [];
759
+ const running = [
760
+ ...(android.running || []).map((row) => ({ ...row, platform: 'android' })),
761
+ ...(ios.running || []).map((simulator) => ({
762
+ platform: 'ios',
763
+ udid: simulator.udid,
764
+ name: simulator.name,
765
+ serial: simulator.udid,
766
+ state: 'booted',
767
+ kind: 'ios',
768
+ uptimeSeconds: null,
769
+ version: simulator.version,
770
+ })),
771
+ ];
772
+ const pendingAvds = android.avds?.pending || [];
773
+ const pendingIos = ios.pending || [];
774
+ const hasContent = running.length > 0 || pendingAvds.length > 0 || pendingIos.length > 0 || iosDevices.length > 0;
775
+ const limit = (key, total) => (expanded[key] ? total : Math.min(2, total));
776
+ const toggle = (key) => setExpanded((current) => ({ ...current, [key]: !current[key] }));
777
+ const more = (key, total, unit) =>
778
+ total > 2
779
+ ? h('button', {
780
+ key: `more-${key}`,
781
+ type: 'button',
782
+ className: 'dd-picker-more',
783
+ onClick: () => toggle(key),
784
+ },
785
+ h('span', null, expanded[key] ? t.menuCollapse : `${t.menuAll} ${total} ${unit}`),
786
+ h(Icon, { name: 'chevron', size: 9 }),
787
+ )
788
+ : null;
789
+
790
+ const keyOf = (row) => (row.platform === 'ios' ? `ios-${row.udid}` : `android-${row.serial || row.pid}`);
791
+ const kindOf = (row) => (row.platform === 'ios' ? t.menuIosEmulator : row.kind === '真机' ? t.menuReal : t.menuEmulator);
792
+ const detailOf = (row) => {
793
+ if (row.platform === 'ios') {
794
+ return `${t.menuIosEmulator}${row.version ? ` · iOS ${row.version}` : ''}`;
795
+ }
796
+ if (row.state === 'starting') return `${t.menuEmulator} · ${t.menuStarting}`;
797
+ if (row.kind === '真机') return `${t.menuReal} · ${row.serial}`;
798
+ const uptime = row.uptimeSeconds !== null && row.uptimeSeconds !== undefined
799
+ ? formatDuration(row.uptimeSeconds, t)
800
+ : row.serial;
801
+ return `${t.menuEmulator} · ${uptime}`;
802
+ };
803
+ const openRow = (row) => {
804
+ if (row.platform === 'ios') {
805
+ onOpenIosDevice({ ...row, kind: 'simulator' });
806
+ return;
807
+ }
808
+ if (!row.serial) {
809
+ onNotice(t.menuStartingWait);
810
+ return;
811
+ }
812
+ onOpenAndroid(row);
813
+ };
814
+
815
+ return h('div', {
816
+ ref: menuRef,
817
+ className: 'dd-picker',
818
+ style: {
819
+ top: rect.top,
820
+ right: rect.right,
821
+ maxHeight: `calc(100vh - ${Math.max(120, rect.top + 12)}px)`,
822
+ },
823
+ },
824
+ h('div', { className: 'dd-picker-scroll' },
825
+ h('div', { className: 'dd-picker-section' },
826
+ h('span', null, t.menuRunning),
827
+ h('span', null, String(running.length)),
828
+ ),
829
+ running.length === 0
830
+ ? h('div', { className: 'dd-picker-empty', style: { padding: '10px 12px', textAlign: 'left' } }, t.menuNoOnline)
831
+ : h('div', null,
832
+ running.slice(0, limit('running', running.length)).map((row) => h('div', {
833
+ key: keyOf(row),
834
+ className: 'dd-picker-row',
835
+ },
836
+ h('button', {
837
+ type: 'button',
838
+ className: 'dd-picker-main',
839
+ title: t.menuOpenMirror,
840
+ onClick: () => openRow(row),
841
+ },
842
+ h('div', { className: 'dd-picker-line' },
843
+ h('span', {
844
+ style: {
845
+ display: 'inline-flex',
846
+ flex: 'none',
847
+ color: row.platform === 'android' ? '#10b981' : 'var(--dsw-alias-label-secondary)',
848
+ },
849
+ }, h(Icon, { name: 'phone', size: 11 })),
850
+ h('span', { className: 'dd-picker-name' }, row.name),
851
+ h('span', {
852
+ className: 'dd-picker-status',
853
+ 'data-state': row.state === 'booted' ? 'booted' : 'starting',
854
+ }, row.state === 'booted' ? t.menuReady : t.menuStarting),
855
+ ),
856
+ h('div', { className: 'dd-picker-detail' }, detailOf(row)),
857
+ ),
858
+ row.platform === 'android' && row.serial && h('button', {
859
+ type: 'button',
860
+ className: 'dd-picker-icon',
861
+ title: t.menuOpenMirror,
862
+ onClick: () => onOpenAndroid(row),
863
+ }, h(Icon, { name: 'monitorplay', size: 12 })),
864
+ row.platform === 'ios' && h('button', {
865
+ type: 'button',
866
+ className: 'dd-picker-icon',
867
+ title: t.menuOpenSimulator,
868
+ onClick: () => void onOpenIos(row),
869
+ }, h(Icon, { name: 'external', size: 12 })),
870
+ (row.platform === 'ios' || row.kind !== '真机') && h('button', {
871
+ type: 'button',
872
+ className: 'dd-picker-icon',
873
+ 'data-danger': 'true',
874
+ title: t.menuStop,
875
+ disabled: Boolean(stopping),
876
+ onClick: () => void onStop(row),
877
+ }, stopping === keyOf(row)
878
+ ? h('span', { className: 'dd-picker-spinner' })
879
+ : h(Icon, { name: 'power', size: 12 })),
880
+ )),
881
+ more('running', running.length, t.menuDeviceUnit),
882
+ ),
883
+
884
+ iosDevices.length > 0 && h(Fragment, { key: 'ios-device-section' },
885
+ h('div', { className: 'dd-picker-section' },
886
+ h('span', null, t.menuIosDevices),
887
+ h('span', null, String(iosDevices.length)),
888
+ ),
889
+ h('div', null,
890
+ iosDevices.map((device) => h('div', {
891
+ key: device.udid,
892
+ className: 'dd-picker-row',
893
+ },
894
+ h('button', {
895
+ type: 'button',
896
+ className: 'dd-picker-main',
897
+ title: t.menuIosMirrorOpen,
898
+ onClick: () => onOpenIosDevice(device),
899
+ },
900
+ h('div', { className: 'dd-picker-line' },
901
+ h('span', {
902
+ style: { display: 'inline-flex', flex: 'none', color: '#0ea5e9' },
903
+ }, h(Icon, { name: 'phone', size: 11 })),
904
+ h('span', { className: 'dd-picker-name' }, device.name),
905
+ device.mirroring?.status === 'ready' && h('span', {
906
+ className: 'dd-picker-status',
907
+ 'data-state': 'booted',
908
+ }, t.iosReady),
909
+ device.mirroring?.status === 'starting' && h('span', {
910
+ className: 'dd-picker-status',
911
+ 'data-state': 'starting',
912
+ }, t.menuStarting),
913
+ ),
914
+ h('div', { className: 'dd-picker-detail' },
915
+ `${device.model || device.productType || 'iOS'}${device.osVersion ? ` · iOS ${device.osVersion}` : ''}${device.transport ? ` · ${device.transport}` : ''}`,
916
+ ),
917
+ ),
918
+ h('button', {
919
+ type: 'button',
920
+ className: 'dd-picker-icon',
921
+ title: t.menuIosMirrorOpen,
922
+ onClick: () => onOpenIosDevice(device),
923
+ }, h(Icon, { name: 'monitorplay', size: 12 })),
924
+ )),
925
+ ),
926
+ ),
927
+
928
+ android.avds?.available && pendingAvds.length > 0 && h(Fragment, { key: 'android-section' },
929
+ h('div', { className: 'dd-picker-section' },
930
+ h('span', null, t.menuAndroidEmulators),
931
+ ),
932
+ h('div', null,
933
+ pendingAvds.slice(0, limit('android', pendingAvds.length)).map((name) => h('div', {
934
+ key: name,
935
+ className: 'dd-picker-row',
936
+ },
937
+ h('div', { className: 'dd-picker-main', style: { cursor: 'default' } },
938
+ h('div', { className: 'dd-picker-name' }, name),
939
+ h('div', { className: 'dd-picker-detail' }, t.menuNotStarted),
940
+ ),
941
+ h('button', {
942
+ type: 'button',
943
+ className: 'dd-picker-start',
944
+ disabled: Boolean(launching),
945
+ onClick: () => void onStartAvd(name),
946
+ },
947
+ launching === `avd-${name}`
948
+ ? h('span', { className: 'dd-picker-spinner' })
949
+ : h(Icon, { name: 'play', size: 9 }),
950
+ h('span', null, launching === `avd-${name}` ? t.menuStartingBtn : t.menuStart),
951
+ ),
952
+ )),
953
+ more('android', pendingAvds.length, t.menuItemUnit),
954
+ ),
955
+ ),
956
+
957
+ ios.available && pendingIos.length > 0 && h(Fragment, { key: 'ios-section' },
958
+ h('div', { className: 'dd-picker-section' },
959
+ h('span', null, t.menuIosEmulators),
960
+ ),
961
+ h('div', null,
962
+ pendingIos.slice(0, limit('ios', pendingIos.length)).map((simulator) => h('div', {
963
+ key: simulator.udid,
964
+ className: 'dd-picker-row',
965
+ },
966
+ h('div', { className: 'dd-picker-main', style: { cursor: 'default' } },
967
+ h('div', { className: 'dd-picker-name' }, simulator.name),
968
+ h('div', { className: 'dd-picker-detail' },
969
+ `${t.menuShutdown}${simulator.version ? ` · iOS ${simulator.version}` : ''}`,
970
+ ),
971
+ ),
972
+ h('button', {
973
+ type: 'button',
974
+ className: 'dd-picker-start',
975
+ disabled: Boolean(launching),
976
+ onClick: () => void onStartIos(simulator),
977
+ },
978
+ launching === `ios-${simulator.udid}`
979
+ ? h('span', { className: 'dd-picker-spinner' })
980
+ : h(Icon, { name: 'play', size: 9 }),
981
+ h('span', null, launching === `ios-${simulator.udid}` ? t.menuStartingBtn : t.menuStart),
982
+ ),
983
+ )),
984
+ more('ios', pendingIos.length, t.menuDeviceUnit),
985
+ ),
986
+ ),
987
+
988
+ !hasContent && h('div', { className: 'dd-picker-empty' },
989
+ t.menuEmpty,
990
+ h('br', null),
991
+ t.menuEmptyHint,
992
+ ),
993
+
994
+ inventory?.adb?.available === false && h('div', { className: 'dd-picker-notice' },
995
+ inventory.adb.error || t.adbMissing,
996
+ ),
997
+ ),
998
+
999
+ notice && h('div', { className: 'dd-picker-notice' }, notice),
1000
+
1001
+ h('div', { className: 'dd-picker-wifi' },
1002
+ h('div', { className: 'dd-picker-wifi-row' },
1003
+ h(Icon, { name: 'wifi', size: 12 }),
1004
+ h('input', {
1005
+ className: 'dd-picker-input',
1006
+ value: wifiAddress,
1007
+ placeholder: t.menuWifiPlaceholder,
1008
+ spellCheck: false,
1009
+ onChange: (event) => setWifiAddress(event.target.value),
1010
+ onKeyDown: (event) => {
1011
+ if (event.key === 'Enter') {
1012
+ event.preventDefault();
1013
+ void onConnectWifi();
1014
+ }
1015
+ },
1016
+ }),
1017
+ h('button', {
1018
+ type: 'button',
1019
+ className: 'dd-picker-start',
1020
+ disabled: !wifiAddress.trim() || wifiBusy,
1021
+ onClick: () => void onConnectWifi(),
1022
+ }, wifiBusy ? t.menuConnecting : t.menuConnect),
1023
+ h('button', {
1024
+ type: 'button',
1025
+ className: 'dd-picker-icon',
1026
+ title: t.menuRefresh,
1027
+ onClick: () => void onRefresh(),
1028
+ }, h(Icon, { name: 'refresh', size: 12 })),
1029
+ ),
1030
+ wifiResult && h('div', {
1031
+ className: 'dd-picker-result',
1032
+ 'data-ok': wifiResult.ok ? 'true' : 'false',
1033
+ }, wifiResult.text),
1034
+ ),
1035
+ );
1036
+ }
1037
+
1038
+ function makeMirrorHost(ctx) {
1039
+ return function DeviceMirrorHost() {
1040
+ const open = useSharedOpen();
1041
+ const platform = useSharedPlatform();
1042
+
1043
+ useEffect(() => {
1044
+ const onKeyDown = (event) => {
1045
+ if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
1046
+ if (event.repeat) return;
1047
+ if (String(event.key).toLowerCase() !== 'm') return;
1048
+ event.preventDefault();
1049
+ event.stopPropagation();
1050
+ setSharedOpen(!shared.open);
1051
+ };
1052
+ window.addEventListener('keydown', onKeyDown, true);
1053
+ return () => window.removeEventListener('keydown', onKeyDown, true);
1054
+ }, []);
1055
+
1056
+ return h(Fragment, null,
1057
+ h('style', { 'data-dsh-device-deck': '' }, CSS),
1058
+ open
1059
+ ? ReactDOM.createPortal(
1060
+ platform === 'ios'
1061
+ ? h(IosPanel, {
1062
+ ctx,
1063
+ onClose: () => setSharedOpen(false),
1064
+ })
1065
+ : h(Panel, {
1066
+ ctx,
1067
+ onClose: () => setSharedOpen(false),
1068
+ }),
1069
+ document.body,
1070
+ )
1071
+ : null,
1072
+ );
1073
+ };
1074
+ }
1075
+
1076
+ function makeLauncher(ctx) {
1077
+ return function DeviceMirrorLauncher(props) {
1078
+ const open = useSharedOpen();
1079
+ const [menuOpen, setMenuOpen] = useState(false);
1080
+ const [menuRect, setMenuRect] = useState(null);
1081
+ const [inventory, setInventory] = useState(null);
1082
+ const [deviceCount, setDeviceCount] = useState(0);
1083
+ const [menuNotice, setMenuNotice] = useState('');
1084
+ const [launching, setLaunching] = useState(null);
1085
+ const [stopping, setStopping] = useState(null);
1086
+ const [wifiAddress, setWifiAddress] = useState('');
1087
+ const [wifiBusy, setWifiBusy] = useState(false);
1088
+ const [wifiResult, setWifiResult] = useState(null);
1089
+ const t = useCopy(ctx);
1090
+ const shotPickerOpen = useShotPickerOpen(props.sessionId);
1091
+ const buttonRef = useRef(null);
1092
+ const menuRef = useRef(null);
1093
+
1094
+ const reloadInventory = useCallback(async () => {
1095
+ try {
1096
+ const data = await apiJson('/inventory');
1097
+ setInventory(data);
1098
+ const count =
1099
+ (Array.isArray(data.android?.running) ? data.android.running.length : 0) +
1100
+ (Array.isArray(data.ios?.running) ? data.ios.running.length : 0) +
1101
+ (Array.isArray(data.ios?.devices?.items) ? data.ios.devices.items.length : 0);
1102
+ setDeviceCount(count);
1103
+ } catch (error) {
1104
+ setInventory((previous) => previous || {
1105
+ adb: { available: false, error: error.message },
1106
+ android: { running: [], avds: { available: false, items: [], pending: [], message: error.message } },
1107
+ ios: { available: false, message: error.message, running: [], pending: [] },
1108
+ });
1109
+ }
1110
+ }, []);
1111
+
1112
+ useEffect(() => {
1113
+ void reloadInventory();
1114
+ const timer = window.setInterval(() => void reloadInventory(), menuOpen ? 5000 : 15000);
1115
+ return () => window.clearInterval(timer);
1116
+ }, [reloadInventory, menuOpen]);
1117
+
1118
+ useEffect(() => {
1119
+ if (!menuOpen) return undefined;
1120
+ const onPointerDown = (event) => {
1121
+ if (menuRef.current?.contains(event.target) || buttonRef.current?.contains(event.target)) return;
1122
+ setMenuOpen(false);
1123
+ };
1124
+ const onKeyDown = (event) => {
1125
+ if (event.key === 'Escape') setMenuOpen(false);
1126
+ };
1127
+ document.addEventListener('pointerdown', onPointerDown, true);
1128
+ window.addEventListener('keydown', onKeyDown);
1129
+ return () => {
1130
+ document.removeEventListener('pointerdown', onPointerDown, true);
1131
+ window.removeEventListener('keydown', onKeyDown);
1132
+ };
1133
+ }, [menuOpen]);
1134
+
1135
+ useEffect(() => {
1136
+ if (!menuOpen) return undefined;
1137
+ const close = () => setMenuOpen(false);
1138
+ window.addEventListener('resize', close);
1139
+ return () => window.removeEventListener('resize', close);
1140
+ }, [menuOpen]);
1141
+
1142
+ const openMenu = () => {
1143
+ if (menuOpen) {
1144
+ setMenuOpen(false);
1145
+ return;
1146
+ }
1147
+ const rect = buttonRef.current?.getBoundingClientRect();
1148
+ if (rect) {
1149
+ setMenuRect({
1150
+ top: Math.round(rect.bottom + 6),
1151
+ right: Math.max(8, Math.round(window.innerWidth - rect.right)),
1152
+ });
1153
+ }
1154
+ setMenuOpen(true);
1155
+ setMenuNotice('');
1156
+ setWifiResult(null);
1157
+ if (open) setSharedOpen(false);
1158
+ void reloadInventory();
1159
+ };
1160
+
1161
+ const openAndroid = (row) => {
1162
+ if (!row.serial) {
1163
+ setMenuNotice(t.menuStartingWait);
1164
+ return;
1165
+ }
1166
+ shared.platform = 'android';
1167
+ shared.serial = row.serial;
1168
+ saveStore();
1169
+ notifyStore();
1170
+ setSharedOpen(true);
1171
+ setMenuOpen(false);
1172
+ };
1173
+
1174
+ const openIosMirror = (row) => {
1175
+ shared.platform = 'ios';
1176
+ shared.iosKind = row.kind === 'simulator' ? 'simulator' : 'device';
1177
+ shared.serial = row.udid;
1178
+ saveStore();
1179
+ notifyStore();
1180
+ setSharedOpen(true);
1181
+ setMenuOpen(false);
1182
+ };
1183
+
1184
+ const openIos = async (row) => {
1185
+ try {
1186
+ const result = await postJson('/ios/open', { udid: row.udid });
1187
+ setMenuNotice(result.message || t.menuIosUnsupported);
1188
+ } catch (error) {
1189
+ setMenuNotice(error.message);
1190
+ }
1191
+ };
1192
+
1193
+ const startAvd = async (name) => {
1194
+ if (launching) return;
1195
+ const key = `avd-${name}`;
1196
+ setLaunching(key);
1197
+ setMenuNotice('');
1198
+ try {
1199
+ const result = await postJson('/emulator/start', { name });
1200
+ setMenuNotice(result.message || t.menuStartingBtn);
1201
+ } catch (error) {
1202
+ setMenuNotice(error.message);
1203
+ } finally {
1204
+ window.setTimeout(() => setLaunching((current) => (current === key ? null : current)), 2500);
1205
+ void reloadInventory();
1206
+ }
1207
+ };
1208
+
1209
+ const startIos = async (simulator) => {
1210
+ if (launching) return;
1211
+ const key = `ios-${simulator.udid}`;
1212
+ setLaunching(key);
1213
+ setMenuNotice('');
1214
+ try {
1215
+ const result = await postJson('/ios/boot', { udid: simulator.udid });
1216
+ setMenuNotice(result.message || t.menuStartingBtn);
1217
+ } catch (error) {
1218
+ setMenuNotice(error.message);
1219
+ } finally {
1220
+ window.setTimeout(() => setLaunching((current) => (current === key ? null : current)), 2500);
1221
+ void reloadInventory();
1222
+ }
1223
+ };
1224
+
1225
+ const stopRow = async (row) => {
1226
+ if (stopping) return;
1227
+ const key = row.platform === 'ios' ? `ios-${row.udid}` : `android-${row.serial || row.pid}`;
1228
+ setStopping(key);
1229
+ try {
1230
+ const result = row.platform === 'ios'
1231
+ ? await postJson('/ios/shutdown', { udid: row.udid })
1232
+ : await postJson('/emulator/stop', { serial: row.serial, pid: row.pid });
1233
+ setMenuNotice(result.message || t.menuStopRequested);
1234
+ } catch (error) {
1235
+ setMenuNotice(error.message);
1236
+ } finally {
1237
+ setStopping(null);
1238
+ window.setTimeout(() => void reloadInventory(), 600);
1239
+ }
1240
+ };
1241
+
1242
+ const connectWifi = async () => {
1243
+ const address = wifiAddress.trim();
1244
+ if (!address || wifiBusy) return;
1245
+ setWifiBusy(true);
1246
+ setWifiResult(null);
1247
+ try {
1248
+ const result = await postJson('/wifi/connect', { address });
1249
+ setWifiResult({ ok: Boolean(result.success), text: result.message });
1250
+ if (result.success) setWifiAddress('');
1251
+ } catch (error) {
1252
+ setWifiResult({ ok: false, text: error.message });
1253
+ } finally {
1254
+ setWifiBusy(false);
1255
+ void reloadInventory();
1256
+ }
1257
+ };
1258
+
1259
+ const title = deviceCount > 0
1260
+ ? `设备 · ${deviceCount} ${t.menuOnline}`
1261
+ : `设备 · ${t.menuNoDevice}`;
1262
+
1263
+ return h(Fragment, null,
1264
+ h('button', {
1265
+ ref: buttonRef,
1266
+ type: 'button',
1267
+ className: 'dd-launcher',
1268
+ title,
1269
+ 'aria-label': t.title,
1270
+ 'aria-keyshortcuts': 'Control+M',
1271
+ 'aria-expanded': menuOpen,
1272
+ 'data-active': menuOpen || open ? 'true' : 'false',
1273
+ 'data-count': deviceCount > 0 ? 'true' : 'false',
1274
+ onClick: openMenu,
1275
+ },
1276
+ h(LauncherIcon, { size: 16 }),
1277
+ deviceCount > 0 && h('span', { className: 'dd-launcher-count' }, deviceCount > 99 ? '99+' : String(deviceCount)),
1278
+ ),
1279
+ shotPickerOpen && h(ShotPicker, {
1280
+ ctx,
1281
+ sessionId: props.sessionId,
1282
+ inputActions: props.inputActions,
1283
+ onClose: () => closeShotPicker(props.sessionId),
1284
+ }),
1285
+ menuOpen && menuRect
1286
+ ? ReactDOM.createPortal(
1287
+ h(DevicePicker, {
1288
+ inventory,
1289
+ rect: menuRect,
1290
+ menuRef,
1291
+ t,
1292
+ onOpenAndroid: openAndroid,
1293
+ onOpenIos: openIos,
1294
+ onOpenIosDevice: openIosMirror,
1295
+ onStartAvd: startAvd,
1296
+ onStartIos: startIos,
1297
+ onStop: stopRow,
1298
+ onRefresh: reloadInventory,
1299
+ onNotice: setMenuNotice,
1300
+ launching,
1301
+ stopping,
1302
+ wifiAddress,
1303
+ setWifiAddress,
1304
+ onConnectWifi: connectWifi,
1305
+ wifiBusy,
1306
+ wifiResult,
1307
+ notice: menuNotice,
1308
+ }),
1309
+ document.body,
1310
+ )
1311
+ : null,
1312
+ );
1313
+ };
1314
+ }
1315
+
1316
+ function Panel({ ctx, onClose }) {
1317
+ const t = useCopy(ctx);
1318
+ const [devices, setDevices] = useState([]);
1319
+ const [adb, setAdb] = useState(null);
1320
+ const [loadingDevices, setLoadingDevices] = useState(true);
1321
+ const [serial, setSerial] = useState(shared.serial || '');
1322
+ const [status, setStatus] = useState('connecting');
1323
+ const [error, setError] = useState('');
1324
+ const [videoSize, setVideoSize] = useState({ width: 1080, height: 2400 });
1325
+ const [shots, setShots] = useState([]);
1326
+ const [stripOpen, setStripOpen] = useState(false);
1327
+ const [inputOpen, setInputOpen] = useState(false);
1328
+ const [linkOpen, setLinkOpen] = useState(false);
1329
+ const [text, setText] = useState('');
1330
+ const [url, setUrl] = useState('');
1331
+ const [notice, setNotice] = useState('');
1332
+ const [busy, setBusy] = useState(false);
1333
+ const [pushing, setPushing] = useState(false);
1334
+ const [nonce, setNonce] = useState(0);
1335
+ const [offset, setOffset] = useState(shared.offset || { x: 0, y: 0 });
1336
+ const [switcherOpen, setSwitcherOpen] = useState(false);
1337
+ const [ripple, setRipple] = useState(null);
1338
+ const [railTip, setRailTip] = useState(null);
1339
+ const [frameWidth, setFrameWidth] = useState(360);
1340
+
1341
+ const canvasRef = useRef(null);
1342
+ const viewportRef = useRef(null);
1343
+ const ctx2dRef = useRef(null);
1344
+ const pointerRef = useRef(null);
1345
+ const retryRef = useRef(0);
1346
+ const dragRef = useRef(null);
1347
+ const menuRef = useRef(null);
1348
+ const fileInputRef = useRef(null);
1349
+ const frameRef = useRef(null);
1350
+
1351
+ const device = devices.find((item) => item.serial === serial);
1352
+ const deviceName = device?.model || (serial ? serial : 'Android');
1353
+ const onlineDevices = devices.filter((item) => item.state === 'device');
1354
+
1355
+ const flash = useCallback((message) => {
1356
+ setNotice(message);
1357
+ }, []);
1358
+
1359
+ useEffect(() => {
1360
+ if (!notice || pushing) return undefined;
1361
+ const timer = window.setTimeout(() => setNotice(''), 3200);
1362
+ return () => window.clearTimeout(timer);
1363
+ }, [notice, pushing]);
1364
+
1365
+ useEffect(() => {
1366
+ if (!serial) return;
1367
+ shared.serial = serial;
1368
+ saveStore();
1369
+ }, [serial]);
1370
+
1371
+ useEffect(() => {
1372
+ retryRef.current = 0;
1373
+ }, [serial]);
1374
+
1375
+ const refreshDevices = useCallback(async () => {
1376
+ try {
1377
+ const data = await apiJson('/devices');
1378
+ setAdb(data.adb || null);
1379
+ const list = Array.isArray(data.devices) ? data.devices : [];
1380
+ setDevices(list);
1381
+ setSerial((previous) => {
1382
+ const online = list.filter((item) => item.state === 'device');
1383
+ if (previous && online.some((item) => item.serial === previous)) return previous;
1384
+ return online[0]?.serial || '';
1385
+ });
1386
+ } catch (fetchError) {
1387
+ setAdb({ available: false, error: fetchError.message });
1388
+ setDevices([]);
1389
+ } finally {
1390
+ setLoadingDevices(false);
1391
+ }
1392
+ }, []);
1393
+
1394
+ useEffect(() => {
1395
+ void refreshDevices();
1396
+ const timer = window.setInterval(() => void refreshDevices(), 10_000);
1397
+ return () => window.clearInterval(timer);
1398
+ }, [refreshDevices]);
1399
+
1400
+ useLayoutEffect(() => {
1401
+ const frame = frameRef.current;
1402
+ if (!frame) return undefined;
1403
+ const measure = () => {
1404
+ const height = frame.clientHeight;
1405
+ if (height <= 0) return;
1406
+ const usable = height - HEADER_HEIGHT - (stripOpen ? STRIP_HEIGHT : 0);
1407
+ if (usable <= 0) return;
1408
+ const aspect = videoSize.width / Math.max(1, videoSize.height);
1409
+ const maxWidth = Math.max(260, Math.min(520, window.innerWidth - 80));
1410
+ const next = Math.max(260, Math.min(maxWidth, Math.round(usable * aspect)));
1411
+ setFrameWidth((previous) => (Math.abs(previous - next) <= 1 ? previous : next));
1412
+ };
1413
+ measure();
1414
+ const observer = new ResizeObserver(measure);
1415
+ observer.observe(frame);
1416
+ window.addEventListener('resize', measure);
1417
+ return () => {
1418
+ observer.disconnect();
1419
+ window.removeEventListener('resize', measure);
1420
+ };
1421
+ }, [videoSize.width, videoSize.height, stripOpen]);
1422
+
1423
+ const refreshShots = useCallback(async () => {
1424
+ if (!serial) {
1425
+ setShots([]);
1426
+ return;
1427
+ }
1428
+ try {
1429
+ const data = await apiJson(`/screenshots?serial=${encodeURIComponent(serial)}`);
1430
+ setShots(Array.isArray(data.items) ? data.items : []);
1431
+ } catch {
1432
+ /* history is best effort */
1433
+ }
1434
+ }, [serial]);
1435
+
1436
+ useEffect(() => {
1437
+ void refreshShots();
1438
+ }, [refreshShots]);
1439
+
1440
+ useEffect(() => {
1441
+ if (shots.length === 0) {
1442
+ setStripOpen(false);
1443
+ setRailTip(null);
1444
+ }
1445
+ }, [shots.length]);
1446
+
1447
+ useEffect(() => {
1448
+ if (!switcherOpen) return undefined;
1449
+ const onPointerDown = (event) => {
1450
+ if (menuRef.current?.contains(event.target)) return;
1451
+ setSwitcherOpen(false);
1452
+ };
1453
+ document.addEventListener('pointerdown', onPointerDown, true);
1454
+ return () => document.removeEventListener('pointerdown', onPointerDown, true);
1455
+ }, [switcherOpen]);
1456
+
1457
+ useEffect(() => {
1458
+ if (!serial) {
1459
+ setStatus('offline');
1460
+ setError('');
1461
+ return undefined;
1462
+ }
1463
+ if (typeof VideoDecoder === 'undefined') {
1464
+ setStatus('error');
1465
+ setError('当前浏览器不支持 WebCodecs 视频解码。');
1466
+ return undefined;
1467
+ }
1468
+
1469
+ const controller = new AbortController();
1470
+ let cancelled = false;
1471
+ let decoder = null;
1472
+ let configured = false;
1473
+ let pendingConfig = null;
1474
+ let firstFrame = false;
1475
+ let timestamp = 0;
1476
+ let metaSize = null;
1477
+ let retryResetTimer = null;
1478
+ const watchdog = window.setTimeout(() => {
1479
+ if (cancelled || firstFrame) return;
1480
+ setStatus('error');
1481
+ setError('长时间未收到画面数据。请确认设备在线。');
1482
+ controller.abort();
1483
+ }, 12_000);
1484
+
1485
+ setStatus('connecting');
1486
+ setError('');
1487
+
1488
+ const ensureDecoder = (codec, width, height) => {
1489
+ if (decoder !== null) return decoder;
1490
+ decoder = new VideoDecoder({
1491
+ output: (frame) => {
1492
+ if (cancelled) {
1493
+ frame.close();
1494
+ return;
1495
+ }
1496
+ const canvas = canvasRef.current;
1497
+ if (!canvas) {
1498
+ frame.close();
1499
+ return;
1500
+ }
1501
+ if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
1502
+ canvas.width = frame.displayWidth;
1503
+ canvas.height = frame.displayHeight;
1504
+ }
1505
+ try {
1506
+ if (!ctx2dRef.current) {
1507
+ ctx2dRef.current = canvas.getContext('2d', { alpha: false, desynchronized: true });
1508
+ }
1509
+ ctx2dRef.current.drawImage(frame, 0, 0);
1510
+ } finally {
1511
+ frame.close();
1512
+ }
1513
+ if (!firstFrame) {
1514
+ firstFrame = true;
1515
+ window.clearTimeout(watchdog);
1516
+ retryResetTimer = window.setTimeout(() => {
1517
+ if (!cancelled) retryRef.current = 0;
1518
+ }, 10_000);
1519
+ setStatus('live');
1520
+ setError('');
1521
+ if (width && height && (width !== videoSize.width || height !== videoSize.height)) {
1522
+ setVideoSize({ width, height });
1523
+ }
1524
+ }
1525
+ },
1526
+ error: (decodeError) => {
1527
+ if (cancelled) return;
1528
+ setStatus('error');
1529
+ setError(`解码失败:${decodeError.message}`);
1530
+ },
1531
+ });
1532
+ return decoder;
1533
+ };
1534
+
1535
+ const handleFrame = (type, flags, payload) => {
1536
+ if (cancelled) return;
1537
+ if (type === T_META) {
1538
+ try {
1539
+ const meta = JSON.parse(new TextDecoder().decode(payload));
1540
+ if (meta.width && meta.height) {
1541
+ metaSize = { width: meta.width, height: meta.height };
1542
+ setVideoSize({ width: meta.width, height: meta.height });
1543
+ }
1544
+ if (meta.codec !== 'h264') {
1545
+ setStatus('error');
1546
+ setError(`暂不支持 ${String(meta.codec).toUpperCase()} 视频编码。`);
1547
+ controller.abort();
1548
+ }
1549
+ } catch {
1550
+ /* ignore malformed metadata */
1551
+ }
1552
+ return;
1553
+ }
1554
+ if (type === T_CONFIG) {
1555
+ pendingConfig = payload.slice();
1556
+ try {
1557
+ const codec = avcCodecString(payload);
1558
+ const instance = ensureDecoder(codec, metaSize?.width, metaSize?.height);
1559
+ instance.configure({
1560
+ codec,
1561
+ optimizeForLatency: true,
1562
+ hardwareAcceleration: 'no-preference',
1563
+ });
1564
+ configured = true;
1565
+ } catch (configureError) {
1566
+ setStatus('error');
1567
+ setError(`解码器初始化失败:${configureError.message}`);
1568
+ }
1569
+ return;
1570
+ }
1571
+ if (type === T_DATA) {
1572
+ if (decoder === null || !configured) return;
1573
+ try {
1574
+ let data = payload;
1575
+ if (pendingConfig !== null) {
1576
+ data = concatBytes(pendingConfig, payload);
1577
+ pendingConfig = null;
1578
+ }
1579
+ timestamp += 1000;
1580
+ decoder.decode(new EncodedVideoChunk({
1581
+ type: (flags & 1) !== 0 ? 'key' : 'delta',
1582
+ timestamp,
1583
+ data,
1584
+ }));
1585
+ } catch {
1586
+ /* skip a frame the decoder cannot take */
1587
+ }
1588
+ return;
1589
+ }
1590
+ if (type === T_ERROR) {
1591
+ try {
1592
+ const info = JSON.parse(new TextDecoder().decode(payload));
1593
+ setStatus('error');
1594
+ setError(info?.message || t.streamError);
1595
+ } catch {
1596
+ setStatus('error');
1597
+ setError(t.streamError);
1598
+ }
1599
+ controller.abort();
1600
+ return;
1601
+ }
1602
+ if (type === T_END) {
1603
+ setStatus((previous) => (previous === 'live' ? 'offline' : previous));
1604
+ }
1605
+ };
1606
+
1607
+ const readStream = async () => {
1608
+ try {
1609
+ const response = await fetch(
1610
+ `${API}/stream?serial=${encodeURIComponent(serial)}&_=${Date.now()}`,
1611
+ { signal: controller.signal, cache: 'no-store' },
1612
+ );
1613
+ if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
1614
+ const reader = response.body.getReader();
1615
+ let buffer = new Uint8Array(0);
1616
+ while (!cancelled) {
1617
+ const { done, value } = await reader.read();
1618
+ if (done) break;
1619
+ buffer = concatBytes(buffer, value);
1620
+ let offsetInBuffer = 0;
1621
+ while (buffer.length - offsetInBuffer >= 6) {
1622
+ const type = buffer[offsetInBuffer];
1623
+ const flags = buffer[offsetInBuffer + 1];
1624
+ const length =
1625
+ (buffer[offsetInBuffer + 2] << 24) |
1626
+ (buffer[offsetInBuffer + 3] << 16) |
1627
+ (buffer[offsetInBuffer + 4] << 8) |
1628
+ buffer[offsetInBuffer + 5];
1629
+ if (buffer.length - offsetInBuffer - 6 < length) break;
1630
+ const payload = buffer.slice(offsetInBuffer + 6, offsetInBuffer + 6 + length);
1631
+ handleFrame(type, flags, payload);
1632
+ offsetInBuffer += 6 + length;
1633
+ }
1634
+ buffer = offsetInBuffer > 0 ? buffer.slice(offsetInBuffer) : buffer;
1635
+ }
1636
+ if (!cancelled) {
1637
+ const wasLive = firstFrame;
1638
+ setStatus((previous) => (previous === 'live' ? 'offline' : 'error'));
1639
+ setError(wasLive ? t.disconnected : t.streamError);
1640
+ if (wasLive && retryRef.current < 3) {
1641
+ retryRef.current += 1;
1642
+ window.setTimeout(() => {
1643
+ if (!cancelled) setNonce((value) => value + 1);
1644
+ }, 1200 * retryRef.current);
1645
+ }
1646
+ }
1647
+ } catch (streamError) {
1648
+ if (cancelled || controller.signal.aborted) return;
1649
+ setStatus('error');
1650
+ setError(streamError?.message || t.streamError);
1651
+ if (retryRef.current < 3) {
1652
+ retryRef.current += 1;
1653
+ window.setTimeout(() => {
1654
+ if (!cancelled) setNonce((value) => value + 1);
1655
+ }, 1200 * retryRef.current);
1656
+ }
1657
+ }
1658
+ };
1659
+
1660
+ void readStream();
1661
+
1662
+ return () => {
1663
+ cancelled = true;
1664
+ controller.abort();
1665
+ window.clearTimeout(watchdog);
1666
+ if (retryResetTimer) window.clearTimeout(retryResetTimer);
1667
+ if (decoder !== null && decoder.state !== 'closed') {
1668
+ try {
1669
+ decoder.close();
1670
+ } catch {
1671
+ /* ignore */
1672
+ }
1673
+ }
1674
+ decoder = null;
1675
+ ctx2dRef.current = null;
1676
+ if (canvasRef.current) {
1677
+ canvasRef.current.width = 0;
1678
+ canvasRef.current.height = 0;
1679
+ }
1680
+ };
1681
+ }, [serial, nonce]);
1682
+
1683
+ const ratioFromEvent = useCallback((clientX, clientY) => {
1684
+ const viewport = viewportRef.current;
1685
+ if (!viewport) return null;
1686
+ const rect = viewport.getBoundingClientRect();
1687
+ if (rect.width <= 0 || rect.height <= 0) return null;
1688
+ const aspect = videoSize.width / videoSize.height;
1689
+ let width = rect.width;
1690
+ let height = rect.height;
1691
+ if (width / height > aspect) width = height * aspect;
1692
+ else height = width / aspect;
1693
+ const left = rect.left + (rect.width - width) / 2;
1694
+ const top = rect.top + (rect.height - height) / 2;
1695
+ const x = (clientX - left) / width;
1696
+ const y = (clientY - top) / height;
1697
+ return {
1698
+ x: Math.max(0, Math.min(1, x)),
1699
+ y: Math.max(0, Math.min(1, y)),
1700
+ inside: x >= 0 && x <= 1 && y >= 0 && y <= 1,
1701
+ left,
1702
+ top,
1703
+ width,
1704
+ height,
1705
+ };
1706
+ }, [videoSize]);
1707
+
1708
+ const runAction = useCallback(async (action) => {
1709
+ if (!serial) return false;
1710
+ setBusy(true);
1711
+ try {
1712
+ await postJson('/action', {
1713
+ serial,
1714
+ action: { ...action, frameWidth: videoSize.width, frameHeight: videoSize.height },
1715
+ });
1716
+ return true;
1717
+ } catch (actionError) {
1718
+ flash(actionError.message);
1719
+ return false;
1720
+ } finally {
1721
+ setBusy(false);
1722
+ }
1723
+ }, [serial, busy, videoSize, flash]);
1724
+
1725
+ const handlePointerDown = (event) => {
1726
+ if (status !== 'live' || event.button === 2) return;
1727
+ const ratio = ratioFromEvent(event.clientX, event.clientY);
1728
+ if (!ratio || !ratio.inside) return;
1729
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1730
+ pointerRef.current = {
1731
+ x: ratio.x,
1732
+ y: ratio.y,
1733
+ clientX: event.clientX,
1734
+ clientY: event.clientY,
1735
+ time: Date.now(),
1736
+ };
1737
+ };
1738
+
1739
+ const handlePointerUp = (event) => {
1740
+ const start = pointerRef.current;
1741
+ pointerRef.current = null;
1742
+ if (!start || status !== 'live') return;
1743
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
1744
+ event.currentTarget.releasePointerCapture(event.pointerId);
1745
+ }
1746
+ const ratio = ratioFromEvent(event.clientX, event.clientY);
1747
+ if (!ratio) return;
1748
+ const dx = event.clientX - start.clientX;
1749
+ const dy = event.clientY - start.clientY;
1750
+ const distance = Math.sqrt(dx * dx + dy * dy);
1751
+ const duration = Date.now() - start.time;
1752
+
1753
+ if (distance < 12 && duration < 400) {
1754
+ const viewport = viewportRef.current;
1755
+ if (viewport) {
1756
+ const rect = viewport.getBoundingClientRect();
1757
+ const id = Date.now();
1758
+ setRipple({ left: event.clientX - rect.left, top: event.clientY - rect.top, id });
1759
+ window.setTimeout(() => setRipple((current) => (current?.id === id ? null : current)), 280);
1760
+ }
1761
+ void runAction({ type: 'tap', ratioX: ratio.x, ratioY: ratio.y });
1762
+ return;
1763
+ }
1764
+ void runAction({
1765
+ type: 'swipe',
1766
+ ratioX: start.x,
1767
+ ratioY: start.y,
1768
+ ratioEndX: ratio.x,
1769
+ ratioEndY: ratio.y,
1770
+ duration: Math.max(120, Math.min(1000, duration)),
1771
+ });
1772
+ };
1773
+
1774
+ const capture = useCallback(async () => {
1775
+ if (!serial || busy) return;
1776
+ setBusy(true);
1777
+ try {
1778
+ const data = await postJson('/screenshot', { serial });
1779
+ setShots((previous) => [data.shot, ...previous.map((item) => ({ ...item, selected: false }))]);
1780
+ setStripOpen(true);
1781
+ flash(t.shotSaved);
1782
+ } catch (captureError) {
1783
+ flash(captureError.message);
1784
+ } finally {
1785
+ setBusy(false);
1786
+ }
1787
+ }, [serial, busy, flash, t]);
1788
+
1789
+ const pushFile = useCallback(async (file) => {
1790
+ if (!file || !serial || pushing) return;
1791
+ setPushing(true);
1792
+ flash(t.fileSending.replace('{name}', file.name));
1793
+ try {
1794
+ const response = await fetch(`${API}/file-push?serial=${encodeURIComponent(serial)}&name=${encodeURIComponent(file.name)}`, {
1795
+ method: 'POST',
1796
+ headers: { 'Content-Type': 'application/octet-stream' },
1797
+ body: file,
1798
+ cache: 'no-store',
1799
+ });
1800
+ const data = await response.json().catch(() => null);
1801
+ if (!response.ok || !data?.success) throw new Error(data?.error || `HTTP ${response.status}`);
1802
+ flash(t.fileSent.replace('{path}', data.path || file.name));
1803
+ } catch (error) {
1804
+ flash(t.fileFailed.replace('{error}', error.message));
1805
+ } finally {
1806
+ setPushing(false);
1807
+ if (fileInputRef.current) fileInputRef.current.value = '';
1808
+ }
1809
+ }, [serial, pushing, flash, t]);
1810
+
1811
+ const selectShot = useCallback(async (shot) => {
1812
+ if (!shot || shot.selected) return;
1813
+ try {
1814
+ await postJson('/screenshots/select', { path: shot.path });
1815
+ setShots((previous) => previous.map((item) => ({ ...item, selected: item.path === shot.path })));
1816
+ } catch (selectError) {
1817
+ flash(selectError.message);
1818
+ }
1819
+ }, [flash]);
1820
+
1821
+ const sendText = async () => {
1822
+ const value = text.trim();
1823
+ if (!value || busy) return;
1824
+ setInputOpen(false);
1825
+ const sent = await runAction({ type: 'text', text: value });
1826
+ if (sent) setText((current) => (current === value ? '' : current));
1827
+ };
1828
+
1829
+ const openUrl = () => {
1830
+ const value = url.trim();
1831
+ if (!value || busy) return;
1832
+ void runAction({ type: 'openUrl', url: value });
1833
+ setUrl('');
1834
+ setLinkOpen(false);
1835
+ };
1836
+
1837
+ const startDrag = (event) => {
1838
+ if (event.target.closest?.('button') || event.target.closest?.('input') || event.target.closest?.('textarea')) return;
1839
+ dragRef.current = {
1840
+ clientX: event.clientX,
1841
+ clientY: event.clientY,
1842
+ x: offset.x,
1843
+ y: offset.y,
1844
+ };
1845
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1846
+ };
1847
+
1848
+ const moveDrag = (event) => {
1849
+ const drag = dragRef.current;
1850
+ if (!drag) return;
1851
+ const width = window.innerWidth;
1852
+ const height = window.innerHeight;
1853
+ const nextX = Math.max(-width + 120, Math.min(width - 120, drag.x + event.clientX - drag.clientX));
1854
+ const nextY = Math.max(-80, Math.min(height - 120, drag.y + event.clientY - drag.clientY));
1855
+ setOffset({ x: nextX, y: nextY });
1856
+ };
1857
+
1858
+ const endDrag = () => {
1859
+ if (!dragRef.current) return;
1860
+ dragRef.current = null;
1861
+ shared.offset = offset;
1862
+ saveStore();
1863
+ };
1864
+
1865
+ const showRailTip = (label, element) => {
1866
+ const rect = element.getBoundingClientRect();
1867
+ setRailTip({
1868
+ label,
1869
+ top: Math.round(rect.top + rect.height / 2),
1870
+ right: Math.max(8, Math.round(window.innerWidth - rect.left + 8)),
1871
+ });
1872
+ };
1873
+
1874
+ const railButton = (label, icon, action, options = {}) => h('button', {
1875
+ type: 'button',
1876
+ 'aria-label': label,
1877
+ 'aria-disabled': options.disabled ? 'true' : 'false',
1878
+ 'data-disabled': options.disabled ? 'true' : 'false',
1879
+ 'data-active': options.active ? 'true' : 'false',
1880
+ onMouseEnter: (event) => showRailTip(label, event.currentTarget),
1881
+ onMouseLeave: () => setRailTip(null),
1882
+ onFocus: (event) => showRailTip(label, event.currentTarget),
1883
+ onBlur: () => setRailTip(null),
1884
+ onClick: () => {
1885
+ if (options.disabled) return;
1886
+ setRailTip(null);
1887
+ action();
1888
+ },
1889
+ }, h(Icon, { name: icon, size: 14 }));
1890
+
1891
+ const statusText = statusLabel(t, status);
1892
+ const offline = status === 'error' || status === 'offline';
1893
+ const isCompact = frameWidth < 330;
1894
+
1895
+ return h('div', {
1896
+ className: 'dd-panel',
1897
+ style: { transform: `translate(${offset.x}px, ${offset.y}px)` },
1898
+ role: 'dialog',
1899
+ 'aria-label': t.title,
1900
+ },
1901
+ h('div', {
1902
+ ref: frameRef,
1903
+ className: 'dd-frame',
1904
+ style: {
1905
+ width: `${frameWidth}px`,
1906
+ ...(stripOpen ? { height: `calc(min(70vh, 840px) + ${STRIP_HEIGHT}px)` } : {}),
1907
+ },
1908
+ },
1909
+ h('div', {
1910
+ className: 'dd-head',
1911
+ onPointerDown: startDrag,
1912
+ onPointerMove: moveDrag,
1913
+ onPointerUp: endDrag,
1914
+ onPointerCancel: endDrag,
1915
+ },
1916
+ h('div', { className: 'dd-head-left' },
1917
+ h('div', { ref: menuRef, style: { position: 'relative' } },
1918
+ h('button', {
1919
+ type: 'button',
1920
+ className: 'dd-device',
1921
+ title: t.switchDevice,
1922
+ 'data-open': switcherOpen ? 'true' : 'false',
1923
+ onClick: () => setSwitcherOpen((value) => !value),
1924
+ },
1925
+ h(Icon, { name: 'phone', size: 13 }),
1926
+ h('span', { className: 'dd-device-name', style: { maxWidth: isCompact ? 150 : 240 } }, deviceName),
1927
+ h(Icon, { name: 'chevron', size: 10 }),
1928
+ ),
1929
+ switcherOpen && h('div', { className: 'dd-menu' },
1930
+ h('div', { className: 'dd-menu-title' }, t.switchDevice),
1931
+ devices.length === 0
1932
+ ? h('div', { style: { padding: '8px 7px', color: '#a1a1aa', fontSize: '11.5px' } }, t.noDevice)
1933
+ : devices.map((item) => h('button', {
1934
+ key: item.serial,
1935
+ type: 'button',
1936
+ className: 'dd-menu-item',
1937
+ 'data-active': item.serial === serial ? 'true' : 'false',
1938
+ disabled: item.state !== 'device',
1939
+ onClick: () => {
1940
+ setSwitcherOpen(false);
1941
+ if (item.state !== 'device') return;
1942
+ setSerial(item.serial);
1943
+ },
1944
+ },
1945
+ h(Icon, { name: 'phone', size: 12 }),
1946
+ h('span', null, item.model || item.serial),
1947
+ h('em', null, item.state === 'device' ? (item.isEmulator ? '模拟器' : '') : item.state),
1948
+ )),
1949
+ ),
1950
+ ),
1951
+ h('div', { className: 'dd-head-right' },
1952
+ h('span', { className: 'dd-status', 'data-status': status, 'data-compact': isCompact ? 'true' : 'false' },
1953
+ h('span', { className: 'dd-dot' }),
1954
+ !isCompact && h('span', null, statusText),
1955
+ ),
1956
+ h('button', {
1957
+ type: 'button',
1958
+ className: 'dd-head-refresh',
1959
+ title: t.railRefresh,
1960
+ 'aria-label': t.railRefresh,
1961
+ onClick: () => {
1962
+ retryRef.current = 0;
1963
+ setNonce((value) => value + 1);
1964
+ },
1965
+ }, h(Icon, { name: 'refresh', size: 12 })),
1966
+ ),
1967
+ ),
1968
+ ),
1969
+
1970
+ h('input', {
1971
+ ref: fileInputRef,
1972
+ type: 'file',
1973
+ style: { display: 'none' },
1974
+ onChange: (event) => {
1975
+ const file = event.target.files?.[0];
1976
+ if (file) void pushFile(file);
1977
+ },
1978
+ }),
1979
+
1980
+ h('div', { className: 'dd-body' },
1981
+ h('div', { className: 'dd-rail' },
1982
+ railButton(t.railBack, 'back', () => void runAction({ type: 'key', key: 'back' }), { disabled: !serial || busy }),
1983
+ railButton(t.railHome, 'home', () => void runAction({ type: 'key', key: 'home' }), { disabled: !serial || busy }),
1984
+ railButton(t.railRecents, 'recents', () => void runAction({ type: 'key', key: 'recents' }), { disabled: !serial || busy }),
1985
+ railButton(t.railText, 'keyboard', () => {
1986
+ setInputOpen((value) => !value);
1987
+ setLinkOpen(false);
1988
+ }, { disabled: !serial, active: inputOpen }),
1989
+ railButton(t.railLink, 'link', () => {
1990
+ setLinkOpen((value) => !value);
1991
+ setInputOpen(false);
1992
+ }, { disabled: !serial, active: linkOpen }),
1993
+ railButton(t.railFile, 'paperclip', () => fileInputRef.current?.click(), { disabled: !serial || pushing }),
1994
+ h('div', { className: 'dd-rail-sep' }),
1995
+ shots.length > 0 && railButton(t.railHistory, 'history', () => setStripOpen((value) => !value), { active: stripOpen }),
1996
+ railButton(t.railCapture, 'camera', () => void capture(), { disabled: !serial || busy }),
1997
+ h('div', { className: 'dd-rail-sep' }),
1998
+ railButton(t.railClose, 'minimize', onClose),
1999
+ ),
2000
+
2001
+ h('div', {
2002
+ className: 'dd-viewport',
2003
+ ref: viewportRef,
2004
+ style: { borderRadius: stripOpen ? 0 : '0 0 10px 10px' },
2005
+ onPointerDown: handlePointerDown,
2006
+ onPointerUp: handlePointerUp,
2007
+ onPointerCancel: () => { pointerRef.current = null; },
2008
+ onContextMenu: (event) => event.preventDefault(),
2009
+ },
2010
+ h('canvas', { className: 'dd-canvas', ref: canvasRef }),
2011
+
2012
+ ripple && h('span', {
2013
+ className: 'dd-ripple',
2014
+ style: { left: `${ripple.left}px`, top: `${ripple.top}px` },
2015
+ }),
2016
+
2017
+ !serial && h('div', { className: 'dd-overlay' },
2018
+ h(Icon, { name: 'alert', size: 20 }),
2019
+ h('strong', null, adb?.available === false ? t.adbMissing : t.noDevice),
2020
+ h('p', null, adb?.available === false ? (adb.error || t.adbMissing) : t.noDeviceHint),
2021
+ h('button', {
2022
+ type: 'button',
2023
+ className: 'dd-btn',
2024
+ onClick: () => {
2025
+ setLoadingDevices(true);
2026
+ void refreshDevices();
2027
+ },
2028
+ }, h(Icon, { name: 'refresh', size: 12 }), t.refresh),
2029
+ ),
2030
+
2031
+ serial && status === 'connecting' && h('div', { className: 'dd-overlay' },
2032
+ h('span', { className: 'dd-spinner' }),
2033
+ h('span', null, t.connectingHint),
2034
+ ),
2035
+
2036
+ serial && offline && h('div', { className: 'dd-overlay' },
2037
+ h(Icon, { name: 'alert', size: 20 }),
2038
+ h('strong', null, status === 'offline' ? t.disconnected : t.streamError),
2039
+ h('p', null, error || t.noDeviceHint),
2040
+ h('button', {
2041
+ type: 'button',
2042
+ className: 'dd-btn',
2043
+ onClick: () => {
2044
+ retryRef.current = 0;
2045
+ setNonce((value) => value + 1);
2046
+ },
2047
+ }, h(Icon, { name: 'refresh', size: 12 }), t.retry),
2048
+ ),
2049
+ ),
2050
+ ),
2051
+
2052
+ stripOpen && h('div', { className: 'dd-strip' },
2053
+ h('span', { className: 'dd-strip-label' }, t.screenshotStrip),
2054
+ shots.length === 0
2055
+ ? h('span', { style: { color: '#71717a', fontSize: '10.5px' } }, t.emptyShot)
2056
+ : shots.map((shot) => h('button', {
2057
+ key: shot.path,
2058
+ type: 'button',
2059
+ className: 'dd-thumb',
2060
+ 'data-selected': shot.selected ? 'true' : 'false',
2061
+ title: shot.selected ? t.selected : shot.name,
2062
+ onClick: () => void selectShot(shot),
2063
+ }, h('img', { src: shotUrl(shot.path), alt: shot.name, draggable: false }))),
2064
+ ),
2065
+ ),
2066
+
2067
+ inputOpen && h('div', { className: 'dd-pop', style: { top: '46%' } },
2068
+ h('div', { className: 'dd-pop-head' },
2069
+ h('span', null, t.text),
2070
+ h('button', { type: 'button', onClick: () => setInputOpen(false), title: t.close }, h(Icon, { name: 'close', size: 12 })),
2071
+ ),
2072
+ h('textarea', {
2073
+ value: text,
2074
+ autoFocus: true,
2075
+ placeholder: t.enterText,
2076
+ onChange: (event) => setText(event.target.value),
2077
+ onKeyDown: (event) => {
2078
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
2079
+ event.preventDefault();
2080
+ void sendText();
2081
+ }
2082
+ },
2083
+ }),
2084
+ h('div', { className: 'dd-pop-foot' },
2085
+ h('span', { className: 'dd-pop-hint' }, t.textHint),
2086
+ h('button', { type: 'button', className: 'dd-send', disabled: !text.trim() || busy, onClick: () => void sendText() }, t.sendText),
2087
+ ),
2088
+ ),
2089
+
2090
+ linkOpen && h('div', { className: 'dd-pop', style: { top: '56%' } },
2091
+ h('div', { className: 'dd-pop-head' },
2092
+ h('span', null, t.link),
2093
+ h('button', { type: 'button', onClick: () => setLinkOpen(false), title: t.close }, h(Icon, { name: 'close', size: 12 })),
2094
+ ),
2095
+ h('input', {
2096
+ value: url,
2097
+ autoFocus: true,
2098
+ spellCheck: false,
2099
+ placeholder: t.urlHint,
2100
+ onChange: (event) => setUrl(event.target.value),
2101
+ onKeyDown: (event) => {
2102
+ if (event.key === 'Enter') {
2103
+ event.preventDefault();
2104
+ openUrl();
2105
+ }
2106
+ },
2107
+ }),
2108
+ h('div', { className: 'dd-pop-foot' },
2109
+ h('span', { className: 'dd-pop-hint' }, t.enterUrl),
2110
+ h('button', { type: 'button', className: 'dd-send', disabled: !url.trim() || busy, onClick: openUrl }, t.open),
2111
+ ),
2112
+ ),
2113
+
2114
+ notice && h('div', { className: 'dd-notice' }, notice),
2115
+ railTip && ReactDOM.createPortal(
2116
+ h('div', {
2117
+ className: 'dd-rail-tip',
2118
+ style: { top: `${railTip.top}px`, right: `${railTip.right}px` },
2119
+ }, railTip.label),
2120
+ document.body,
2121
+ ),
2122
+ );
2123
+ }
2124
+
2125
+ function IosPanel({ ctx, onClose }) {
2126
+ const t = useCopy(ctx);
2127
+ const [udid, setUdid] = useState(shared.serial || '');
2128
+ const [kind, setKind] = useState(shared.iosKind === 'simulator' ? 'simulator' : 'device');
2129
+ const [devices, setDevices] = useState([]);
2130
+ const [status, setStatus] = useState('starting');
2131
+ const [error, setError] = useState('');
2132
+ const [progress, setProgress] = useState('');
2133
+ const [screen, setScreen] = useState({ width: 390, height: 844 });
2134
+ const [nonce, setNonce] = useState(0);
2135
+ const [offset, setOffset] = useState(shared.offset || { x: 0, y: 0 });
2136
+ const [frameWidth, setFrameWidth] = useState(320);
2137
+ const [shots, setShots] = useState([]);
2138
+ const [stripOpen, setStripOpen] = useState(false);
2139
+ const [inputOpen, setInputOpen] = useState(false);
2140
+ const [text, setText] = useState('');
2141
+ const [notice, setNotice] = useState('');
2142
+ const [ripple, setRipple] = useState(null);
2143
+ const [railTip, setRailTip] = useState(null);
2144
+ const [switcherOpen, setSwitcherOpen] = useState(false);
2145
+
2146
+ const viewportRef = useRef(null);
2147
+ const pointerRef = useRef(null);
2148
+ const dragRef = useRef(null);
2149
+ const menuRef = useRef(null);
2150
+ const frameRef = useRef(null);
2151
+
2152
+ const device = devices.find((item) => item.udid === udid);
2153
+ const deviceName = device?.name || udid || 'iPhone';
2154
+
2155
+ useEffect(() => {
2156
+ if (!udid) return;
2157
+ shared.serial = udid;
2158
+ shared.platform = 'ios';
2159
+ shared.iosKind = kind;
2160
+ saveStore();
2161
+ }, [udid, kind]);
2162
+
2163
+ const refreshDevices = useCallback(async () => {
2164
+ try {
2165
+ const data = await apiJson('/inventory');
2166
+ const physical = (data?.ios?.devices?.items || []).map((item) => ({
2167
+ udid: item.udid,
2168
+ name: item.name,
2169
+ detail: [item.model, item.osVersion ? `iOS ${item.osVersion}` : '', '真机'].filter(Boolean).join(' · '),
2170
+ kind: 'device',
2171
+ }));
2172
+ const simulators = (data?.ios?.running || []).map((item) => ({
2173
+ udid: item.udid,
2174
+ name: item.name,
2175
+ detail: [item.version ? `iOS ${item.version}` : '', '模拟器'].filter(Boolean).join(' · '),
2176
+ kind: 'simulator',
2177
+ }));
2178
+ setDevices([...physical, ...simulators]);
2179
+ } catch {
2180
+ /* keep the previous list */
2181
+ }
2182
+ }, []);
2183
+
2184
+ useEffect(() => {
2185
+ void refreshDevices();
2186
+ }, [refreshDevices]);
2187
+
2188
+ const refreshShots = useCallback(async () => {
2189
+ if (!udid) {
2190
+ setShots([]);
2191
+ return;
2192
+ }
2193
+ try {
2194
+ const data = await apiJson(`/screenshots?serial=${encodeURIComponent(udid)}`);
2195
+ setShots(Array.isArray(data.items) ? data.items : []);
2196
+ } catch {
2197
+ /* history is best effort */
2198
+ }
2199
+ }, [udid]);
2200
+
2201
+ useEffect(() => {
2202
+ void refreshShots();
2203
+ }, [refreshShots]);
2204
+
2205
+ useEffect(() => {
2206
+ if (!notice) return undefined;
2207
+ const timer = window.setTimeout(() => setNotice(''), 3200);
2208
+ return () => window.clearTimeout(timer);
2209
+ }, [notice]);
2210
+
2211
+ useEffect(() => {
2212
+ const frame = frameRef.current;
2213
+ if (!frame) return undefined;
2214
+ const measure = () => {
2215
+ const height = frame.clientHeight;
2216
+ if (height <= 0) return;
2217
+ const usable = height - HEADER_HEIGHT - (stripOpen ? STRIP_HEIGHT : 0);
2218
+ if (usable <= 0) return;
2219
+ const aspect = screen.width / Math.max(1, screen.height);
2220
+ const maxWidth = Math.max(240, Math.min(520, window.innerWidth - 80));
2221
+ const next = Math.max(240, Math.min(maxWidth, Math.round(usable * aspect)));
2222
+ setFrameWidth((previous) => (Math.abs(previous - next) <= 1 ? previous : next));
2223
+ };
2224
+ measure();
2225
+ const observer = new ResizeObserver(measure);
2226
+ observer.observe(frame);
2227
+ window.addEventListener('resize', measure);
2228
+ return () => {
2229
+ observer.disconnect();
2230
+ window.removeEventListener('resize', measure);
2231
+ };
2232
+ }, [screen.width, screen.height, stripOpen]);
2233
+
2234
+ useEffect(() => {
2235
+ if (!udid) return undefined;
2236
+ let cancelled = false;
2237
+ setStatus('starting');
2238
+ setError('');
2239
+ setProgress(t.iosPreparing);
2240
+ const poll = window.setInterval(() => {
2241
+ void apiJson(`/ios/mirror/status?udid=${encodeURIComponent(udid)}`)
2242
+ .then((data) => {
2243
+ if (cancelled) return;
2244
+ if (data?.message) setProgress(data.message);
2245
+ })
2246
+ .catch(() => undefined);
2247
+ }, 1500);
2248
+ void postJson('/ios/mirror/start', { udid, kind })
2249
+ .then((data) => {
2250
+ if (cancelled) return;
2251
+ if (data?.screen?.width) setScreen({ width: data.screen.width, height: data.screen.height });
2252
+ setProgress('');
2253
+ setStatus('live');
2254
+ })
2255
+ .catch((startError) => {
2256
+ if (cancelled) return;
2257
+ setStatus('error');
2258
+ setError(startError.message);
2259
+ })
2260
+ .finally(() => window.clearInterval(poll));
2261
+ return () => {
2262
+ cancelled = true;
2263
+ window.clearInterval(poll);
2264
+ };
2265
+ }, [udid, kind, nonce]);
2266
+
2267
+ const ratioFromEvent = (clientX, clientY) => {
2268
+ const viewport = viewportRef.current;
2269
+ if (!viewport) return null;
2270
+ const rect = viewport.getBoundingClientRect();
2271
+ const x = (clientX - rect.left) / Math.max(1, rect.width);
2272
+ const y = (clientY - rect.top) / Math.max(1, rect.height);
2273
+ return { x, y, inside: x >= 0 && x <= 1 && y >= 0 && y <= 1 };
2274
+ };
2275
+
2276
+ const sendAction = useCallback(async (action) => {
2277
+ if (!udid) return false;
2278
+ try {
2279
+ await postJson('/ios/action', { udid, kind, action });
2280
+ return true;
2281
+ } catch (actionError) {
2282
+ setNotice(actionError.message);
2283
+ return false;
2284
+ }
2285
+ }, [udid, kind]);
2286
+
2287
+ const handlePointerDown = (event) => {
2288
+ if (status !== 'live' || event.button === 2) return;
2289
+ const ratio = ratioFromEvent(event.clientX, event.clientY);
2290
+ if (!ratio || !ratio.inside) return;
2291
+ event.currentTarget.setPointerCapture?.(event.pointerId);
2292
+ pointerRef.current = {
2293
+ x: ratio.x,
2294
+ y: ratio.y,
2295
+ clientX: event.clientX,
2296
+ clientY: event.clientY,
2297
+ time: Date.now(),
2298
+ };
2299
+ };
2300
+
2301
+ const handlePointerUp = (event) => {
2302
+ const start = pointerRef.current;
2303
+ pointerRef.current = null;
2304
+ if (!start || status !== 'live') return;
2305
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
2306
+ event.currentTarget.releasePointerCapture(event.pointerId);
2307
+ }
2308
+ const ratio = ratioFromEvent(event.clientX, event.clientY);
2309
+ if (!ratio) return;
2310
+ const dx = event.clientX - start.clientX;
2311
+ const dy = event.clientY - start.clientY;
2312
+ const distance = Math.sqrt(dx * dx + dy * dy);
2313
+ const duration = Date.now() - start.time;
2314
+ if (distance < 12 && duration < 400) {
2315
+ const viewport = viewportRef.current;
2316
+ if (viewport) {
2317
+ const rect = viewport.getBoundingClientRect();
2318
+ const id = Date.now();
2319
+ setRipple({ left: event.clientX - rect.left, top: event.clientY - rect.top, id });
2320
+ window.setTimeout(() => setRipple((current) => (current?.id === id ? null : current)), 280);
2321
+ }
2322
+ void sendAction({ type: 'tap', ratioX: ratio.x, ratioY: ratio.y });
2323
+ return;
2324
+ }
2325
+ void sendAction({
2326
+ type: 'swipe',
2327
+ ratioX: start.x,
2328
+ ratioY: start.y,
2329
+ ratioEndX: ratio.x,
2330
+ ratioEndY: ratio.y,
2331
+ duration: Math.max(120, Math.min(1000, duration)),
2332
+ });
2333
+ };
2334
+
2335
+ const capture = async () => {
2336
+ if (!udid) return;
2337
+ try {
2338
+ const data = await postJson('/ios/screenshot', { udid, kind });
2339
+ setShots((previous) => [data.shot, ...previous.map((item) => ({ ...item, selected: false }))]);
2340
+ setStripOpen(true);
2341
+ setNotice(t.shotSaved);
2342
+ } catch (captureError) {
2343
+ setNotice(captureError.message);
2344
+ }
2345
+ };
2346
+
2347
+ const stopMirror = async () => {
2348
+ try {
2349
+ await postJson('/ios/mirror/stop', { udid });
2350
+ } catch {
2351
+ /* the session may already be gone */
2352
+ }
2353
+ onClose();
2354
+ };
2355
+
2356
+ const sendText = async () => {
2357
+ const value = text.trim();
2358
+ if (!value) return;
2359
+ setInputOpen(false);
2360
+ const sent = await sendAction({ type: 'text', text: value });
2361
+ if (sent) setText('');
2362
+ };
2363
+
2364
+ const startDrag = (event) => {
2365
+ if (event.target.closest?.('button') || event.target.closest?.('input') || event.target.closest?.('textarea')) return;
2366
+ dragRef.current = { clientX: event.clientX, clientY: event.clientY, x: offset.x, y: offset.y };
2367
+ event.currentTarget.setPointerCapture?.(event.pointerId);
2368
+ };
2369
+
2370
+ const moveDrag = (event) => {
2371
+ const drag = dragRef.current;
2372
+ if (!drag) return;
2373
+ const width = window.innerWidth;
2374
+ const height = window.innerHeight;
2375
+ const nextX = Math.max(-width + 120, Math.min(width - 120, drag.x + event.clientX - drag.clientX));
2376
+ const nextY = Math.max(-80, Math.min(height - 120, drag.y + event.clientY - drag.clientY));
2377
+ setOffset({ x: nextX, y: nextY });
2378
+ };
2379
+
2380
+ const endDrag = () => {
2381
+ if (!dragRef.current) return;
2382
+ dragRef.current = null;
2383
+ shared.offset = offset;
2384
+ saveStore();
2385
+ };
2386
+
2387
+ const showRailTip = (label, element) => {
2388
+ const rect = element.getBoundingClientRect();
2389
+ setRailTip({
2390
+ label,
2391
+ top: Math.round(rect.top + rect.height / 2),
2392
+ right: Math.max(8, Math.round(window.innerWidth - rect.left + 8)),
2393
+ });
2394
+ };
2395
+
2396
+ const railButton = (label, icon, action, options = {}) => h('button', {
2397
+ type: 'button',
2398
+ 'aria-label': label,
2399
+ 'aria-disabled': options.disabled ? 'true' : 'false',
2400
+ 'data-disabled': options.disabled ? 'true' : 'false',
2401
+ 'data-active': options.active ? 'true' : 'false',
2402
+ onMouseEnter: (event) => showRailTip(label, event.currentTarget),
2403
+ onMouseLeave: () => setRailTip(null),
2404
+ onFocus: (event) => showRailTip(label, event.currentTarget),
2405
+ onBlur: () => setRailTip(null),
2406
+ onClick: () => {
2407
+ if (options.disabled) return;
2408
+ setRailTip(null);
2409
+ action();
2410
+ },
2411
+ }, h(Icon, { name: icon, size: 14 }));
2412
+
2413
+ const isCompact = frameWidth < 330;
2414
+ const isLive = status === 'live';
2415
+
2416
+ return h('div', {
2417
+ className: 'dd-panel',
2418
+ style: { transform: `translate(${offset.x}px, ${offset.y}px)` },
2419
+ role: 'dialog',
2420
+ 'aria-label': t.title,
2421
+ },
2422
+ h('div', {
2423
+ ref: frameRef,
2424
+ className: 'dd-frame',
2425
+ style: {
2426
+ width: `${frameWidth}px`,
2427
+ ...(stripOpen ? { height: `calc(min(70vh, 840px) + ${STRIP_HEIGHT}px)` } : {}),
2428
+ },
2429
+ },
2430
+ h('div', {
2431
+ className: 'dd-head',
2432
+ onPointerDown: startDrag,
2433
+ onPointerMove: moveDrag,
2434
+ onPointerUp: endDrag,
2435
+ onPointerCancel: endDrag,
2436
+ },
2437
+ h('div', { className: 'dd-head-left' },
2438
+ h('div', { ref: menuRef, style: { position: 'relative' } },
2439
+ h('button', {
2440
+ type: 'button',
2441
+ className: 'dd-device',
2442
+ title: t.switchDevice,
2443
+ 'data-open': switcherOpen ? 'true' : 'false',
2444
+ onClick: () => setSwitcherOpen((value) => !value),
2445
+ },
2446
+ h(Icon, { name: 'phone', size: 13 }),
2447
+ h('span', { className: 'dd-device-name', style: { maxWidth: isCompact ? 150 : 240 } }, deviceName),
2448
+ h(Icon, { name: 'chevron', size: 10 }),
2449
+ ),
2450
+ switcherOpen && h('div', { className: 'dd-menu' },
2451
+ h('div', { className: 'dd-menu-title' }, t.switchDevice),
2452
+ devices.length === 0
2453
+ ? h('div', { style: { padding: '8px 7px', color: '#a1a1aa', fontSize: '11.5px' } }, t.menuEmpty)
2454
+ : devices.map((item) => h('button', {
2455
+ key: item.udid,
2456
+ type: 'button',
2457
+ className: 'dd-menu-item',
2458
+ 'data-active': item.udid === udid && item.kind === kind ? 'true' : 'false',
2459
+ onClick: () => {
2460
+ setSwitcherOpen(false);
2461
+ setKind(item.kind);
2462
+ setUdid(item.udid);
2463
+ },
2464
+ },
2465
+ h(Icon, { name: 'phone', size: 12 }),
2466
+ h('span', null, item.name),
2467
+ h('em', null, item.detail || ''),
2468
+ )),
2469
+ ),
2470
+ ),
2471
+ h('div', { className: 'dd-head-right' },
2472
+ h('span', { className: 'dd-status', 'data-status': isLive ? 'live' : status === 'error' ? 'error' : 'connecting', 'data-compact': isCompact ? 'true' : 'false' },
2473
+ h('span', { className: 'dd-dot' }),
2474
+ !isCompact && h('span', null, isLive ? t.iosReady : status === 'error' ? t.iosFailed : t.menuStarting),
2475
+ ),
2476
+ h('button', {
2477
+ type: 'button',
2478
+ className: 'dd-head-refresh',
2479
+ title: t.retry,
2480
+ 'aria-label': t.retry,
2481
+ onClick: () => setNonce((value) => value + 1),
2482
+ }, h(Icon, { name: 'refresh', size: 12 })),
2483
+ ),
2484
+ ),
2485
+ ),
2486
+
2487
+ h('div', { className: 'dd-body' },
2488
+ h('div', { className: 'dd-rail' },
2489
+ railButton(t.iosHome, 'home', () => void sendAction({ type: 'home' }), { disabled: !isLive }),
2490
+ railButton(t.railText, 'keyboard', () => setInputOpen((value) => !value), { disabled: !isLive, active: inputOpen }),
2491
+ h('div', { className: 'dd-rail-sep' }),
2492
+ shots.length > 0 && railButton(t.railHistory, 'history', () => setStripOpen((value) => !value), { active: stripOpen }),
2493
+ railButton(t.railCapture, 'camera', () => void capture(), { disabled: !isLive }),
2494
+ h('div', { className: 'dd-rail-sep' }),
2495
+ railButton(t.railClose, 'minimize', onClose),
2496
+ railButton(t.iosStop, 'power', () => void stopMirror(), { disabled: !udid }),
2497
+ ),
2498
+
2499
+ h('div', {
2500
+ className: 'dd-viewport',
2501
+ ref: viewportRef,
2502
+ style: { borderRadius: stripOpen ? 0 : '0 0 10px 10px' },
2503
+ onPointerDown: handlePointerDown,
2504
+ onPointerUp: handlePointerUp,
2505
+ onPointerCancel: () => { pointerRef.current = null; },
2506
+ onContextMenu: (event) => event.preventDefault(),
2507
+ },
2508
+ isLive && h('img', {
2509
+ className: 'dd-canvas',
2510
+ key: `${udid}-${nonce}`,
2511
+ src: `${API}/ios/stream?udid=${encodeURIComponent(udid)}&kind=${kind}&_=${nonce}`,
2512
+ alt: deviceName,
2513
+ draggable: false,
2514
+ onError: () => {
2515
+ setStatus('error');
2516
+ setError(t.streamError);
2517
+ },
2518
+ }),
2519
+
2520
+ ripple && h('span', {
2521
+ className: 'dd-ripple',
2522
+ style: { left: `${ripple.left}px`, top: `${ripple.top}px` },
2523
+ }),
2524
+
2525
+ !udid && h('div', { className: 'dd-overlay' },
2526
+ h(Icon, { name: 'alert', size: 20 }),
2527
+ h('strong', null, t.menuEmpty),
2528
+ h('p', null, t.menuEmptyHint),
2529
+ ),
2530
+
2531
+ udid && status === 'starting' && h('div', { className: 'dd-overlay' },
2532
+ h('span', { className: 'dd-spinner' }),
2533
+ h('span', null, progress || t.iosStarting),
2534
+ h('p', null, t.menuIosWdaHint),
2535
+ ),
2536
+
2537
+ udid && status === 'error' && h('div', { className: 'dd-overlay' },
2538
+ h(Icon, { name: 'alert', size: 20 }),
2539
+ h('strong', null, t.iosFailed),
2540
+ h('p', null, error || t.menuIosWdaHint),
2541
+ h('button', {
2542
+ type: 'button',
2543
+ className: 'dd-btn',
2544
+ onClick: () => setNonce((value) => value + 1),
2545
+ }, h(Icon, { name: 'refresh', size: 12 }), t.retry),
2546
+ ),
2547
+ ),
2548
+ ),
2549
+
2550
+ stripOpen && h('div', { className: 'dd-strip' },
2551
+ h('span', { className: 'dd-strip-label' }, t.screenshotStrip),
2552
+ shots.length === 0
2553
+ ? h('span', { style: { color: '#71717a', fontSize: '10.5px' } }, t.emptyShot)
2554
+ : shots.map((shot) => h('button', {
2555
+ key: shot.path,
2556
+ type: 'button',
2557
+ className: 'dd-thumb',
2558
+ 'data-selected': shot.selected ? 'true' : 'false',
2559
+ title: shot.selected ? t.selected : shot.name,
2560
+ onClick: () => {
2561
+ void apiJson('/screenshots/select', {
2562
+ method: 'POST',
2563
+ headers: { 'Content-Type': 'application/json' },
2564
+ body: JSON.stringify({ path: shot.path }),
2565
+ }).catch(() => undefined);
2566
+ },
2567
+ }, h('img', { src: shotUrl(shot.path), alt: shot.name, draggable: false }))),
2568
+ ),
2569
+ ),
2570
+
2571
+ inputOpen && h('div', { className: 'dd-pop', style: { top: '46%' } },
2572
+ h('div', { className: 'dd-pop-head' },
2573
+ h('span', null, t.text),
2574
+ h('button', { type: 'button', onClick: () => setInputOpen(false), title: t.close }, h(Icon, { name: 'close', size: 12 })),
2575
+ ),
2576
+ h('textarea', {
2577
+ value: text,
2578
+ autoFocus: true,
2579
+ placeholder: t.enterText,
2580
+ onChange: (event) => setText(event.target.value),
2581
+ onKeyDown: (event) => {
2582
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
2583
+ event.preventDefault();
2584
+ void sendText();
2585
+ }
2586
+ },
2587
+ }),
2588
+ h('div', { className: 'dd-pop-foot' },
2589
+ h('span', { className: 'dd-pop-hint' }, t.textHint),
2590
+ h('button', { type: 'button', className: 'dd-send', disabled: !text.trim(), onClick: () => void sendText() }, t.sendText),
2591
+ ),
2592
+ ),
2593
+
2594
+ notice && h('div', { className: 'dd-notice' }, notice),
2595
+ railTip && ReactDOM.createPortal(
2596
+ h('div', {
2597
+ className: 'dd-rail-tip',
2598
+ style: { top: `${railTip.top}px`, right: `${railTip.right}px` },
2599
+ }, railTip.label),
2600
+ document.body,
2601
+ ),
2602
+ );
2603
+ }
2604
+
2605
+ function ShotMenuIcon({ size = 14 }) {
2606
+ return h(Icon, { name: 'camera', size });
2607
+ }
2608
+
2609
+ return {
2610
+ inject: ['slots', 'sessions', 'locale', 'conversation'],
2611
+ apply(ctx) {
2612
+ const Launcher = makeLauncher(ctx);
2613
+ const MirrorHost = makeMirrorHost(ctx);
2614
+ ctx.slots.inject('shell.overlay', () =>
2615
+ ctx.slots.register({
2616
+ name: 'shell.overlay',
2617
+ id: 'device-deck-mirror',
2618
+ order: 20,
2619
+ }, MirrorHost),
2620
+ );
2621
+ ctx.slots.inject('conversation.session.header.utilities', () =>
2622
+ ctx.slots.register({
2623
+ name: 'conversation.session.header.utilities',
2624
+ id: 'device-deck',
2625
+ order: 10,
2626
+ }, Launcher),
2627
+ );
2628
+ ctx.inject(['commandUi'], (scope) => {
2629
+ const commands = scope.get('commandUi');
2630
+ scope.effect(() => commands.register({
2631
+ name: 'device-shot',
2632
+ label: () => currentCopy(ctx).menuShot,
2633
+ description: () => currentCopy(ctx).menuShotHint,
2634
+ icon: ShotMenuIcon,
2635
+ available: (session) => Boolean(session?.sessionId && ctx.sessions?.binding?.(session.sessionId)),
2636
+ ui: {
2637
+ kind: 'action',
2638
+ run: (session) => {
2639
+ openShotPicker(session.sessionId);
2640
+ },
2641
+ },
2642
+ }), 'device-deck: screenshot command');
2643
+ });
2644
+ },
2645
+ };
2646
+ },
2647
+ });