dsh-mobile 0.1.0-alpha.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/LICENSE +201 -0
- package/README.md +207 -0
- package/README.zh.md +213 -0
- package/SECURITY.md +29 -0
- package/assets/brand/app-icon-master.png +0 -0
- package/assets/brand/repository-hero.png +0 -0
- package/cordis.patch.yml +18 -0
- package/lib/cli.js +202 -0
- package/lib/client.js +212 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +409 -0
- package/lib/index.mjs +2118 -0
- package/package.json +117 -0
package/lib/cli.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { generate } from "selfsigned";
|
|
7
|
+
//#region src/cli.ts
|
|
8
|
+
function privateIpv4(value) {
|
|
9
|
+
const parts = value.split(".").map(Number);
|
|
10
|
+
return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
|
|
11
|
+
}
|
|
12
|
+
function networkCidr(address, cidr) {
|
|
13
|
+
const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
|
|
14
|
+
const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
|
|
15
|
+
return `${[
|
|
16
|
+
24,
|
|
17
|
+
16,
|
|
18
|
+
8,
|
|
19
|
+
0
|
|
20
|
+
].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
|
|
21
|
+
}
|
|
22
|
+
function availableAddresses() {
|
|
23
|
+
const candidates = Object.values(networkInterfaces()).flatMap((entries) => entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
|
|
24
|
+
address: entry.address,
|
|
25
|
+
cidr: networkCidr(entry.address, entry.cidr)
|
|
26
|
+
}));
|
|
27
|
+
return [...new Map(candidates.map((entry) => [entry.address, entry])).values()];
|
|
28
|
+
}
|
|
29
|
+
function parseOptions(args) {
|
|
30
|
+
let address;
|
|
31
|
+
let port = 3443;
|
|
32
|
+
let dshPort = 3080;
|
|
33
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
34
|
+
const name = args[index];
|
|
35
|
+
const value = args[index + 1];
|
|
36
|
+
if (name === "--address" && value !== void 0) {
|
|
37
|
+
address = value;
|
|
38
|
+
index += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (name === "--port" && value !== void 0) {
|
|
42
|
+
port = Number(value);
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (name === "--dsh-port" && value !== void 0) {
|
|
47
|
+
dshPort = Number(value);
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`unknown setup option: ${name ?? ""}`);
|
|
52
|
+
}
|
|
53
|
+
if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be from 1024 through 65535");
|
|
54
|
+
if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error("--dsh-port must be from 1024 through 65535");
|
|
55
|
+
if (address !== void 0 && !privateIpv4(address)) throw new Error("--address must be a private IPv4 address");
|
|
56
|
+
return {
|
|
57
|
+
...address === void 0 ? {} : { address },
|
|
58
|
+
port,
|
|
59
|
+
dshPort
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function selectNetwork(requested) {
|
|
63
|
+
const candidates = availableAddresses();
|
|
64
|
+
if (requested !== void 0) {
|
|
65
|
+
const match = candidates.find((candidate) => candidate.address === requested);
|
|
66
|
+
if (match === void 0) throw new Error(`--address ${requested} is not an active private LAN address`);
|
|
67
|
+
return match;
|
|
68
|
+
}
|
|
69
|
+
if (candidates.length === 1) return candidates[0];
|
|
70
|
+
if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
|
|
71
|
+
throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => entry.address).join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
function dshHome() {
|
|
74
|
+
return resolve(process.env.DSH_HOME ?? join(homedir(), ".dsh"));
|
|
75
|
+
}
|
|
76
|
+
async function setup(args) {
|
|
77
|
+
const options = parseOptions(args);
|
|
78
|
+
const network = selectNetwork(options.address);
|
|
79
|
+
const home = dshHome();
|
|
80
|
+
const directory = join(home, "mobile-access");
|
|
81
|
+
const tls = join(directory, "tls");
|
|
82
|
+
await mkdir(tls, {
|
|
83
|
+
recursive: true,
|
|
84
|
+
mode: 448
|
|
85
|
+
});
|
|
86
|
+
const now = /* @__PURE__ */ new Date();
|
|
87
|
+
const notAfter = new Date(now);
|
|
88
|
+
notAfter.setDate(notAfter.getDate() + 397);
|
|
89
|
+
const certificate = await generate([{
|
|
90
|
+
name: "commonName",
|
|
91
|
+
value: network.address
|
|
92
|
+
}], {
|
|
93
|
+
keyType: "ec",
|
|
94
|
+
curve: "P-256",
|
|
95
|
+
algorithm: "sha256",
|
|
96
|
+
notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
|
|
97
|
+
notAfterDate: notAfter,
|
|
98
|
+
extensions: [
|
|
99
|
+
{
|
|
100
|
+
name: "basicConstraints",
|
|
101
|
+
cA: true,
|
|
102
|
+
critical: true
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "keyUsage",
|
|
106
|
+
digitalSignature: true,
|
|
107
|
+
keyCertSign: true,
|
|
108
|
+
cRLSign: true,
|
|
109
|
+
critical: true
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "extKeyUsage",
|
|
113
|
+
serverAuth: true
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "subjectAltName",
|
|
117
|
+
altNames: [{
|
|
118
|
+
type: 7,
|
|
119
|
+
ip: network.address
|
|
120
|
+
}]
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
});
|
|
124
|
+
const certFile = join(tls, "cert.pem");
|
|
125
|
+
const keyFile = join(tls, "key.pem");
|
|
126
|
+
const androidCertificate = join(tls, "dsh-mobile-ca.cer");
|
|
127
|
+
await Promise.all([
|
|
128
|
+
writeFile(certFile, certificate.cert, { mode: 384 }),
|
|
129
|
+
writeFile(keyFile, certificate.private, { mode: 384 }),
|
|
130
|
+
writeFile(androidCertificate, new X509Certificate(certificate.cert).raw, { mode: 384 })
|
|
131
|
+
]);
|
|
132
|
+
await Promise.all([
|
|
133
|
+
chmod(certFile, 384),
|
|
134
|
+
chmod(keyFile, 384),
|
|
135
|
+
chmod(androidCertificate, 384)
|
|
136
|
+
]);
|
|
137
|
+
const customCss = join(directory, "mobile.css");
|
|
138
|
+
try {
|
|
139
|
+
await readFile(customCss);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.code !== "ENOENT") throw error;
|
|
142
|
+
await writeFile(customCss, [
|
|
143
|
+
"/* Safe mobile overrides. Edit these values, then refresh the phone. */",
|
|
144
|
+
":root {",
|
|
145
|
+
" --dsh-mobile-accent: #2563eb;",
|
|
146
|
+
" --dsh-mobile-font-scale: 1;",
|
|
147
|
+
" --dsh-mobile-radius: 14px;",
|
|
148
|
+
"}",
|
|
149
|
+
""
|
|
150
|
+
].join("\n"), { mode: 384 });
|
|
151
|
+
}
|
|
152
|
+
const origin = `https://${network.address}:${String(options.port)}`;
|
|
153
|
+
await Promise.all([writeFile(join(directory, "setup.json"), `${JSON.stringify({
|
|
154
|
+
version: 1,
|
|
155
|
+
publicOrigin: origin,
|
|
156
|
+
listenHost: network.address,
|
|
157
|
+
upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,
|
|
158
|
+
allowedCidrs: [network.cidr],
|
|
159
|
+
tls: {
|
|
160
|
+
mode: "provided",
|
|
161
|
+
certFile: certFile.replaceAll("\\", "/"),
|
|
162
|
+
keyFile: keyFile.replaceAll("\\", "/")
|
|
163
|
+
}
|
|
164
|
+
}, null, 2)}\n`, { mode: 384 }), writeFile(join(directory, "control.json"), "{\"version\":1,\"enabled\":true}\n", { mode: 384 })]);
|
|
165
|
+
console.log(`DSH Mobile is configured for ${origin}`);
|
|
166
|
+
console.log(`Install this certificate on Android before connecting: ${androidCertificate}`);
|
|
167
|
+
console.log("Start DSH with: dsh --profile web");
|
|
168
|
+
console.log("Then open the Mobile card in the lower-left corner and create a pairing link.");
|
|
169
|
+
}
|
|
170
|
+
async function purge(args) {
|
|
171
|
+
if (args.length !== 1 || args[0] !== "--yes") throw new Error("purge requires --yes");
|
|
172
|
+
const home = dshHome();
|
|
173
|
+
await rm(join(home, "mobile-access"), {
|
|
174
|
+
recursive: true,
|
|
175
|
+
force: true
|
|
176
|
+
});
|
|
177
|
+
console.log("Removed DSH Mobile certificates, devices, preferences, and custom CSS.");
|
|
178
|
+
}
|
|
179
|
+
function help() {
|
|
180
|
+
console.log([
|
|
181
|
+
"dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080]",
|
|
182
|
+
"dsh-mobile purge --yes",
|
|
183
|
+
"",
|
|
184
|
+
"Run through the DSH profile:",
|
|
185
|
+
" dsh plugin --profile web exec dsh-mobile setup"
|
|
186
|
+
].join("\n"));
|
|
187
|
+
}
|
|
188
|
+
async function main() {
|
|
189
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
190
|
+
if (command === "setup") await setup(args);
|
|
191
|
+
else if (command === "purge") await purge(args);
|
|
192
|
+
else if (command === "help" || command === "--help" || command === "-h") help();
|
|
193
|
+
else throw new Error(`unknown command: ${command}`);
|
|
194
|
+
}
|
|
195
|
+
main().catch((error) => {
|
|
196
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
});
|
|
199
|
+
//#endregion
|
|
200
|
+
export {};
|
|
201
|
+
|
|
202
|
+
//# sourceMappingURL=cli.js.map
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-mobile",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
//#region src/client.ts
|
|
9
|
+
const BASE_STYLES = `
|
|
10
|
+
@media (max-width: 720px) {
|
|
11
|
+
:root {
|
|
12
|
+
--dsh-mobile-accent: #2563eb;
|
|
13
|
+
--dsh-mobile-font-scale: 1;
|
|
14
|
+
--dsh-mobile-radius: 14px;
|
|
15
|
+
}
|
|
16
|
+
html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }
|
|
17
|
+
body { overscroll-behavior: none; }
|
|
18
|
+
button, [role='button'], input, textarea, select { min-height: 44px; }
|
|
19
|
+
input, textarea, select { font-size: 16px !important; }
|
|
20
|
+
[data-sidebar-collapsed] {
|
|
21
|
+
padding-top: env(safe-area-inset-top);
|
|
22
|
+
padding-right: env(safe-area-inset-right);
|
|
23
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
24
|
+
padding-left: env(safe-area-inset-left);
|
|
25
|
+
}
|
|
26
|
+
[data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
|
|
27
|
+
* { -webkit-tap-highlight-color: transparent; }
|
|
28
|
+
}
|
|
29
|
+
@media (prefers-reduced-motion: reduce) {
|
|
30
|
+
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
|
31
|
+
}
|
|
32
|
+
.dsh-mobile-control {
|
|
33
|
+
position: fixed;
|
|
34
|
+
left: 68px;
|
|
35
|
+
bottom: 12px;
|
|
36
|
+
z-index: 90;
|
|
37
|
+
color: #172554;
|
|
38
|
+
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
39
|
+
}
|
|
40
|
+
.dsh-mobile-control button {
|
|
41
|
+
min-width: 44px;
|
|
42
|
+
min-height: 44px;
|
|
43
|
+
border: 1px solid #bfdbfe;
|
|
44
|
+
border-radius: 12px;
|
|
45
|
+
background: #fff;
|
|
46
|
+
color: #1e3a8a;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
}
|
|
49
|
+
.dsh-mobile-control__trigger {
|
|
50
|
+
width: 100%;
|
|
51
|
+
min-width: 44px;
|
|
52
|
+
min-height: 44px;
|
|
53
|
+
padding: 0 12px;
|
|
54
|
+
border: 0;
|
|
55
|
+
border-radius: 10px;
|
|
56
|
+
background: transparent;
|
|
57
|
+
color: inherit;
|
|
58
|
+
font: inherit;
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
}
|
|
62
|
+
.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }
|
|
63
|
+
.dsh-mobile-control__panel {
|
|
64
|
+
width: min(320px, calc(100vw - 24px));
|
|
65
|
+
margin-bottom: 8px;
|
|
66
|
+
padding: 16px;
|
|
67
|
+
border: 1px solid #dbeafe;
|
|
68
|
+
border-radius: 16px;
|
|
69
|
+
background: #fff;
|
|
70
|
+
box-shadow: 0 10px 30px rgb(15 23 42 / 12%);
|
|
71
|
+
}
|
|
72
|
+
.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }
|
|
73
|
+
.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }
|
|
74
|
+
.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
75
|
+
.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
76
|
+
.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }
|
|
77
|
+
`;
|
|
78
|
+
function isLoopbackHost(hostname) {
|
|
79
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
80
|
+
}
|
|
81
|
+
function element(name, className) {
|
|
82
|
+
const node = document.createElement(name);
|
|
83
|
+
if (className !== void 0) node.className = className;
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
async function requestJson(path, init) {
|
|
87
|
+
const response = await fetch(path, {
|
|
88
|
+
...init,
|
|
89
|
+
headers: {
|
|
90
|
+
"content-type": "application/json",
|
|
91
|
+
...init?.headers
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
const body = await response.json();
|
|
95
|
+
if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`);
|
|
96
|
+
return body;
|
|
97
|
+
}
|
|
98
|
+
function installControl() {
|
|
99
|
+
const zh = navigator.language.toLowerCase().startsWith("zh");
|
|
100
|
+
const root = element("div", "dsh-mobile-control");
|
|
101
|
+
const panel = element("section", "dsh-mobile-control__panel");
|
|
102
|
+
panel.hidden = true;
|
|
103
|
+
const title = element("h2", "dsh-mobile-control__title");
|
|
104
|
+
title.textContent = zh ? "移动访问" : "Mobile access";
|
|
105
|
+
const status = element("p", "dsh-mobile-control__status");
|
|
106
|
+
status.textContent = zh ? "正在读取状态…" : "Reading status…";
|
|
107
|
+
const actions = element("div", "dsh-mobile-control__actions");
|
|
108
|
+
const toggle = element("button");
|
|
109
|
+
toggle.dataset.primary = "true";
|
|
110
|
+
const pair = element("button");
|
|
111
|
+
pair.textContent = zh ? "生成配对链接" : "Create pairing link";
|
|
112
|
+
actions.append(toggle, pair);
|
|
113
|
+
panel.append(title, status, actions);
|
|
114
|
+
root.append(panel);
|
|
115
|
+
document.body.append(root);
|
|
116
|
+
let running = false;
|
|
117
|
+
const render = (data) => {
|
|
118
|
+
running = data.running === true;
|
|
119
|
+
const origin = typeof data.origin === "string" ? data.origin : void 0;
|
|
120
|
+
status.textContent = running ? zh ? `已开启:${origin ?? ""}` : `Running: ${origin ?? ""}` : zh ? "已关闭。DSH 仍只在本机可用。" : "Stopped. DSH remains local-only.";
|
|
121
|
+
toggle.textContent = running ? zh ? "关闭" : "Stop" : zh ? "开启" : "Start";
|
|
122
|
+
pair.disabled = !running;
|
|
123
|
+
};
|
|
124
|
+
const refresh = async () => {
|
|
125
|
+
try {
|
|
126
|
+
render(await requestJson("/api/mobile-access/control"));
|
|
127
|
+
} catch (error) {
|
|
128
|
+
status.textContent = error instanceof Error ? error.message : String(error);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
toggle.addEventListener("click", () => {
|
|
132
|
+
toggle.disabled = true;
|
|
133
|
+
requestJson("/api/mobile-access/control", {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: JSON.stringify({ running: !running })
|
|
136
|
+
}).then(render, (error) => {
|
|
137
|
+
status.textContent = error instanceof Error ? error.message : String(error);
|
|
138
|
+
}).finally(() => {
|
|
139
|
+
toggle.disabled = false;
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
pair.addEventListener("click", () => {
|
|
143
|
+
pair.disabled = true;
|
|
144
|
+
requestJson("/api/mobile-access/pairing/open", {
|
|
145
|
+
method: "POST",
|
|
146
|
+
body: "{}"
|
|
147
|
+
}).then(async (data) => {
|
|
148
|
+
const url = typeof data.pairUrl === "string" ? data.pairUrl : "";
|
|
149
|
+
if (url !== "" && navigator.clipboard !== void 0) await navigator.clipboard.writeText(url);
|
|
150
|
+
status.textContent = url === "" ? zh ? "无法生成链接" : "Could not create link" : zh ? `链接已复制:${url}` : `Copied: ${url}`;
|
|
151
|
+
}, (error) => {
|
|
152
|
+
status.textContent = error instanceof Error ? error.message : String(error);
|
|
153
|
+
}).finally(() => {
|
|
154
|
+
pair.disabled = !running;
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
refresh();
|
|
158
|
+
return {
|
|
159
|
+
remove: () => {
|
|
160
|
+
root.remove();
|
|
161
|
+
},
|
|
162
|
+
toggle: () => {
|
|
163
|
+
panel.hidden = !panel.hidden;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Install the responsive mobile layer and the loopback-only control card. */
|
|
168
|
+
function apply(ctx) {
|
|
169
|
+
ctx.effect(() => {
|
|
170
|
+
const style = document.createElement("style");
|
|
171
|
+
style.dataset.plugin = "dsh-mobile";
|
|
172
|
+
style.textContent = BASE_STYLES;
|
|
173
|
+
document.head.append(style);
|
|
174
|
+
const loopback = isLoopbackHost(location.hostname);
|
|
175
|
+
const custom = loopback ? void 0 : document.createElement("link");
|
|
176
|
+
if (custom !== void 0) {
|
|
177
|
+
custom.rel = "stylesheet";
|
|
178
|
+
custom.href = "/mobile-access/custom.css";
|
|
179
|
+
custom.dataset.plugin = "dsh-mobile";
|
|
180
|
+
document.head.append(custom);
|
|
181
|
+
}
|
|
182
|
+
const control = loopback ? installControl() : void 0;
|
|
183
|
+
const disposeSlot = control === void 0 ? void 0 : ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
184
|
+
name: "sidebar.footer.action",
|
|
185
|
+
id: "dsh-mobile"
|
|
186
|
+
}, ({ wide }) => (0, react.createElement)("button", {
|
|
187
|
+
className: "dsh-mobile-control__trigger",
|
|
188
|
+
type: "button",
|
|
189
|
+
title: controlLabel(),
|
|
190
|
+
onClick: control.toggle
|
|
191
|
+
}, wide ? controlLabel() : "M")));
|
|
192
|
+
return () => {
|
|
193
|
+
disposeSlot?.();
|
|
194
|
+
control?.remove();
|
|
195
|
+
custom?.remove();
|
|
196
|
+
style.remove();
|
|
197
|
+
};
|
|
198
|
+
}, "dsh-mobile: responsive UI and local control");
|
|
199
|
+
}
|
|
200
|
+
/** Client face has no service prerequisites. */
|
|
201
|
+
const inject = ["slots"];
|
|
202
|
+
function controlLabel() {
|
|
203
|
+
return navigator.language.toLowerCase().startsWith("zh") ? "移动端" : "Mobile access";
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
exports.apply = apply;
|
|
207
|
+
exports.inject = inject;
|
|
208
|
+
return module.exports;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":["createElement"],"sources":["../src/client.ts"],"sourcesContent":["import { createElement } from 'react'\n\ninterface ClientContext {\n effect(effect: () => void | (() => void), label?: string): void\n slots: {\n inject(key: string, callback: () => (() => void)): () => void\n register(options: { name: string; id: string }, component: (props: { wide: boolean }) => unknown): () => void\n }\n}\n\nconst MOBILE_QUERY = '(max-width: 720px)'\n\nconst BASE_STYLES = `\n@media ${MOBILE_QUERY} {\n :root {\n --dsh-mobile-accent: #2563eb;\n --dsh-mobile-font-scale: 1;\n --dsh-mobile-radius: 14px;\n }\n html { font-size: calc(16px * var(--dsh-mobile-font-scale)); }\n body { overscroll-behavior: none; }\n button, [role='button'], input, textarea, select { min-height: 44px; }\n input, textarea, select { font-size: 16px !important; }\n [data-sidebar-collapsed] {\n padding-top: env(safe-area-inset-top);\n padding-right: env(safe-area-inset-right);\n padding-bottom: env(safe-area-inset-bottom);\n padding-left: env(safe-area-inset-left);\n }\n [data-shell-overlay] { inset: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }\n * { -webkit-tap-highlight-color: transparent; }\n}\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }\n}\n.dsh-mobile-control {\n position: fixed;\n left: 68px;\n bottom: 12px;\n z-index: 90;\n color: #172554;\n font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n}\n.dsh-mobile-control button {\n min-width: 44px;\n min-height: 44px;\n border: 1px solid #bfdbfe;\n border-radius: 12px;\n background: #fff;\n color: #1e3a8a;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger {\n width: 100%;\n min-width: 44px;\n min-height: 44px;\n padding: 0 12px;\n border: 0;\n border-radius: 10px;\n background: transparent;\n color: inherit;\n font: inherit;\n font-weight: 700;\n cursor: pointer;\n}\n.dsh-mobile-control__trigger:hover { background: rgb(37 99 235 / 8%); }\n.dsh-mobile-control__panel {\n width: min(320px, calc(100vw - 24px));\n margin-bottom: 8px;\n padding: 16px;\n border: 1px solid #dbeafe;\n border-radius: 16px;\n background: #fff;\n box-shadow: 0 10px 30px rgb(15 23 42 / 12%);\n}\n.dsh-mobile-control__title { margin: 0 0 4px; font-size: 16px; font-weight: 800; }\n.dsh-mobile-control__status { margin: 0 0 14px; color: #475569; overflow-wrap: anywhere; }\n.dsh-mobile-control__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.dsh-mobile-control__actions button[data-primary='true'] { background: #2563eb; border-color: #2563eb; color: #fff; }\n.dsh-mobile-control[hidden], .dsh-mobile-control__panel[hidden] { display: none; }\n`\n\nfunction isLoopbackHost(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'\n}\n\nfunction element<K extends keyof HTMLElementTagNameMap>(name: K, className?: string): HTMLElementTagNameMap[K] {\n const node = document.createElement(name)\n if (className !== undefined) node.className = className\n return node\n}\n\nasync function requestJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {\n const response = await fetch(path, {\n ...init,\n headers: { 'content-type': 'application/json', ...init?.headers },\n })\n const body = await response.json() as Record<string, unknown>\n if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`)\n return body\n}\n\nfunction installControl(): { remove: () => void; toggle: () => void } {\n const zh = navigator.language.toLowerCase().startsWith('zh')\n const root = element('div', 'dsh-mobile-control')\n const panel = element('section', 'dsh-mobile-control__panel')\n panel.hidden = true\n const title = element('h2', 'dsh-mobile-control__title')\n title.textContent = zh ? '移动访问' : 'Mobile access'\n const status = element('p', 'dsh-mobile-control__status')\n status.textContent = zh ? '正在读取状态…' : 'Reading status…'\n const actions = element('div', 'dsh-mobile-control__actions')\n const toggle = element('button')\n toggle.dataset.primary = 'true'\n const pair = element('button')\n pair.textContent = zh ? '生成配对链接' : 'Create pairing link'\n actions.append(toggle, pair)\n panel.append(title, status, actions)\n root.append(panel)\n document.body.append(root)\n\n let running = false\n const render = (data: Record<string, unknown>): void => {\n running = data.running === true\n const origin = typeof data.origin === 'string' ? data.origin : undefined\n status.textContent = running\n ? (zh ? `已开启:${origin ?? ''}` : `Running: ${origin ?? ''}`)\n : (zh ? '已关闭。DSH 仍只在本机可用。' : 'Stopped. DSH remains local-only.')\n toggle.textContent = running ? (zh ? '关闭' : 'Stop') : (zh ? '开启' : 'Start')\n pair.disabled = !running\n }\n const refresh = async (): Promise<void> => {\n try { render(await requestJson('/api/mobile-access/control')) }\n catch (error) { status.textContent = error instanceof Error ? error.message : String(error) }\n }\n toggle.addEventListener('click', () => {\n toggle.disabled = true\n void requestJson('/api/mobile-access/control', {\n method: 'POST',\n body: JSON.stringify({ running: !running }),\n }).then(render, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { toggle.disabled = false })\n })\n pair.addEventListener('click', () => {\n pair.disabled = true\n void requestJson('/api/mobile-access/pairing/open', {\n method: 'POST',\n body: '{}',\n }).then(async (data) => {\n const url = typeof data.pairUrl === 'string' ? data.pairUrl : ''\n if (url !== '' && navigator.clipboard !== undefined) await navigator.clipboard.writeText(url)\n status.textContent = url === '' ? (zh ? '无法生成链接' : 'Could not create link')\n : (zh ? `链接已复制:${url}` : `Copied: ${url}`)\n }, (error: unknown) => {\n status.textContent = error instanceof Error ? error.message : String(error)\n }).finally(() => { pair.disabled = !running })\n })\n void refresh()\n return {\n remove: () => { root.remove() },\n toggle: () => { panel.hidden = !panel.hidden },\n }\n}\n\n/** Install the responsive mobile layer and the loopback-only control card. */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-mobile'\n style.textContent = BASE_STYLES\n document.head.append(style)\n\n const loopback = isLoopbackHost(location.hostname)\n const custom = loopback ? undefined : document.createElement('link')\n if (custom !== undefined) {\n custom.rel = 'stylesheet'\n custom.href = '/mobile-access/custom.css'\n custom.dataset.plugin = 'dsh-mobile'\n document.head.append(custom)\n }\n const control = loopback ? installControl() : undefined\n const disposeSlot = control === undefined ? undefined : ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({\n name: 'sidebar.footer.action',\n id: 'dsh-mobile',\n }, ({ wide }) => createElement('button', {\n className: 'dsh-mobile-control__trigger',\n type: 'button',\n title: controlLabel(),\n onClick: control.toggle,\n }, wide ? controlLabel() : 'M')))\n return () => {\n disposeSlot?.()\n control?.remove()\n custom?.remove()\n style.remove()\n }\n }, 'dsh-mobile: responsive UI and local control')\n}\n\n/** Client face has no service prerequisites. */\nexport const inject: readonly string[] = ['slots']\n\nfunction controlLabel(): string {\n return navigator.language.toLowerCase().startsWith('zh') ? '移动端' : 'Mobile access'\n}\n"],"mappings":"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"}
|