dsh-mobilecode 0.1.0
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/LICENSE +26 -0
- package/README.md +164 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +828 -0
- package/lib/device-build.js +964 -0
- package/lib/device-preview.js +870 -0
- package/lib/index.js +976 -0
- package/lib/setup.js +243 -0
- package/package.json +68 -0
- package/scripts/ocr.py +111 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — browser half. Runs inside the dsh web GUI.
|
|
3
|
+
*
|
|
4
|
+
* Renders the device pane, the DSH equivalent of mobilecode's embedded
|
|
5
|
+
* device-preview UI:
|
|
6
|
+
* - sidebar entry row toggling the pane (DOM-level injection, self-healing),
|
|
7
|
+
* - directory input (remembers the last one) + platform pills from detect,
|
|
8
|
+
* - per platform: start/stop the preview server, run/stop the app,
|
|
9
|
+
* - embedded iframe of the serve-sim / serve-avd stream,
|
|
10
|
+
* - build status, error and expandable log tail,
|
|
11
|
+
* - polls GET /api/dsh-mobilecode every ~2s while open.
|
|
12
|
+
*
|
|
13
|
+
* Bundle format: `window.__ModuleLoader__.load({id, factory})` (lazy CJS) —
|
|
14
|
+
* the only client bundle format the web shell materializes.
|
|
15
|
+
*/
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: "dsh-mobilecode",
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
var module = { exports: {} };
|
|
20
|
+
var exports = module.exports;
|
|
21
|
+
|
|
22
|
+
const { createElement: h, useEffect, useMemo, useRef, useState, useSyncExternalStore } = require("react");
|
|
23
|
+
const { createRoot } = require("react-dom/client");
|
|
24
|
+
|
|
25
|
+
//#region styles
|
|
26
|
+
const STYLE = `
|
|
27
|
+
[data-dsh-mobilecode-entry] {
|
|
28
|
+
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
29
|
+
padding: 9px 12px; margin: 2px 0; border: 0; border-radius: 8px;
|
|
30
|
+
background: transparent; color: inherit; font: inherit; cursor: pointer;
|
|
31
|
+
text-align: left;
|
|
32
|
+
}
|
|
33
|
+
[data-dsh-mobilecode-entry]:hover { background: rgba(128,128,128,.14); }
|
|
34
|
+
[data-dsh-mobilecode-entry][data-active] { background: rgba(128,128,128,.22); }
|
|
35
|
+
[data-dsh-mobilecode-entry] .mc-entry-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex: none; }
|
|
36
|
+
[data-dsh-mobilecode-entry] .mc-entry-label { font-size: 13px; line-height: 1.2; opacity: .92; }
|
|
37
|
+
.dsh-mobilecode-view { position: fixed; top: 0; right: 0; bottom: 0; width: min(560px, 94vw); z-index: 999999; left: auto; }
|
|
38
|
+
.dsh-mobilecode-view[hidden] { display: none !important; }
|
|
39
|
+
.dsh-mobilecode-panel {
|
|
40
|
+
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
41
|
+
background: light-dark(#ffffff, #171a1f);
|
|
42
|
+
color: light-dark(#1a1d23, #dfe3ea);
|
|
43
|
+
border-left: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14));
|
|
44
|
+
box-shadow: -10px 0 28px rgba(0,0,0,.22);
|
|
45
|
+
}
|
|
46
|
+
.dsh-mobilecode-resize { position: absolute; left: -6px; top: 0; bottom: 0; width: 12px; cursor: col-resize; z-index: 2; display: flex; align-items: center; justify-content: center; }
|
|
47
|
+
.dsh-mobilecode-resize::before { content: ""; width: 3px; height: 48px; border-radius: 2px; background: light-dark(rgba(0,0,0,.3), rgba(255,255,255,.35)); transition: background .15s, height .15s; }
|
|
48
|
+
.dsh-mobilecode-resize:hover::before, .dsh-mobilecode-resize[data-drag]::before { background: light-dark(rgba(0,0,0,.6), rgba(255,255,255,.75)); height: 72px; }
|
|
49
|
+
.mc-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
|
50
|
+
.mc-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; }
|
|
51
|
+
.mc-title { font-size: 15px; font-weight: 700; margin: 0; letter-spacing: .2px; flex: 1; }
|
|
52
|
+
.mc-close { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 6px; font-size: 13px; }
|
|
53
|
+
.mc-close:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
|
|
54
|
+
.mc-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
|
|
55
|
+
.mc-row { display: flex; align-items: center; gap: 8px; }
|
|
56
|
+
.mc-input { flex: 1; min-width: 0; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)); border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 7px; color: inherit; font: inherit; font-size: 12px; padding: 6px 9px; }
|
|
57
|
+
.mc-input:focus { outline: 2px solid rgba(66,133,244,.35); border-color: rgba(66,133,244,.5); }
|
|
58
|
+
.mc-btn { border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 7px; cursor: pointer; transition: background .12s; white-space: nowrap; }
|
|
59
|
+
.mc-btn:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
|
|
60
|
+
.mc-btn[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); border-color: rgba(66,133,244,.45); color: #4285f4; }
|
|
61
|
+
.mc-btn:disabled { opacity: .45; cursor: default; }
|
|
62
|
+
.mc-btn.primary { background: #4285f4; border-color: #4285f4; color: #fff; }
|
|
63
|
+
.mc-btn.primary:hover { background: #3b78e7; }
|
|
64
|
+
.mc-btn.danger { color: #ef5350; }
|
|
65
|
+
.mc-pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
66
|
+
.mc-pill { display: inline-flex; align-items: center; gap: 6px; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 5px 10px; border-radius: 999px; cursor: pointer; }
|
|
67
|
+
.mc-pill:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
|
|
68
|
+
.mc-pill[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); border-color: rgba(66,133,244,.45); color: #4285f4; }
|
|
69
|
+
.mc-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; }
|
|
70
|
+
.mc-dot.on { background: #4caf50; box-shadow: 0 0 6px rgba(76,175,80,.6); }
|
|
71
|
+
.mc-dot.off { background: #9e9e9e; }
|
|
72
|
+
.mc-dot.warn { background: #ff9800; }
|
|
73
|
+
.mc-dot.err { background: #ef5350; }
|
|
74
|
+
.mc-card { border: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
75
|
+
.mc-card-head { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; }
|
|
76
|
+
.mc-card-head .sp { flex: 1; }
|
|
77
|
+
.mc-status { font-size: 12px; color: light-dark(#4a4f58, #b9bec7); display: flex; flex-direction: column; gap: 2px; }
|
|
78
|
+
.mc-status b { font-weight: 600; color: inherit; }
|
|
79
|
+
.mc-error { font-size: 12px; color: #ef5350; background: rgba(239,83,80,.1); border: 1px solid rgba(239,83,80,.3); border-radius: 7px; padding: 7px 9px; white-space: pre-wrap; word-break: break-all; }
|
|
80
|
+
.mc-log-toggle { font-size: 11px; color: #4285f4; cursor: pointer; border: 0; background: transparent; padding: 2px 0; text-align: left; font: inherit; }
|
|
81
|
+
.mc-log { max-height: 180px; overflow: auto; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.06)); border-radius: 7px; padding: 8px; margin: 0; font-size: 11px; line-height: 16px; white-space: pre-wrap; word-break: break-all; font-family: Consolas, "Cascadia Mono", monospace; }
|
|
82
|
+
.mc-frame { width: 100%; height: 300px; border: 1px solid light-dark(rgba(0,0,0,.15), rgba(255,255,255,.18)); border-radius: 10px; background: #000; display: block; }
|
|
83
|
+
.mc-empty { color: light-dark(#9aa0a8, #7c838d); font-size: 12px; padding: 8px 0; }
|
|
84
|
+
.mc-footer { display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-top: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; font-size: 11px; color: light-dark(#6b7078, #9aa0a8); flex-wrap: wrap; }
|
|
85
|
+
/* ── welcome modal ── */
|
|
86
|
+
.mc-overlay { position: fixed; inset: 0; z-index: 1000000; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.45); backdrop-filter: blur(2px); padding: 24px; }
|
|
87
|
+
.mc-modal { width: min(680px, 100%); max-height: 88vh; overflow: auto; background: light-dark(#ffffff, #1b1f26); color: light-dark(#1a1d23, #e3e7ee); border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)); border-radius: 14px; box-shadow: 0 18px 60px rgba(0,0,0,.4); display: flex; flex-direction: column; }
|
|
88
|
+
.mc-modal-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; }
|
|
89
|
+
.mc-modal-title { font-size: 16px; font-weight: 700; margin: 0; flex: 1; }
|
|
90
|
+
.mc-modal-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; font-size: 13px; line-height: 1.55; }
|
|
91
|
+
.mc-modal-foot { padding: 12px 18px; border-top: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex: none; }
|
|
92
|
+
.mc-modal-foot .sp { flex: 1; }
|
|
93
|
+
.mc-modal h3 { margin: 0; font-size: 13px; }
|
|
94
|
+
.mc-prompt { position: relative; }
|
|
95
|
+
.mc-prompt pre { margin: 0; max-height: 220px; overflow: auto; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.07)); border: 1px solid light-dark(rgba(0,0,0,.14), rgba(255,255,255,.18)); border-radius: 8px; padding: 10px 12px; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-family: Consolas, "Cascadia Mono", monospace; }
|
|
96
|
+
.mc-copy { position: absolute; top: 8px; right: 8px; }
|
|
97
|
+
.mc-tabs { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; }
|
|
98
|
+
.mc-tab { border: 0; background: transparent; color: inherit; font: inherit; font-size: 13px; padding: 9px 12px; cursor: pointer; border-bottom: 2px solid transparent; opacity: .65; }
|
|
99
|
+
.mc-tab[data-on] { opacity: 1; border-bottom-color: #4285f4; color: #4285f4; }
|
|
100
|
+
.mc-check { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.13)); border-radius: 8px; font-size: 12px; line-height: 1.5; }
|
|
101
|
+
.mc-check .mc-check-ico { flex: none; font-weight: 700; width: 18px; text-align: center; }
|
|
102
|
+
.mc-check .mc-check-detail { flex: 1; min-width: 0; word-break: break-all; }
|
|
103
|
+
.mc-check .mc-check-name { font-weight: 700; }
|
|
104
|
+
.mc-spin { display: inline-block; width: 12px; height: 12px; border: 2px solid rgba(128,128,128,.35); border-top-color: #4285f4; border-radius: 50%; animation: mc-spin .8s linear infinite; vertical-align: -2px; }
|
|
105
|
+
@keyframes mc-spin { to { transform: rotate(360deg); } }
|
|
106
|
+
.mc-logline { font-family: Consolas, "Cascadia Mono", monospace; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow: auto; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.06)); border-radius: 8px; padding: 8px 10px; margin: 0; }
|
|
107
|
+
.mc-hint { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); }
|
|
108
|
+
/* ── DSH Settings page contribution ── */
|
|
109
|
+
.mc-section { display: flex; flex-direction: column; gap: 4px; padding: 4px 6px 16px; }
|
|
110
|
+
.mc-section-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 2px 0 10px; }
|
|
111
|
+
.mc-section-head .mc-hint { flex: 1; min-width: 200px; }
|
|
112
|
+
.mc-section .mc-tabs { padding: 0; margin-bottom: 4px; }
|
|
113
|
+
`;
|
|
114
|
+
//#endregion
|
|
115
|
+
|
|
116
|
+
const API_BASE = "/api/dsh-mobilecode";
|
|
117
|
+
const PLATFORMS = ["ios", "android"];
|
|
118
|
+
const label = (p) => (p === "ios" ? "iOS" : "Android");
|
|
119
|
+
|
|
120
|
+
function PanelController() {
|
|
121
|
+
let panelOpen = false;
|
|
122
|
+
const listeners = new Set();
|
|
123
|
+
return {
|
|
124
|
+
getSnapshot: () => ({ panelOpen }),
|
|
125
|
+
subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
|
|
126
|
+
setOpen: (value) => { if (panelOpen === value) return; panelOpen = value; for (const fn of listeners) fn(); },
|
|
127
|
+
toggle: () => { const value = !panelOpen; panelOpen = value; for (const fn of listeners) fn(); },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const getInfo = async (directory) => {
|
|
132
|
+
const url = API_BASE + (directory ? "?directory=" + encodeURIComponent(directory) : "");
|
|
133
|
+
const response = await fetch(url);
|
|
134
|
+
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
135
|
+
return response.json();
|
|
136
|
+
};
|
|
137
|
+
const post = async (path, body) => {
|
|
138
|
+
const response = await fetch(API_BASE + path, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": "application/json" },
|
|
141
|
+
body: JSON.stringify(body ?? {}),
|
|
142
|
+
});
|
|
143
|
+
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
144
|
+
return response.json();
|
|
145
|
+
};
|
|
146
|
+
const apiGet = async (path) => {
|
|
147
|
+
const response = await fetch(API_BASE + path);
|
|
148
|
+
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
149
|
+
return response.json();
|
|
150
|
+
};
|
|
151
|
+
const copyText = async (text) => {
|
|
152
|
+
try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const readDir = () => { try { return localStorage.getItem("dsh-mobilecode-directory") ?? ""; } catch { return ""; } };
|
|
156
|
+
const writeDir = (value) => { try { localStorage.setItem("dsh-mobilecode-directory", value); } catch { /* private mode */ } };
|
|
157
|
+
|
|
158
|
+
function MobileCodePanel({ controller }) {
|
|
159
|
+
const [directory, setDirectory] = useState(readDir);
|
|
160
|
+
const [info, setInfo] = useState(null);
|
|
161
|
+
const [error, setError] = useState("");
|
|
162
|
+
const [busy, setBusy] = useState("");
|
|
163
|
+
const [openLogs, setOpenLogs] = useState({});
|
|
164
|
+
const [frameKey, setFrameKey] = useState(0);
|
|
165
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
166
|
+
|
|
167
|
+
// Poll while the panel is open.
|
|
168
|
+
const open = controller.getSnapshot().panelOpen;
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!open) return;
|
|
171
|
+
let stopped = false;
|
|
172
|
+
let timer;
|
|
173
|
+
const poll = async () => {
|
|
174
|
+
try {
|
|
175
|
+
const current = await getInfo(readDir());
|
|
176
|
+
if (!stopped) { setInfo(current); setError(""); }
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (!stopped) setError(err instanceof Error ? err.message : String(err));
|
|
179
|
+
}
|
|
180
|
+
if (!stopped) timer = setTimeout(poll, 2000);
|
|
181
|
+
};
|
|
182
|
+
poll();
|
|
183
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
184
|
+
}, [open]);
|
|
185
|
+
|
|
186
|
+
const act = async (name, path, body) => {
|
|
187
|
+
setBusy(name);
|
|
188
|
+
setError("");
|
|
189
|
+
try {
|
|
190
|
+
const current = await post(path, { directory, ...body });
|
|
191
|
+
setInfo(current);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
194
|
+
} finally {
|
|
195
|
+
setBusy("");
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const detect = async () => {
|
|
200
|
+
setBusy("detect");
|
|
201
|
+
setError("");
|
|
202
|
+
try {
|
|
203
|
+
const current = await getInfo(directory);
|
|
204
|
+
setInfo(current);
|
|
205
|
+
writeDir(directory);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
208
|
+
} finally {
|
|
209
|
+
setBusy("");
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const server = (platform) => info?.servers?.find((s) => s.platform === platform);
|
|
214
|
+
const build = (platform) => info?.builds?.find((b) => b.platform === platform);
|
|
215
|
+
const platforms = info?.platforms?.length > 0 ? info.platforms : PLATFORMS;
|
|
216
|
+
|
|
217
|
+
const statusClass = (status) =>
|
|
218
|
+
status === "running" ? "on" : status === "starting" ? "warn" : status === "exited" ? "off" : "err";
|
|
219
|
+
|
|
220
|
+
const toggleLog = (key) => setOpenLogs((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
221
|
+
|
|
222
|
+
const logsOf = (entries) => {
|
|
223
|
+
const lines = [];
|
|
224
|
+
for (const entry of entries ?? []) {
|
|
225
|
+
lines.push(`[${entry.platform}] ${entry.status}${entry.step ? " — " + entry.step : ""}`);
|
|
226
|
+
if (entry.error) lines.push(` error: ${entry.error}`);
|
|
227
|
+
}
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return h("div", { className: "mc-panel" },
|
|
232
|
+
h("div", { className: "mc-header" },
|
|
233
|
+
h("h2", { className: "mc-title" }, "Devices"),
|
|
234
|
+
h("button", {
|
|
235
|
+
className: "mc-close",
|
|
236
|
+
title: "Settings — doctor, PaddleOCR, AI prompt",
|
|
237
|
+
onClick: () => setSettingsOpen(true),
|
|
238
|
+
}, "⚙"),
|
|
239
|
+
h("button", { className: "mc-close", onClick: () => controller.setOpen(false) }, "✕"),
|
|
240
|
+
),
|
|
241
|
+
h("div", { className: "mc-body" },
|
|
242
|
+
h("div", { className: "mc-row" },
|
|
243
|
+
h("input", {
|
|
244
|
+
className: "mc-input",
|
|
245
|
+
placeholder: "Project directory (leave empty for the session default)",
|
|
246
|
+
value: directory,
|
|
247
|
+
onChange: (e) => setDirectory(e.target.value),
|
|
248
|
+
onKeyDown: (e) => { if (e.key === "Enter") detect(); },
|
|
249
|
+
}),
|
|
250
|
+
h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: detect }, "Detect"),
|
|
251
|
+
),
|
|
252
|
+
error !== "" && h("div", { className: "mc-error" }, error),
|
|
253
|
+
h("div", { className: "mc-pills" },
|
|
254
|
+
platforms.map((platform) => {
|
|
255
|
+
const running = server(platform)?.status === "running";
|
|
256
|
+
return h("span", { key: platform, className: "mc-pill", "data-on": running ? "true" : undefined },
|
|
257
|
+
h("span", { className: "mc-dot " + (running ? "on" : "off") }),
|
|
258
|
+
label(platform),
|
|
259
|
+
);
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
platforms.map((platform) => {
|
|
263
|
+
const srv = server(platform);
|
|
264
|
+
const bld = build(platform);
|
|
265
|
+
const busyHere = busy !== "" && busy.startsWith(platform);
|
|
266
|
+
return h("div", { key: platform, className: "mc-card" },
|
|
267
|
+
h("div", { className: "mc-card-head" },
|
|
268
|
+
h("span", null, label(platform)),
|
|
269
|
+
h("span", { className: "sp" }),
|
|
270
|
+
srv
|
|
271
|
+
? h("button", { className: "mc-btn", disabled: busy !== "", onClick: () => act(platform + "-stop-server", "/stop", { platform }) },
|
|
272
|
+
srv.status === "running" ? "Stop server" : "Server " + srv.status)
|
|
273
|
+
: h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: () => act(platform + "-start-server", "/start", { platform }) }, "Start server"),
|
|
274
|
+
),
|
|
275
|
+
srv?.url
|
|
276
|
+
? h("iframe", {
|
|
277
|
+
key: frameKey,
|
|
278
|
+
className: "mc-frame",
|
|
279
|
+
src: srv.url,
|
|
280
|
+
sandbox: "allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-downloads",
|
|
281
|
+
title: label(platform) + " preview",
|
|
282
|
+
})
|
|
283
|
+
: srv?.status === "starting"
|
|
284
|
+
? h("div", { className: "mc-status" }, h("span", null, h("span", { className: "mc-dot warn" }), " Starting preview server…"))
|
|
285
|
+
: null,
|
|
286
|
+
h("div", { className: "mc-row" },
|
|
287
|
+
h("button", {
|
|
288
|
+
className: "mc-btn primary",
|
|
289
|
+
disabled: busy !== "",
|
|
290
|
+
onClick: () => { act(platform + "-run", "/run", { platform }); setFrameKey((k) => k + 1); },
|
|
291
|
+
}, "Run app"),
|
|
292
|
+
h("button", { className: "mc-btn danger", disabled: busy !== "", onClick: () => act(platform + "-stop", "/run/stop", { platform }) }, "Stop app"),
|
|
293
|
+
),
|
|
294
|
+
bld
|
|
295
|
+
? h("div", { className: "mc-status" },
|
|
296
|
+
h("span", null,
|
|
297
|
+
h("span", { className: "mc-dot " + statusClass(bld.status) }),
|
|
298
|
+
" Build: ", h("b", null, bld.status),
|
|
299
|
+
bld.step ? " — " + bld.step : "",
|
|
300
|
+
bld.target ? " · target " + bld.target : "",
|
|
301
|
+
bld.appID ? " · " + bld.appID : "",
|
|
302
|
+
),
|
|
303
|
+
bld.error !== undefined && bld.error !== "" && h("div", { className: "mc-error" }, bld.error),
|
|
304
|
+
bld.log.length > 0 &&
|
|
305
|
+
h("button", { className: "mc-log-toggle", onClick: () => toggleLog(platform + "-build") },
|
|
306
|
+
openLogs[platform + "-build"] ? "Hide log" : "Show log (" + bld.log.length + ")"),
|
|
307
|
+
openLogs[platform + "-build"] && h("pre", { className: "mc-log" }, bld.log.join("\n")),
|
|
308
|
+
)
|
|
309
|
+
: h("div", { className: "mc-empty" }, "Nothing built yet."),
|
|
310
|
+
);
|
|
311
|
+
}),
|
|
312
|
+
info?.bundler &&
|
|
313
|
+
h("div", { className: "mc-card" },
|
|
314
|
+
h("div", { className: "mc-card-head" },
|
|
315
|
+
h("span", null, "Metro"),
|
|
316
|
+
h("span", { className: "mc-dot " + statusClass(info.bundler.status) }),
|
|
317
|
+
h("span", { className: "sp" }),
|
|
318
|
+
h("span", null, info.bundler.status + (info.bundler.url ? " · " + info.bundler.url : "")),
|
|
319
|
+
),
|
|
320
|
+
info.bundler.log.length > 0 &&
|
|
321
|
+
h("button", { className: "mc-log-toggle", onClick: () => toggleLog("bundler") },
|
|
322
|
+
openLogs["bundler"] ? "Hide log" : "Show log (" + info.bundler.log.length + ")"),
|
|
323
|
+
openLogs["bundler"] && h("pre", { className: "mc-log" }, info.bundler.log.join("\n")),
|
|
324
|
+
),
|
|
325
|
+
info?.servers?.length === 0 && info?.builds?.length === 0 &&
|
|
326
|
+
h("div", { className: "mc-empty" }, "No preview server running. Pick a project directory and press Run app."),
|
|
327
|
+
),
|
|
328
|
+
h("div", { className: "mc-footer" },
|
|
329
|
+
h("span", null, "dsh-mobilecode · polls every 2s"),
|
|
330
|
+
info && info.platforms.length > 0 && h("span", null, "detected: " + info.platforms.map(label).join(", ")),
|
|
331
|
+
),
|
|
332
|
+
settingsOpen && h(SettingsDialog, { onClose: () => setSettingsOpen(false) }),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
//#region setup UI (welcome + settings)
|
|
337
|
+
|
|
338
|
+
/** PaddleOCR status card: fetch /ocr, Install button, log tail. */
|
|
339
|
+
function OcrCard({ compact }) {
|
|
340
|
+
const [status, setStatus] = useState(null);
|
|
341
|
+
const [error, setError] = useState("");
|
|
342
|
+
const [busy, setBusy] = useState(false);
|
|
343
|
+
|
|
344
|
+
const refresh = async () => {
|
|
345
|
+
try { setStatus(await apiGet("/ocr")); } catch (err) { setError(err.message); }
|
|
346
|
+
};
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
refresh();
|
|
349
|
+
const timer = setInterval(refresh, 3000); // follow a running install
|
|
350
|
+
return () => clearInterval(timer);
|
|
351
|
+
}, []);
|
|
352
|
+
|
|
353
|
+
const install = async () => {
|
|
354
|
+
setBusy(true);
|
|
355
|
+
setError("");
|
|
356
|
+
try { await post("/ocr/install"); } catch (err) { setError(err.message); }
|
|
357
|
+
setBusy(false);
|
|
358
|
+
refresh();
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const state = status?.state ?? "idle";
|
|
362
|
+
const ok = status?.working === true;
|
|
363
|
+
const installing = state === "installing";
|
|
364
|
+
const failed = state === "failed";
|
|
365
|
+
const badge = ok ? "Ready" : failed ? "Failed" : installing ? "Installing…" : "Not installed";
|
|
366
|
+
|
|
367
|
+
return h("div", { className: "mc-card" },
|
|
368
|
+
h("div", { className: "mc-card-head" },
|
|
369
|
+
h("span", null, "PaddleOCR"),
|
|
370
|
+
h("span", { className: "mc-dot " + (ok ? "on" : failed ? "err" : installing ? "warn" : "off") }),
|
|
371
|
+
h("b", null, badge),
|
|
372
|
+
h("span", { className: "sp" }),
|
|
373
|
+
!ok && !installing && h("button", { className: "mc-btn primary", disabled: busy, onClick: install }, "Install"),
|
|
374
|
+
),
|
|
375
|
+
h("div", { className: "mc-status" },
|
|
376
|
+
ok
|
|
377
|
+
? h("span", null, "device_screen reads text from screenshots with local OCR.")
|
|
378
|
+
: h("span", null, "Without it, device_screen returns the screenshot and UI tree but no OCR text — install to make the plugin work perfectly."),
|
|
379
|
+
h("span", { className: "mc-hint" }, "PaddleOCR 2.x · fully local · one-time install (~2 min, needs network once)"),
|
|
380
|
+
),
|
|
381
|
+
error !== "" && h("div", { className: "mc-error" }, error),
|
|
382
|
+
(installing || failed) && (status?.log?.length > 0) && h("pre", { className: "mc-logline" }, status.log.join("\n")),
|
|
383
|
+
!compact && h("div", { className: "mc-status" },
|
|
384
|
+
h("span", null, ok ? "OCR is available to every AI via device_screen." : "Run the doctor after installing to confirm everything works."),
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Doctor view: run /doctor checks, show ✓/✗ with detail, Fix buttons. */
|
|
390
|
+
function DoctorView({ onFixed }) {
|
|
391
|
+
const [checks, setChecks] = useState(null);
|
|
392
|
+
const [error, setError] = useState("");
|
|
393
|
+
const [busy, setBusy] = useState("");
|
|
394
|
+
const [fixResult, setFixResult] = useState("");
|
|
395
|
+
|
|
396
|
+
const run = async () => {
|
|
397
|
+
setChecks(null);
|
|
398
|
+
setError("");
|
|
399
|
+
setFixResult("");
|
|
400
|
+
try { setChecks((await apiGet("/doctor")).checks); } catch (err) { setError(err.message); }
|
|
401
|
+
};
|
|
402
|
+
useEffect(() => { run(); }, []);
|
|
403
|
+
|
|
404
|
+
const fix = async (id) => {
|
|
405
|
+
setBusy(id);
|
|
406
|
+
setFixResult("");
|
|
407
|
+
try {
|
|
408
|
+
const result = await post("/doctor/fix", { id });
|
|
409
|
+
setFixResult(result.message ?? "Fix requested.");
|
|
410
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
411
|
+
await run();
|
|
412
|
+
onFixed?.();
|
|
413
|
+
} catch (err) {
|
|
414
|
+
setError(err.message);
|
|
415
|
+
} finally {
|
|
416
|
+
setBusy("");
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return h("div", { className: "mc-body", style: { padding: "12px 0 0" } },
|
|
421
|
+
h("div", { className: "mc-row" },
|
|
422
|
+
h("button", { className: "mc-btn primary", disabled: checks === null, onClick: run }, "Re-run checks"),
|
|
423
|
+
checks !== null && h("span", { className: "mc-hint" },
|
|
424
|
+
checks.filter((c) => c.ok).length + "/" + checks.length + " checks passing"),
|
|
425
|
+
),
|
|
426
|
+
error !== "" && h("div", { className: "mc-error" }, error),
|
|
427
|
+
fixResult !== "" && h("div", { className: "mc-status" }, fixResult),
|
|
428
|
+
checks === null && !error && h("div", { className: "mc-row" }, h("span", { className: "mc-spin" }), " Running checks…"),
|
|
429
|
+
checks?.map((check) =>
|
|
430
|
+
h("div", { key: check.name, className: "mc-check" },
|
|
431
|
+
h("span", { className: "mc-check-ico", style: { color: check.ok ? "#4caf50" : "#ef5350" } }, check.ok ? "✓" : "✗"),
|
|
432
|
+
h("span", { className: "mc-check-detail" },
|
|
433
|
+
h("span", { className: "mc-check-name" }, check.name),
|
|
434
|
+
" — ", check.detail,
|
|
435
|
+
check.fix && !check.ok && h("span", null,
|
|
436
|
+
" ", h("button", { className: "mc-btn", disabled: busy !== "", onClick: () => fix(check.fix) },
|
|
437
|
+
busy === check.fix ? "Fixing…" : "Fix")),
|
|
438
|
+
),
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Settings dialog: tabs for Doctor / PaddleOCR / AI prompt. */
|
|
445
|
+
/** Shared settings tabs (Doctor / PaddleOCR / AI Prompt) — used by both the ⚙ dialog and the DSH Settings page. */
|
|
446
|
+
function SettingsTabs() {
|
|
447
|
+
const [tab, setTab] = useState("doctor");
|
|
448
|
+
const [prompt, setPrompt] = useState("");
|
|
449
|
+
const [copied, setCopied] = useState(false);
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
apiGet("/welcome").then((w) => setPrompt(w.prompt ?? "")).catch(() => {});
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
const copy = async () => {
|
|
455
|
+
const done = await copyText(prompt);
|
|
456
|
+
setCopied(done);
|
|
457
|
+
setTimeout(() => setCopied(false), 2000);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const tabButton = (id, label) =>
|
|
461
|
+
h("button", { className: "mc-tab", "data-on": tab === id ? "true" : undefined, onClick: () => setTab(id) }, label);
|
|
462
|
+
|
|
463
|
+
return [
|
|
464
|
+
h("div", { className: "mc-tabs" },
|
|
465
|
+
tabButton("doctor", "Doctor"),
|
|
466
|
+
tabButton("ocr", "PaddleOCR"),
|
|
467
|
+
tabButton("prompt", "AI Prompt"),
|
|
468
|
+
),
|
|
469
|
+
tab === "doctor" && h("div", { className: "mc-modal-body" }, h(DoctorView, { onFixed: () => {} })),
|
|
470
|
+
tab === "ocr" && h("div", { className: "mc-modal-body" }, h(OcrCard, {})),
|
|
471
|
+
tab === "prompt" && h("div", { className: "mc-modal-body" },
|
|
472
|
+
h("div", null,
|
|
473
|
+
h("h3", null, "Prompt for any AI"),
|
|
474
|
+
h("div", { className: "mc-hint" }, "Copy this into any AI model to tell it how to fully control this plugin."),
|
|
475
|
+
),
|
|
476
|
+
h("div", { className: "mc-prompt" },
|
|
477
|
+
h("pre", null, prompt || "Loading…"),
|
|
478
|
+
h("button", { className: "mc-btn mc-copy", onClick: copy }, copied ? "Copied ✓" : "Copy"),
|
|
479
|
+
),
|
|
480
|
+
),
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Settings dialog (⚙ in the Devices pane header). */
|
|
485
|
+
function SettingsDialog({ onClose }) {
|
|
486
|
+
return h("div", { className: "mc-overlay", onClick: (e) => { if (e.target === e.currentTarget) onClose(); } },
|
|
487
|
+
h("div", { className: "mc-modal" },
|
|
488
|
+
h("div", { className: "mc-modal-head" },
|
|
489
|
+
h("h2", { className: "mc-modal-title" }, "dsh-mobilecode · Settings"),
|
|
490
|
+
h("button", { className: "mc-close", onClick: onClose }, "✕"),
|
|
491
|
+
),
|
|
492
|
+
h(SettingsTabs, {}),
|
|
493
|
+
h("div", { className: "mc-modal-foot" },
|
|
494
|
+
h("span", { className: "mc-hint" }, "Settings live in ~/.dsh/mobilecode/"),
|
|
495
|
+
h("span", { className: "sp" }),
|
|
496
|
+
h("button", { className: "mc-btn primary", onClick: onClose }, "Done"),
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The DSH Settings page contribution (registered into settings.section). */
|
|
503
|
+
function SettingsSection({ close }) {
|
|
504
|
+
return h("div", { className: "mc-section" },
|
|
505
|
+
h("div", { className: "mc-section-head" },
|
|
506
|
+
h("h2", { className: "mc-modal-title" }, "dsh-mobilecode"),
|
|
507
|
+
h("span", { className: "mc-hint" }, "Detect iOS/Android projects, run them on the Simulator/Emulator, and let AI agents control the device."),
|
|
508
|
+
close !== undefined && h("button", { className: "mc-btn", onClick: close }, "Close"),
|
|
509
|
+
),
|
|
510
|
+
h(SettingsTabs, {}),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** First-run welcome modal: how-to + copyable prompt + PaddleOCR install. */
|
|
515
|
+
function WelcomeModal({ onClose }) {
|
|
516
|
+
const [prompt, setPrompt] = useState("");
|
|
517
|
+
const [copied, setCopied] = useState(false);
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
apiGet("/welcome").then((w) => setPrompt(w.prompt ?? "")).catch(() => {});
|
|
520
|
+
}, []);
|
|
521
|
+
|
|
522
|
+
const copy = async () => {
|
|
523
|
+
const done = await copyText(prompt);
|
|
524
|
+
setCopied(done);
|
|
525
|
+
setTimeout(() => setCopied(false), 2000);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const dismiss = async () => {
|
|
529
|
+
try { await post("/welcome/dismiss"); } catch { /* keep modal in-memory */ }
|
|
530
|
+
onClose();
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
return h("div", { className: "mc-overlay" },
|
|
534
|
+
h("div", { className: "mc-modal" },
|
|
535
|
+
h("div", { className: "mc-modal-head" },
|
|
536
|
+
h("h2", { className: "mc-modal-title" }, "Welcome to dsh-mobilecode 🚀"),
|
|
537
|
+
h("button", { className: "mc-close", onClick: dismiss }, "✕"),
|
|
538
|
+
),
|
|
539
|
+
h("div", { className: "mc-modal-body" },
|
|
540
|
+
h("div", null,
|
|
541
|
+
h("h3", null, "How to use it"),
|
|
542
|
+
h("div", { className: "mc-status" },
|
|
543
|
+
h("span", null, "1. Open the Devices pane (sidebar) and pick a project directory, or let an AI do it for you."),
|
|
544
|
+
h("span", null, "2. Press Run app (or ask the AI) — it builds, installs and launches on the Simulator/Emulator."),
|
|
545
|
+
h("span", null, "3. AI agents get device_detect / device_run / device_screen / device_input / device_log / device_status — full build, see, tap, log control."),
|
|
546
|
+
h("span", null, "4. The ⚙ Settings button (Devices pane) has the doctor, PaddleOCR and this prompt."),
|
|
547
|
+
),
|
|
548
|
+
),
|
|
549
|
+
h("div", { className: "mc-prompt" },
|
|
550
|
+
h("pre", null, prompt || "Loading…"),
|
|
551
|
+
h("button", { className: "mc-btn mc-copy", onClick: copy }, copied ? "Copied ✓" : "Copy prompt"),
|
|
552
|
+
),
|
|
553
|
+
h(OcrCard, { compact: true }),
|
|
554
|
+
h("div", { className: "mc-hint" },
|
|
555
|
+
"Tip: install PaddleOCR above so device_screen can read text from the screen — the plugin still works without it, just without OCR."),
|
|
556
|
+
),
|
|
557
|
+
h("div", { className: "mc-modal-foot" },
|
|
558
|
+
h("span", { className: "mc-hint" }, "The ⚙ in the Devices pane opens Settings (Doctor / PaddleOCR / AI Prompt) anytime."),
|
|
559
|
+
h("span", { className: "sp" }),
|
|
560
|
+
h("button", { className: "mc-btn primary", onClick: dismiss }, "Got it — let's go"),
|
|
561
|
+
),
|
|
562
|
+
),
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
//#endregion
|
|
566
|
+
|
|
567
|
+
//#region DOM mounts
|
|
568
|
+
const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><rect x="2" y="1.5" width="12" height="13" rx="2"/><path d="M4.5 4h7M4.5 6.5h7M4.5 9h4" stroke-linecap="round"/><circle cx="8" cy="11.8" r="0.8" fill="currentColor" stroke="none"/></svg>';
|
|
569
|
+
|
|
570
|
+
function sidebarRoot() {
|
|
571
|
+
const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]');
|
|
572
|
+
if (column === null) return undefined;
|
|
573
|
+
const logoOwner = column.querySelector('[class*="logoRow"]')?.parentElement;
|
|
574
|
+
return logoOwner ?? (column.firstElementChild ?? undefined);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function newSessionButton(root) {
|
|
578
|
+
const nested = root.querySelector('button[class*="newSession"]');
|
|
579
|
+
if (nested !== null) return nested;
|
|
580
|
+
for (const child of root.children) {
|
|
581
|
+
if (child.tagName === "BUTTON") return child;
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function createEntry(controller) {
|
|
587
|
+
const entry = document.createElement("button");
|
|
588
|
+
entry.type = "button";
|
|
589
|
+
entry.dataset.dshMobilecodeEntry = "";
|
|
590
|
+
entry.setAttribute("aria-label", "Devices (mobilecode)");
|
|
591
|
+
entry.setAttribute("title", "Devices — run the mobile app in the Simulator/Emulator");
|
|
592
|
+
entry.innerHTML = '<span class="mc-entry-icon">' + ICON + '</span><span class="mc-entry-label">Devices</span>';
|
|
593
|
+
entry.addEventListener("click", () => { controller.toggle(); });
|
|
594
|
+
return entry;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function placeEntry(root, entry) {
|
|
598
|
+
const button = newSessionButton(root);
|
|
599
|
+
if (button === undefined) return false;
|
|
600
|
+
if (entry.parentElement !== root) {
|
|
601
|
+
const row = button.closest('[class*="logoRow"]');
|
|
602
|
+
const base = (row !== null && row.parentElement === root) ? row : button;
|
|
603
|
+
const family = Array.from(root.children).filter(
|
|
604
|
+
(el) => el instanceof HTMLElement && el.matches("[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-logcat-entry], [data-dsh-mobilecode-entry]"),
|
|
605
|
+
);
|
|
606
|
+
const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
|
|
607
|
+
root.insertBefore(entry, anchor);
|
|
608
|
+
}
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function mountSidebarEntry(controller) {
|
|
613
|
+
const entry = createEntry(controller);
|
|
614
|
+
let root;
|
|
615
|
+
let placed = false;
|
|
616
|
+
let rootObserver;
|
|
617
|
+
|
|
618
|
+
const tryPlace = () => {
|
|
619
|
+
if (root !== undefined && !root.isConnected) {
|
|
620
|
+
rootObserver?.disconnect();
|
|
621
|
+
root = undefined;
|
|
622
|
+
placed = false;
|
|
623
|
+
}
|
|
624
|
+
if (placed) {
|
|
625
|
+
if (document.body.contains(entry)) return;
|
|
626
|
+
rootObserver?.disconnect();
|
|
627
|
+
root = undefined;
|
|
628
|
+
placed = false;
|
|
629
|
+
}
|
|
630
|
+
root ??= sidebarRoot();
|
|
631
|
+
if (root === undefined) return;
|
|
632
|
+
placed = placeEntry(root, entry);
|
|
633
|
+
if (placed) {
|
|
634
|
+
rootObserver = new MutationObserver(() => {
|
|
635
|
+
if (root === undefined || !root.isConnected) { placed = false; tryPlace(); return; }
|
|
636
|
+
if (!root.contains(entry)) placed = placeEntry(root, entry);
|
|
637
|
+
});
|
|
638
|
+
rootObserver.observe(root, { childList: true, subtree: true });
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const waitObserver = new MutationObserver(() => { tryPlace(); });
|
|
643
|
+
waitObserver.observe(document.body, { childList: true, subtree: true });
|
|
644
|
+
|
|
645
|
+
const syncActive = () => {
|
|
646
|
+
if (controller.getSnapshot().panelOpen) entry.dataset.active = "true";
|
|
647
|
+
else delete entry.dataset.active;
|
|
648
|
+
};
|
|
649
|
+
const unsubscribe = controller.subscribe(syncActive);
|
|
650
|
+
syncActive();
|
|
651
|
+
tryPlace();
|
|
652
|
+
|
|
653
|
+
return () => {
|
|
654
|
+
waitObserver.disconnect();
|
|
655
|
+
rootObserver?.disconnect();
|
|
656
|
+
unsubscribe();
|
|
657
|
+
entry.remove();
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function mountPanel(controller) {
|
|
662
|
+
let root;
|
|
663
|
+
let container;
|
|
664
|
+
let handle;
|
|
665
|
+
let drag = null;
|
|
666
|
+
|
|
667
|
+
const MIN_WIDTH = 320;
|
|
668
|
+
const DEFAULT_WIDTH = 560;
|
|
669
|
+
const readWidth = () => {
|
|
670
|
+
try {
|
|
671
|
+
const w = Number(localStorage.getItem("dsh-mobilecode-width"));
|
|
672
|
+
if (Number.isFinite(w) && w >= MIN_WIDTH && w <= window.innerWidth - 40) return w;
|
|
673
|
+
} catch { /* private mode */ }
|
|
674
|
+
return Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80));
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const onMove = (e) => {
|
|
678
|
+
if (drag === null || container === undefined) return;
|
|
679
|
+
const width = Math.max(MIN_WIDTH, Math.min(window.innerWidth - 40, drag.startWidth + (drag.startX - e.clientX)));
|
|
680
|
+
container.style.width = width + "px";
|
|
681
|
+
};
|
|
682
|
+
const onUp = () => {
|
|
683
|
+
if (drag === null) return;
|
|
684
|
+
handle?.removeAttribute("data-drag");
|
|
685
|
+
try {
|
|
686
|
+
const w = container.style.width.replace("px", "");
|
|
687
|
+
localStorage.setItem("dsh-mobilecode-width", w);
|
|
688
|
+
} catch { /* private mode */ }
|
|
689
|
+
window.removeEventListener("mousemove", onMove);
|
|
690
|
+
window.removeEventListener("mouseup", onUp);
|
|
691
|
+
drag = null;
|
|
692
|
+
};
|
|
693
|
+
const onDown = (e) => {
|
|
694
|
+
e.preventDefault();
|
|
695
|
+
if (drag !== null) return;
|
|
696
|
+
const now = Date.now();
|
|
697
|
+
if (now - (handle?._lastClick ?? 0) < 350) {
|
|
698
|
+
handle._lastClick = 0;
|
|
699
|
+
container.style.width = Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80)) + "px";
|
|
700
|
+
try { localStorage.setItem("dsh-mobilecode-width", String(Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80)))); } catch { /* private */ }
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
handle._lastClick = now;
|
|
704
|
+
drag = { startX: e.clientX, startWidth: container.getBoundingClientRect().width };
|
|
705
|
+
handle?.setAttribute("data-drag", "");
|
|
706
|
+
window.addEventListener("mousemove", onMove);
|
|
707
|
+
window.addEventListener("mouseup", onUp);
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const ensure = () => {
|
|
711
|
+
if (container !== undefined && container.isConnected) return;
|
|
712
|
+
root?.unmount();
|
|
713
|
+
root = undefined;
|
|
714
|
+
container?.remove();
|
|
715
|
+
handle = undefined;
|
|
716
|
+
container = document.createElement("div");
|
|
717
|
+
container.dataset.dshMobilecodeView = "";
|
|
718
|
+
container.className = "dsh-mobilecode-view";
|
|
719
|
+
container.style.cssText += "position:fixed;top:0;right:0;bottom:0;left:auto;z-index:999999;";
|
|
720
|
+
container.style.width = readWidth() + "px";
|
|
721
|
+
container.hidden = true;
|
|
722
|
+
handle = document.createElement("div");
|
|
723
|
+
handle.className = "dsh-mobilecode-resize";
|
|
724
|
+
handle.title = "Drag to resize (double-click to reset)";
|
|
725
|
+
handle.addEventListener("mousedown", onDown);
|
|
726
|
+
container.appendChild(handle);
|
|
727
|
+
const panel = document.createElement("div");
|
|
728
|
+
panel.className = "dsh-mobilecode-panel";
|
|
729
|
+
panel.style.cssText += "position:absolute;inset:0;display:flex;flex-direction:column;";
|
|
730
|
+
container.appendChild(panel);
|
|
731
|
+
document.body.appendChild(container);
|
|
732
|
+
root = createRoot(panel);
|
|
733
|
+
root.render(h(MobileCodePanel, { controller }));
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
ensure();
|
|
737
|
+
|
|
738
|
+
const applyOpen = () => {
|
|
739
|
+
if (container !== undefined) container.hidden = !controller.getSnapshot().panelOpen;
|
|
740
|
+
};
|
|
741
|
+
const unsubscribe = controller.subscribe(applyOpen);
|
|
742
|
+
applyOpen();
|
|
743
|
+
|
|
744
|
+
return () => {
|
|
745
|
+
window.removeEventListener("mousemove", onMove);
|
|
746
|
+
window.removeEventListener("mouseup", onUp);
|
|
747
|
+
unsubscribe();
|
|
748
|
+
root?.unmount();
|
|
749
|
+
root = undefined;
|
|
750
|
+
container?.remove();
|
|
751
|
+
container = undefined;
|
|
752
|
+
handle = undefined;
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
//#endregion
|
|
756
|
+
|
|
757
|
+
//#region entry
|
|
758
|
+
/** Required services (fiber inject waiting — the runtime must be up first). */
|
|
759
|
+
const inject = ["slots"];
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* First-run welcome: ask the host whether to show it, and if so mount the
|
|
763
|
+
* modal over everything. The host remembers dismissal in settings.json.
|
|
764
|
+
*/
|
|
765
|
+
function mountWelcome() {
|
|
766
|
+
let root;
|
|
767
|
+
let container;
|
|
768
|
+
const render = (show) => {
|
|
769
|
+
if (!show) return;
|
|
770
|
+
container = document.createElement("div");
|
|
771
|
+
container.dataset.dshMobilecodeWelcome = "";
|
|
772
|
+
document.body.appendChild(container);
|
|
773
|
+
root = createRoot(container);
|
|
774
|
+
root.render(h(WelcomeModal, { onClose: () => { root?.unmount(); container?.remove(); root = undefined; container = undefined; } }));
|
|
775
|
+
};
|
|
776
|
+
apiGet("/welcome").then((w) => render(w.show === true)).catch(() => { /* host older than this feature — no welcome */ });
|
|
777
|
+
return () => { root?.unmount(); container?.remove(); root = undefined; container = undefined; };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Mount the device pane.
|
|
782
|
+
* @param ctx - client root context.
|
|
783
|
+
*/
|
|
784
|
+
function apply(ctx) {
|
|
785
|
+
const style = document.createElement("style");
|
|
786
|
+
style.textContent = STYLE;
|
|
787
|
+
style.dataset.dshMobilecodeStyle = "";
|
|
788
|
+
document.head.appendChild(style);
|
|
789
|
+
const styleGuard = new MutationObserver(() => {
|
|
790
|
+
if (!document.head.contains(style)) document.head.appendChild(style);
|
|
791
|
+
});
|
|
792
|
+
styleGuard.observe(document.head, { childList: true });
|
|
793
|
+
|
|
794
|
+
const controller = new PanelController();
|
|
795
|
+
const disposers = [];
|
|
796
|
+
try {
|
|
797
|
+
disposers.push(mountSidebarEntry(controller));
|
|
798
|
+
disposers.push(mountPanel(controller));
|
|
799
|
+
disposers.push(mountWelcome());
|
|
800
|
+
// DSH Settings page: register our own section (doctor / PaddleOCR / AI prompt),
|
|
801
|
+
// like the other installed plugins' settings pages.
|
|
802
|
+
if (typeof ctx.slots?.inject === "function") {
|
|
803
|
+
const disposeSlot = ctx.slots.inject("settings.section", () =>
|
|
804
|
+
ctx.slots.register({
|
|
805
|
+
name: "settings.section",
|
|
806
|
+
id: "mobilecode",
|
|
807
|
+
order: 200,
|
|
808
|
+
label: "MobileCode",
|
|
809
|
+
}, SettingsSection),
|
|
810
|
+
);
|
|
811
|
+
disposers.push(disposeSlot);
|
|
812
|
+
}
|
|
813
|
+
} catch (error) {
|
|
814
|
+
console.warn("[dsh-mobilecode] mount failed:", error);
|
|
815
|
+
}
|
|
816
|
+
ctx.effect(() => () => {
|
|
817
|
+
styleGuard.disconnect();
|
|
818
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
819
|
+
style.remove();
|
|
820
|
+
}, "dsh-mobilecode: ui mounts");
|
|
821
|
+
}
|
|
822
|
+
//#endregion
|
|
823
|
+
|
|
824
|
+
exports.apply = apply;
|
|
825
|
+
exports.inject = inject;
|
|
826
|
+
return module.exports;
|
|
827
|
+
}
|
|
828
|
+
});
|