dsh-pocket-ios 0.1.3 → 0.2.4
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/README.md +65 -24
- package/client/client.js +203 -93
- package/cordis.patch.yml +1 -2
- package/lib/index.js +390 -7
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,43 +1,84 @@
|
|
|
1
1
|
# dsh-pocket-ios(DSH 插件)
|
|
2
2
|
|
|
3
|
-
在 DSH
|
|
4
|
-
[DSH Pocket iOS](https://github.com/jqlong17/dsh-pocket-ios) 串起来。
|
|
3
|
+
在 DSH **设置 → 手机访问(iOS)** 里加入一个原生设置页:App Store 下载二维码、Tailscale 固定地址与二维码、访问密码管理。
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
## 它做什么
|
|
10
|
-
|
|
11
|
-
- 设置页新增「iOS 客户端」入口(`settings.section` 插槽,原生渲染);
|
|
12
|
-
- 在「手机访问」页的**局域网访问上方**尽力插入一张 iOS 卡片:App Store 下载二维码 +
|
|
13
|
-
App Store / GitHub Release / IPA 三个入口(DOM 注入,插入失败不影响上面的原生入口);
|
|
14
|
-
- `dependencies` 带 `dsh-pocket`,安装本插件会一并安装手机访问所需的 dsh-pocket。
|
|
5
|
+
- 不依赖 `dsh-pocket`:插件自带稳定本地入口代理(默认 `127.0.0.1:3081` → DSH web)、自己的登录端点与访问密码。
|
|
6
|
+
- 二维码**只包含地址,不包含密码**;密码只保存在本机(`~/.dsh/dsh-pocket-ios/pin`,权限 600)。
|
|
7
|
+
- 访问密码支持:显示 / 复制 / 随机更换 / 自定义(8–64 位字母数字),保存后旧密码立即失效。
|
|
15
8
|
|
|
16
9
|
## 安装
|
|
17
10
|
|
|
18
11
|
```bash
|
|
19
|
-
# 从 npm 安装(会自动带上依赖 dsh-pocket)
|
|
20
12
|
dsh plugin add dsh-pocket-ios
|
|
21
13
|
```
|
|
22
14
|
|
|
23
|
-
|
|
15
|
+
安装后重启 DSH,并刷新页面(client 模块在启动时加载)。
|
|
16
|
+
|
|
17
|
+
App:<https://apps.apple.com/app/id6832288744>(DSH Pocket iOS)。
|
|
18
|
+
|
|
19
|
+
## 连接流程
|
|
20
|
+
|
|
21
|
+
1. 手机安装 Tailscale 并登录同一账号、开启 VPN;
|
|
22
|
+
2. Mac 上执行一次:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
tailscale serve --bg 3081
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
(在 Tailscale 后台 DNS 页开启 HTTPS Certificates;插件读取 `tailscale serve status` 得到固定地址)
|
|
29
|
+
3. 打开 DSH 设置 →「手机访问(iOS)」→ 用 DSH Pocket iOS 扫描二维码填入地址;
|
|
30
|
+
4. 输入设置页显示的访问密码 → 连接。
|
|
31
|
+
|
|
32
|
+
> 若宿主进程读不到 Tailscale 状态(例如 GUI 应用 IPC 不可用),可手写配置文件:
|
|
33
|
+
>
|
|
34
|
+
> ```bash
|
|
35
|
+
> mkdir -p ~/.dsh/dsh-pocket-ios
|
|
36
|
+
> echo '{"url":"https://你的机器名.你的tailnet.ts.net"}' > ~/.dsh/dsh-pocket-ios/tailscale.json
|
|
37
|
+
> ```
|
|
24
38
|
|
|
25
|
-
|
|
39
|
+
## APNs 后台完成推送(可选)
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
1. Apple 开发者后台 → Keys → 新建 **APNs** Key,记录 **Key ID**,下载 `.p8`(只能下载一次),放到 `~/.appstoreconnect/private_keys/AuthKey_<KEYID>.p8`;
|
|
42
|
+
2. 确认 App ID 已开启 **Push Notifications** capability;
|
|
43
|
+
3. 写配置(权限 600):
|
|
29
44
|
|
|
30
|
-
|
|
45
|
+
```bash
|
|
46
|
+
cat > ~/.dsh/dsh-pocket-ios/apns.json <<'JSON'
|
|
47
|
+
{ "keyId": "<KEYID>", "teamId": "<TEAMID>", "environment": "sandbox" }
|
|
48
|
+
JSON
|
|
49
|
+
chmod 600 ~/.dsh/dsh-pocket-ios/apns.json
|
|
50
|
+
```
|
|
31
51
|
|
|
32
|
-
|
|
52
|
+
Debug 构建用 `sandbox`,App Store 版本用 `production`;
|
|
53
|
+
4. 重启 DSH:插件每 5 秒检测会话 `turn/start → turn/end`,向已注册设备推送「对话已完成」;
|
|
54
|
+
5. 通知支持直接回复(含 Apple Watch),回复会作为 prompt 发回对应会话。
|
|
33
55
|
|
|
34
|
-
|
|
56
|
+
推送日志:`~/.dsh/dsh-pocket-ios/tailscale.log`(只记 sessionId、token 尾号、HTTP 状态);设备 token 存 `push.json`。
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
下载 IPA 自签安装。装好后在 App 里扫描 dsh-pocket 的二维码即可连接本机 DSH。
|
|
58
|
+
## 端口说明
|
|
38
59
|
|
|
39
|
-
|
|
60
|
+
稳定入口默认 **3091**(避开 dsh-pocket 的 3081),可与 dsh-pocket 同时启用:
|
|
61
|
+
|
|
62
|
+
- Tailscale Serve 指向 3091:`tailscale serve --bg 3091`
|
|
63
|
+
- dsh-pocket 继续占用 3081 提供局域网/公网二维码
|
|
64
|
+
- 如需改端口:`DSH_POCKET_IOS_PROXY_PORT=<port> dsh start`,并同步 `tailscale serve`
|
|
65
|
+
|
|
66
|
+
## 安全
|
|
67
|
+
|
|
68
|
+
- 密码/token 不写入日志、不进入构建产物、不进入 Git;
|
|
69
|
+
- 二维码只编码地址;
|
|
70
|
+
- 登录端点在校验密码后,通过 DSH 自带 `connection.authenticatedUrl` 换 `dsh-auth` cookie,不另造会话体系;
|
|
71
|
+
- 代理只监听 loopback(`127.0.0.1`)。
|
|
72
|
+
|
|
73
|
+
## 开发
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install
|
|
77
|
+
# 修改 lib/index.js / client/client.js 后:
|
|
78
|
+
node --check lib/index.js && node --check client/client.js
|
|
79
|
+
npm pack --dry-run
|
|
80
|
+
```
|
|
40
81
|
|
|
41
|
-
|
|
82
|
+
## License
|
|
42
83
|
|
|
43
|
-
|
|
84
|
+
GPL-2.0,见 [LICENSE](LICENSE)。
|
package/client/client.js
CHANGED
|
@@ -5,119 +5,229 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
var React = require("react");
|
|
7
7
|
|
|
8
|
-
var RELEASE_URL = "https://github.com/jqlong17/dsh-pocket-ios/releases/latest";
|
|
9
|
-
var DOWNLOAD_URL = "https://github.com/jqlong17/dsh-pocket-ios/releases/latest/download/DSHPocket.ipa";
|
|
10
8
|
var APP_STORE_URL = "https://apps.apple.com/app/id6832288744";
|
|
9
|
+
var REPO_URL = "https://github.com/jqlong17/dsh-pocket-ios";
|
|
11
10
|
var QR_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"100%\" viewBox=\"0 0 37 37\" shape-rendering=\"crispEdges\"><path fill=\"#ffffff\" d=\"M0 0h37v37H0z\"/><path stroke=\"#000000\" d=\"M4 4.5h7m3 0h1m4 0h5m2 0h7M4 5.5h1m5 0h1m2 0h1m7 0h1m2 0h1m1 0h1m5 0h1M4 6.5h1m1 0h3m1 0h1m1 0h1m5 0h1m1 0h1m3 0h1m1 0h1m1 0h3m1 0h1M4 7.5h1m1 0h3m1 0h1m1 0h2m7 0h1m1 0h1m2 0h1m1 0h3m1 0h1M4 8.5h1m1 0h3m1 0h1m1 0h8m2 0h3m1 0h1m1 0h3m1 0h1M4 9.5h1m5 0h1m1 0h2m1 0h5m2 0h1m1 0h1m1 0h1m5 0h1M4 10.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7M12 11.5h1m1 0h1m1 0h2m1 0h2m2 0h2M4 12.5h1m1 0h5m5 0h1m1 0h2m1 0h1m1 0h1m2 0h5M8 13.5h1m5 0h2m7 0h4m1 0h1m3 0h1M5 14.5h1m3 0h2m1 0h1m1 0h3m5 0h1m1 0h1m1 0h2M4 15.5h5m6 0h2m1 0h1m1 0h1m1 0h2m1 0h2m1 0h2m1 0h1M5 16.5h3m1 0h2m1 0h2m5 0h3m5 0h1m1 0h2M4 17.5h4m3 0h3m1 0h1m1 0h2m4 0h6m3 0h1M8 18.5h3m3 0h1m2 0h4m1 0h1m2 0h1m2 0h3M5 19.5h2m2 0h1m1 0h1m3 0h1m1 0h1m1 0h1m2 0h3m3 0h1m2 0h1M4 20.5h2m2 0h1m1 0h6m2 0h1m1 0h4m3 0h1m1 0h2M4 21.5h6m1 0h1m1 0h3m5 0h3m2 0h3m1 0h1m1 0h1M4 22.5h1m4 0h3m2 0h2m3 0h3m2 0h2m2 0h1m1 0h1M4 23.5h1m2 0h3m3 0h1m1 0h2m1 0h1m5 0h2m2 0h1m2 0h1M4 24.5h1m1 0h3m1 0h1m3 0h3m4 0h1m2 0h5m1 0h3M12 25.5h3m1 0h3m2 0h2m1 0h1m3 0h5M4 26.5h7m6 0h2m3 0h3m1 0h1m1 0h3M4 27.5h1m5 0h1m1 0h6m5 0h2m3 0h1M4 28.5h1m1 0h3m1 0h1m1 0h5m1 0h1m2 0h1m2 0h5m1 0h1m1 0h1M4 29.5h1m1 0h3m1 0h1m1 0h2m8 0h1m4 0h1m1 0h2M4 30.5h1m1 0h3m1 0h1m1 0h1m1 0h1m3 0h2m3 0h2m1 0h6M4 31.5h1m5 0h1m3 0h2m3 0h1m2 0h1m1 0h2m1 0h3m1 0h1M4 32.5h7m1 0h2m1 0h1m1 0h4m1 0h2m1 0h1m2 0h1m1 0h1\"/></svg>";
|
|
12
|
-
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// 跟随 DSH 设置页的语言:locale 服务会同步 <html lang>,这里监听变化。
|
|
14
|
+
function currentLang() {
|
|
15
|
+
try {
|
|
16
|
+
var tag = (document.documentElement.lang || navigator.language || "zh").toLowerCase();
|
|
17
|
+
return tag.indexOf("zh") === 0 ? "zh" : "en";
|
|
18
|
+
} catch (e) { return "zh"; }
|
|
19
|
+
}
|
|
20
|
+
function useLang() {
|
|
21
|
+
var state = React.useState(currentLang());
|
|
22
|
+
React.useEffect(function () {
|
|
23
|
+
if (typeof MutationObserver === "undefined") return undefined;
|
|
24
|
+
var observer = new MutationObserver(function () { state[1](currentLang()); });
|
|
25
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
|
|
26
|
+
return function () { observer.disconnect(); };
|
|
27
|
+
}, []);
|
|
28
|
+
return state[0];
|
|
29
|
+
}
|
|
30
|
+
function makeT(lang) { return function (zh, en) { return lang === "zh" ? zh : en; }; }
|
|
13
31
|
|
|
14
32
|
function linkStyle(primary) {
|
|
15
33
|
return {
|
|
16
|
-
display: "inline-flex",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
padding: "7px 12px",
|
|
20
|
-
borderRadius: 999,
|
|
21
|
-
fontSize: 13,
|
|
22
|
-
fontWeight: 600,
|
|
23
|
-
textDecoration: "none",
|
|
24
|
-
color: primary ? "#fff" : "#147A5E",
|
|
34
|
+
display: "inline-flex", alignItems: "center", gap: 6,
|
|
35
|
+
padding: "7px 12px", borderRadius: 999, fontSize: 13, fontWeight: 600,
|
|
36
|
+
textDecoration: "none", color: primary ? "#fff" : "#147A5E",
|
|
25
37
|
background: primary ? "#147A5E" : "rgba(20,122,94,.08)",
|
|
26
38
|
border: primary ? "1px solid #147A5E" : "1px solid rgba(20,122,94,.25)"
|
|
27
39
|
};
|
|
28
40
|
}
|
|
41
|
+
var chipStyle = { display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 999, fontSize: 12, fontWeight: 500, color: "#147A5E", background: "rgba(20,122,94,.08)", border: "1px solid rgba(20,122,94,.22)", cursor: "pointer" };
|
|
42
|
+
var chipPrimary = { ...chipStyle, color: "#fff", background: "#147A5E", border: "1px solid #147A5E" };
|
|
29
43
|
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{ style: { flex: "1 1 220px", minWidth: 0 } },
|
|
41
|
-
React.createElement("div", { style: { fontWeight: 600, fontSize: 15, marginBottom: 6 } }, "在 iPhone 上使用 DSH"),
|
|
42
|
-
React.createElement(
|
|
43
|
-
"div",
|
|
44
|
-
{ style: { fontSize: 13, opacity: 0.72, lineHeight: 1.6, marginBottom: 10 } },
|
|
45
|
-
"用手机扫描二维码从 App Store 下载 DSH Pocket iOS;没有 App Store 的地区可从 GitHub Release 下载 IPA 自签安装。装好后在 App 里扫描电脑上 dsh-pocket 的二维码即可连接本机 DSH。"
|
|
46
|
-
),
|
|
47
|
-
React.createElement(
|
|
48
|
-
"div",
|
|
49
|
-
{ style: { display: "flex", gap: 10, flexWrap: "wrap" } },
|
|
50
|
-
React.createElement("a", { href: APP_STORE_URL, target: "_blank", rel: "noreferrer", style: linkStyle(true) }, "在 App Store 下载"),
|
|
51
|
-
React.createElement("a", { href: RELEASE_URL, target: "_blank", rel: "noreferrer", style: linkStyle(false) }, "GitHub Release(自签)"),
|
|
52
|
-
React.createElement("a", { href: DOWNLOAD_URL, target: "_blank", rel: "noreferrer", style: linkStyle(false) }, "直接下载 IPA")
|
|
53
|
-
)
|
|
54
|
-
)
|
|
55
|
-
);
|
|
44
|
+
function copyText(text) {
|
|
45
|
+
try { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text); return true; } } catch (e) {}
|
|
46
|
+
try {
|
|
47
|
+
var area = document.createElement("textarea");
|
|
48
|
+
area.value = text; area.style.position = "fixed"; area.style.top = "-1000px"; area.style.opacity = "0";
|
|
49
|
+
document.body.appendChild(area); area.focus(); area.select();
|
|
50
|
+
var ok = document.execCommand("copy");
|
|
51
|
+
document.body.removeChild(area);
|
|
52
|
+
return ok;
|
|
53
|
+
} catch (e) { return false; }
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
'<div style="font-weight:600;font-size:15px;margin-bottom:6px">在 iPhone 上使用 DSH</div>' +
|
|
63
|
-
'<div style="font-size:13px;opacity:.72;line-height:1.6;margin-bottom:10px">扫码从 App Store 下载 DSH Pocket iOS;没有 App Store 的地区可从 GitHub Release 下载 IPA 自签安装。装好后在 App 里扫描上方二维码连接本机 DSH。</div>' +
|
|
64
|
-
'<div style="display:flex;gap:10px;flex-wrap:wrap">' +
|
|
65
|
-
'<a href="' + APP_STORE_URL + '" target="_blank" rel="noreferrer" style="display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;font-size:13px;font-weight:600;text-decoration:none;color:#fff;background:#147A5E">在 App Store 下载</a>' +
|
|
66
|
-
'<a href="' + RELEASE_URL + '" target="_blank" rel="noreferrer" style="display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;font-size:13px;font-weight:600;text-decoration:none;color:#147A5E;background:rgba(20,122,94,.08);border:1px solid rgba(20,122,94,.25)">GitHub Release(自签)</a>' +
|
|
67
|
-
'<a href="' + DOWNLOAD_URL + '" target="_blank" rel="noreferrer" style="display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;font-size:13px;font-weight:600;text-decoration:none;color:#147A5E;background:rgba(20,122,94,.08);border:1px solid rgba(20,122,94,.25)">直接下载 IPA</a>' +
|
|
68
|
-
"</div>" +
|
|
69
|
-
"</div>" +
|
|
70
|
-
"</div>";
|
|
71
|
-
/** 尽力把卡片插到「手机访问 → 局域网访问」上方;失败时不影响原生设置入口。 */
|
|
72
|
-
function insertCardInPocketPage() {
|
|
73
|
-
if (typeof document === "undefined") return;
|
|
74
|
-
if (document.getElementById(CARD_ID)) return;
|
|
75
|
-
var nodes = Array.prototype.slice.call(document.querySelectorAll("div,span,p"));
|
|
76
|
-
var label = null;
|
|
77
|
-
for (var i = 0; i < nodes.length; i++) {
|
|
78
|
-
var el = nodes[i];
|
|
79
|
-
if (el.childElementCount === 0 && (el.textContent || "").trim() === "局域网访问" && el.offsetParent !== null) { label = el; break; }
|
|
80
|
-
}
|
|
81
|
-
if (!label) return;
|
|
82
|
-
// 从标签向上找「手机访问」页签的滚动内容容器;只在这里插入,避免落到设置弹窗的导航列里。
|
|
83
|
-
var node = label.parentElement;
|
|
84
|
-
var scroller = null;
|
|
85
|
-
for (var depth = 0; depth < 8 && node && node !== document.body; depth++) {
|
|
86
|
-
var style = window.getComputedStyle(node);
|
|
87
|
-
var width = node.getBoundingClientRect().width;
|
|
88
|
-
var scrollable = style.overflowY === "auto" || style.overflowY === "scroll";
|
|
89
|
-
if (width >= 260 && scrollable) { scroller = node; break; }
|
|
90
|
-
node = node.parentElement;
|
|
56
|
+
function unwrapRpc(result) {
|
|
57
|
+
if (result && typeof result === "object" && "ok" in result) {
|
|
58
|
+
if (!result.ok) return Promise.reject(new Error((result.error && result.error.message) || makeT(currentLang())("RPC 失败", "RPC failed")));
|
|
59
|
+
return result.value !== undefined ? result.value : result;
|
|
91
60
|
}
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
61
|
+
if (result && typeof result === "object" && result.result) return unwrapRpc(result.result);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ===== 单个连接卡片:配对二维码 + 地址 + 密码管理 =====
|
|
66
|
+
function AccessPanelCard(props) {
|
|
67
|
+
var pairState = React.useState({ loading: true, error: null, data: null });
|
|
68
|
+
var pairValue = pairState[0];
|
|
69
|
+
var setPair = pairState[1];
|
|
70
|
+
var remainingState = React.useState(0);
|
|
71
|
+
var remaining = remainingState[0];
|
|
72
|
+
var setRemaining = remainingState[1];
|
|
73
|
+
var statusState = React.useState(null);
|
|
74
|
+
var status = statusState[0];
|
|
75
|
+
var setStatus = statusState[1];
|
|
76
|
+
var loadingState = React.useState(true);
|
|
77
|
+
var loading = loadingState[0];
|
|
78
|
+
var setLoading = loadingState[1];
|
|
79
|
+
|
|
80
|
+
var showPinState = React.useState(false);
|
|
81
|
+
var showPin = showPinState[0];
|
|
82
|
+
var setShowPin = showPinState[1];
|
|
83
|
+
var copiedState = React.useState(null);
|
|
84
|
+
var copied = copiedState[0];
|
|
85
|
+
var setCopied = copiedState[1];
|
|
86
|
+
var editingState = React.useState(false);
|
|
87
|
+
var editing = editingState[0];
|
|
88
|
+
var setEditing = editingState[1];
|
|
89
|
+
var customState = React.useState("");
|
|
90
|
+
var customPin = customState[0];
|
|
91
|
+
var setCustomPin = customState[1];
|
|
92
|
+
var busyState = React.useState(false);
|
|
93
|
+
var busy = busyState[0];
|
|
94
|
+
var setBusy = busyState[1];
|
|
95
|
+
var errorState = React.useState(null);
|
|
96
|
+
var actionError = errorState[0];
|
|
97
|
+
var setActionError = errorState[1];
|
|
98
|
+
|
|
99
|
+
var refreshStatus = function () {
|
|
100
|
+
if (!props.rpcCall) { setLoading(false); return; }
|
|
101
|
+
Promise.resolve(props.rpcCall("tailscale.status", {})).then(unwrapRpc).then(function (s) {
|
|
102
|
+
setStatus(s || null); setLoading(false);
|
|
103
|
+
}).catch(function () { setLoading(false); });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
var generatePair = function () {
|
|
107
|
+
if (!props.rpcCall) return;
|
|
108
|
+
Promise.resolve(props.rpcCall("pair.create", {})).then(unwrapRpc).then(function (data) {
|
|
109
|
+
setPair({ loading: false, error: null, data: data });
|
|
110
|
+
setRemaining(data && data.expiresIn ? data.expiresIn : 60);
|
|
111
|
+
}).catch(function (e) {
|
|
112
|
+
var msg = String((e && e.message) || e);
|
|
113
|
+
if (msg.indexOf("unknown endpoint") >= 0) msg = makeT(currentLang())("配对服务未就绪:请重启 DSH 后再刷新本页。", "Pairing service not ready: restart DSH and refresh this page.");
|
|
114
|
+
setPair({ loading: false, error: msg, data: null });
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
React.useEffect(function () {
|
|
119
|
+
refreshStatus();
|
|
120
|
+
generatePair();
|
|
121
|
+
var timer = setInterval(function () {
|
|
122
|
+
setRemaining(function (prev) {
|
|
123
|
+
var next = prev - 1;
|
|
124
|
+
if (next <= 8) { generatePair(); return 60; }
|
|
125
|
+
return next;
|
|
126
|
+
});
|
|
127
|
+
}, 1000);
|
|
128
|
+
return function () { clearInterval(timer); };
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
var mask = new Array(status && status.pin ? status.pin.length : 8).fill("•").join("");
|
|
132
|
+
var copy = function (text, field) {
|
|
133
|
+
if (!text) return;
|
|
134
|
+
copyText(text); setCopied(field);
|
|
135
|
+
setTimeout(function () { setCopied(null); }, 1500);
|
|
136
|
+
};
|
|
137
|
+
var savePin = function (nextPin) {
|
|
138
|
+
if (!props.rpcCall) return;
|
|
139
|
+
if (!/^[A-Za-z0-9]{8,64}$/.test(nextPin)) { setActionError(makeT(currentLang())("密码需为 8-64 位英文字母或数字", "PIN must be 8–64 letters or digits")); return; }
|
|
140
|
+
setBusy(true); setActionError(null);
|
|
141
|
+
Promise.resolve(props.rpcCall("pin.set", { pin: nextPin })).then(unwrapRpc).then(function (s) {
|
|
142
|
+
setStatus(s || status); setShowPin(true); setEditing(false); setCustomPin(""); setBusy(false);
|
|
143
|
+
}).catch(function (e) { setBusy(false); setActionError(String((e && e.message) || e)); });
|
|
144
|
+
};
|
|
145
|
+
var refreshPin = function () {
|
|
146
|
+
if (!props.rpcCall) return;
|
|
147
|
+
setBusy(true); setActionError(null);
|
|
148
|
+
Promise.resolve(props.rpcCall("pin.refresh", {})).then(unwrapRpc).then(function (s) {
|
|
149
|
+
setStatus(s || status); setShowPin(true); setBusy(false);
|
|
150
|
+
}).catch(function (e) { setBusy(false); setActionError(String((e && e.message) || e)); });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return React.createElement("div", { style: { padding: 14, borderRadius: 12, border: "1px solid rgba(0,0,0,.08)" } },
|
|
154
|
+
React.createElement("div", { style: { fontWeight: 600, fontSize: 14, marginBottom: 4 } }, makeT(currentLang())("扫码连接(推荐,免密码)", "Scan to connect (recommended, no PIN)")),
|
|
155
|
+
React.createElement("div", { style: { fontSize: 12, opacity: 0.65, lineHeight: 1.5, marginBottom: 10 } },
|
|
156
|
+
makeT(currentLang())("用 DSH Pocket 扫描下方二维码即可直接连接,无需输入密码。二维码自动轮换,每次有效 60 秒、仅可使用一次。", "Scan the QR below with DSH Pocket to connect directly — no PIN. The code rotates automatically and each one is valid for 60 seconds, single use.")),
|
|
157
|
+
React.createElement("div", { style: { display: "flex", gap: 14, alignItems: "flex-start", flexWrap: "wrap" } },
|
|
158
|
+
pairValue.data && pairValue.data.qr
|
|
159
|
+
? React.createElement("img", { src: pairValue.data.qr, alt: makeT(currentLang())("扫码连接二维码", "Scan-to-connect QR"), style: { width: 148, height: 148, borderRadius: 8, background: "#fff", padding: 5, border: "1px solid rgba(0,0,0,.06)", flex: "0 0 148px" } })
|
|
160
|
+
: React.createElement("div", { style: { width: 148, height: 148, borderRadius: 8, border: "1px dashed rgba(0,0,0,.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 12, opacity: 0.6, flex: "0 0 148px", textAlign: "center", padding: 8 } },
|
|
161
|
+
pairValue.loading ? makeT(currentLang())("正在生成二维码…", "Generating QR…") : makeT(currentLang())(makeT(currentLang())("二维码不可用", "QR unavailable"), "QR unavailable")),
|
|
162
|
+
React.createElement("div", { style: { flex: "1 1 220px", minWidth: 0 } },
|
|
163
|
+
React.createElement("div", { style: { fontSize: 11, opacity: 0.55, marginBottom: 2 } }, makeT(currentLang())("剩余有效时间", "Time remaining")),
|
|
164
|
+
React.createElement("div", { style: { fontSize: 20, fontWeight: 600, color: "#147A5E", marginBottom: 10 } }, remaining + "s"),
|
|
165
|
+
React.createElement("div", { style: { fontSize: 11, opacity: 0.55, marginBottom: 2 } }, makeT(currentLang())("固定地址", "Fixed address")),
|
|
166
|
+
React.createElement("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginBottom: 10 } },
|
|
167
|
+
React.createElement("div", { style: { fontSize: 12.5, wordBreak: "break-all", flex: "1 1 170px", minWidth: 0 } }, status && status.httpsUrl ? status.httpsUrl : (loading ? makeT(currentLang())("检测中…", "Checking…") : makeT(currentLang())("未检测到 Tailscale 地址", "No Tailscale address detected"))),
|
|
168
|
+
React.createElement("button", { type: "button", onClick: function () { copy(status && status.httpsUrl, "url"); }, style: chipStyle }, copied === "url" ? makeT(currentLang())("已复制", "Copied") : makeT(currentLang())("复制", "Copy"))),
|
|
169
|
+
React.createElement("div", { style: { fontSize: 11, opacity: 0.55, marginBottom: 4 } }, makeT(currentLang())("访问密码(手动登录用)", "Access PIN (manual login)")),
|
|
170
|
+
React.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
|
|
171
|
+
React.createElement("code", { style: { fontSize: 13, letterSpacing: 0.5, minWidth: 68 } }, showPin ? ((status && status.pin) || "—") : mask),
|
|
172
|
+
React.createElement("button", { type: "button", onClick: function () { setShowPin(!showPin); }, style: chipStyle }, showPin ? makeT(currentLang())("隐藏", "Hide") : makeT(currentLang())("显示", "Show")),
|
|
173
|
+
React.createElement("button", { type: "button", onClick: function () { copy(status && status.pin, "pin"); }, style: chipStyle }, copied === "pin" ? makeT(currentLang())("已复制", "Copied") : makeT(currentLang())("复制", "Copy")),
|
|
174
|
+
React.createElement("button", { type: "button", disabled: busy, onClick: refreshPin, style: chipStyle }, busy ? makeT(currentLang())("生成中…", "Generating…") : makeT(currentLang())("随机更换", "Regenerate")),
|
|
175
|
+
React.createElement("button", { type: "button", disabled: busy, onClick: function () { setEditing(!editing); setActionError(null); }, style: chipStyle }, editing ? makeT(currentLang())("收起", "Collapse") : makeT(currentLang())("自定义", "Custom"))),
|
|
176
|
+
editing ? React.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap", marginTop: 8 } },
|
|
177
|
+
React.createElement("input", {
|
|
178
|
+
value: customPin, placeholder: makeT(currentLang())("8-64 位字母或数字", "8–64 letters or digits"),
|
|
179
|
+
onChange: function (event) { setCustomPin(event.target.value); },
|
|
180
|
+
style: { padding: "4px 8px", borderRadius: 6, border: "1px solid rgba(0,0,0,.15)", fontSize: 12, minWidth: 160 }
|
|
181
|
+
}),
|
|
182
|
+
React.createElement("button", { type: "button", disabled: busy, onClick: function () { savePin(customPin); }, style: chipPrimary }, makeT(currentLang())("保存", "Save")),
|
|
183
|
+
React.createElement("span", { style: { fontSize: 11, opacity: 0.55 } }, makeT(currentLang())("保存后旧密码失效", "Old PIN stops working after saving"))) : null,
|
|
184
|
+
actionError ? React.createElement("div", { style: { fontSize: 11, color: "#b91c1c", marginTop: 6 } }, actionError) : null,
|
|
185
|
+
pairValue.error ? React.createElement("div", { style: { fontSize: 11, color: "#b91c1c", marginTop: 6 } }, pairValue.error) : null,
|
|
186
|
+
React.createElement("div", { style: { fontSize: 11, opacity: 0.55, lineHeight: 1.5, marginTop: 10 } },
|
|
187
|
+
makeT(currentLang())("扫配对码无需密码,最省事;手动登录时用上面的访问密码。密码只保存在本机,请勿截图分享。", "Scanning the pairing QR needs no PIN and is easiest; use the access PIN above for manual login. The PIN stays on this machine — don't share screenshots.")))));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function AppDownloadCard() {
|
|
191
|
+
return React.createElement("div", { style: { padding: 14, borderRadius: 12, border: "1px solid rgba(0,0,0,.08)", background: "rgba(20,122,94,.04)" } },
|
|
192
|
+
React.createElement("div", { style: { display: "flex", flexWrap: "wrap", gap: 14, alignItems: "flex-start" } },
|
|
193
|
+
React.createElement("div", {
|
|
194
|
+
style: { width: 116, height: 116, flex: "0 0 116px", background: "#fff", borderRadius: 8, padding: 6, border: "1px solid rgba(0,0,0,.06)", boxSizing: "border-box" },
|
|
195
|
+
dangerouslySetInnerHTML: { __html: QR_SVG }
|
|
196
|
+
}),
|
|
197
|
+
React.createElement("div", { style: { flex: "1 1 240px", minWidth: 0 } },
|
|
198
|
+
React.createElement("div", { style: { fontWeight: 600, fontSize: 15, marginBottom: 4 } }, makeT(currentLang())("在 iPhone 上使用 DSH", "Use DSH on iPhone")),
|
|
199
|
+
React.createElement("div", { style: { fontSize: 12, opacity: 0.68, lineHeight: 1.55, marginBottom: 10 } },
|
|
200
|
+
makeT(currentLang())("扫码从 App Store 安装 DSH Pocket iOS;装好后用上方二维码连接(或 dsh-pocket 的局域网/公网二维码)。", "Scan to install DSH Pocket iOS from the App Store, then connect with the QR above (or dsh-pocket's LAN/public QR).")),
|
|
201
|
+
React.createElement("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
|
|
202
|
+
React.createElement("a", { href: APP_STORE_URL, target: "_blank", rel: "noreferrer", style: chipPrimary }, makeT(currentLang())("在 App Store 下载", "Download on the App Store")),
|
|
203
|
+
React.createElement("a", { href: REPO_URL, target: "_blank", rel: "noreferrer", style: chipStyle }, makeT(currentLang())("GitHub 仓库", "GitHub repo"))))));
|
|
97
204
|
}
|
|
98
205
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
run();
|
|
206
|
+
function MobileAccessPanel(props) {
|
|
207
|
+
return React.createElement("div", { style: { width: "100%", maxWidth: 680, display: "flex", flexDirection: "column", gap: 12 } },
|
|
208
|
+
React.createElement(AppDownloadCard),
|
|
209
|
+
React.createElement(AccessPanelCard, { rpcCall: props.rpcCall }),
|
|
210
|
+
React.createElement("div", { style: { fontSize: 11, opacity: 0.55, lineHeight: 1.5 } },
|
|
211
|
+
makeT(currentLang())("局域网 / 公网二维码由 dsh-pocket 插件提供(可选)。扫码配对无需密码,推荐优先使用。", "LAN/public QR codes come from the dsh-pocket plugin (optional). Scan-to-pair needs no PIN and is recommended.")));
|
|
106
212
|
}
|
|
107
213
|
|
|
108
214
|
function apply(ctx) {
|
|
109
|
-
|
|
110
|
-
ctx.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
215
|
+
var rpcCall = function (endpoint, payload) {
|
|
216
|
+
return Promise.resolve(ctx.connection.rpc.call("/dsh-pocket-ios", endpoint || "tailscale.status", payload || {}));
|
|
217
|
+
};
|
|
218
|
+
function MobileAccessSection() { return React.createElement(MobileAccessPanel, { rpcCall: rpcCall }); }
|
|
219
|
+
ctx.slots.inject("settings.section", function () {
|
|
220
|
+
return ctx.slots.register({
|
|
221
|
+
name: "settings.section",
|
|
222
|
+
id: "pocket-ios",
|
|
223
|
+
order: 2,
|
|
224
|
+
label: function () { return currentLang() === "zh" ? "手机访问(iOS)" : "Phone access (iOS)"; },
|
|
225
|
+
inject: function () { return { rpcCall: rpcCall }; }
|
|
226
|
+
}, MobileAccessSection);
|
|
227
|
+
});
|
|
118
228
|
}
|
|
119
229
|
|
|
120
|
-
module.exports = { apply: apply };
|
|
230
|
+
module.exports = { name: "dsh-pocket-ios", inject: ["slots", "connection"], apply: apply };
|
|
121
231
|
return module.exports;
|
|
122
232
|
}
|
|
123
233
|
});
|
package/cordis.patch.yml
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,16 +1,399 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-pocket-ios 宿主入口。
|
|
3
|
-
*
|
|
3
|
+
* 1) 检测本机 Tailscale(DNSName + tailscale serve 的 HTTPS 地址);
|
|
4
|
+
* 2) 读取 dsh-pocket 的访问密码,生成带 `?pin=` 的二维码(App 扫码即连);
|
|
5
|
+
* 3) 通过 webServer 暴露 `/dsh-pocket-ios/tailscale.status` 给设置页卡片。
|
|
4
6
|
*/
|
|
7
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { randomInt } from 'node:crypto'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
12
|
+
import { createServer, request as httpRequest } from 'node:http'
|
|
13
|
+
import { promisify } from 'node:util'
|
|
14
|
+
import QRCode from 'qrcode'
|
|
15
|
+
|
|
16
|
+
const execFileP = promisify(execFile)
|
|
17
|
+
|
|
5
18
|
export const name = 'dsh-pocket-ios'
|
|
6
|
-
export const inject = []
|
|
19
|
+
export const inject = ['connection', 'webServer']
|
|
20
|
+
|
|
21
|
+
const CHANNEL = '/dsh-pocket-ios'
|
|
22
|
+
const TAILSCALE_BIN = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
|
|
23
|
+
const BODY_MAX = 64 * 1024
|
|
24
|
+
/** 稳定本地入口端口:Tailscale Serve 指向它;默认 3081(与 dsh-pocket 代理端口一致)。 */
|
|
25
|
+
const PROXY_PORT = Number(process.env.DSH_POCKET_IOS_PROXY_PORT ?? 3091)
|
|
26
|
+
|
|
27
|
+
const LOG_DIR = join(homedir(), '.dsh', 'dsh-pocket-ios')
|
|
28
|
+
|
|
29
|
+
// 一次性配对 token(60 秒有效、用后作废),用于扫码免密码连接。
|
|
30
|
+
const pairTokens = new Map()
|
|
31
|
+
async function logLine(line) {
|
|
32
|
+
try {
|
|
33
|
+
await mkdir(LOG_DIR, { recursive: true })
|
|
34
|
+
await appendFile(join(LOG_DIR, 'tailscale.log'), `${new Date().toISOString()} ${line}\n`)
|
|
35
|
+
} catch { /* 日志失败不影响主流程 */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function tailscale(args) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execFileP(TAILSCALE_BIN, args, { timeout: 8000, maxBuffer: 1 << 20 })
|
|
41
|
+
return stdout
|
|
42
|
+
} catch (error) {
|
|
43
|
+
await logLine(`exec ${args.join(' ')} failed: ${error?.code ?? ''} ${error?.message ?? error}`)
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PIN_FILE = join(LOG_DIR, 'pin')
|
|
49
|
+
|
|
50
|
+
/** 读取(或首次生成)本插件自己的访问密码,不依赖 dsh-pocket。 */
|
|
51
|
+
const PIN_PATTERN = /^[A-Za-z0-9]{8,64}$/
|
|
52
|
+
|
|
53
|
+
/** 写入访问密码(自定义或随机刷新);返回写入后的状态。 */
|
|
54
|
+
async function setPin(nextPin) {
|
|
55
|
+
await mkdir(LOG_DIR, { recursive: true })
|
|
56
|
+
await writeFile(PIN_FILE, nextPin, { mode: 0o600 })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readPin() {
|
|
60
|
+
try {
|
|
61
|
+
const existing = (await readFile(PIN_FILE, 'utf8')).trim()
|
|
62
|
+
if (/^[A-Za-z0-9]{8,64}$/.test(existing)) return existing
|
|
63
|
+
} catch { /* 首次运行:下面生成 */ }
|
|
64
|
+
const pin = String(randomInt(10_000_000, 100_000_000))
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(LOG_DIR, { recursive: true })
|
|
67
|
+
await writeFile(PIN_FILE, pin, { mode: 0o600 })
|
|
68
|
+
} catch { /* 写失败仍返回内存值 */ }
|
|
69
|
+
return pin
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 读取配置文件里的 Tailscale 地址(DSH 宿主进程往往访问不到 Tailscale GUI 的 IPC,
|
|
73
|
+
* CLI 探测会报 "The Tailscale GUI failed to start",因此配置文件是首选的可靠来源)。
|
|
74
|
+
* 文件:~/.dsh/dsh-pocket-ios/tailscale.json,内容 {"url":"https://<机器>.<tailnet>.ts.net"}
|
|
75
|
+
*/
|
|
76
|
+
async function readConfiguredUrl() {
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(join(LOG_DIR, 'tailscale.json'), 'utf8')
|
|
79
|
+
const parsed = JSON.parse(raw)
|
|
80
|
+
const url = typeof parsed?.url === 'string' ? parsed.url.trim().replace(/\/$/, '') : ''
|
|
81
|
+
if (/^https:\/\//.test(url)) return url
|
|
82
|
+
} catch { /* 没有配置文件时回退到 CLI */ }
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 供 RPC 与单测使用:返回 Tailscale 连接信息与带访问密码的二维码。 */
|
|
87
|
+
export async function tailscaleStatus() {
|
|
88
|
+
let dnsName = null
|
|
89
|
+
let httpsUrl = null
|
|
90
|
+
let serveRaw = null
|
|
91
|
+
let source = null
|
|
92
|
+
|
|
93
|
+
const configuredUrl = await readConfiguredUrl()
|
|
94
|
+
if (configuredUrl) {
|
|
95
|
+
httpsUrl = configuredUrl
|
|
96
|
+
source = 'config'
|
|
97
|
+
try {
|
|
98
|
+
const json = JSON.parse(await tailscale(['status', '--json']))
|
|
99
|
+
dnsName = json?.Self?.DNSName ? String(json.Self.DNSName).replace(/\.$/, '') : null
|
|
100
|
+
} catch { /* 只用于展示;配置了 URL 就够用 */ }
|
|
101
|
+
if (!dnsName) dnsName = new URL(configuredUrl).hostname
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!httpsUrl) {
|
|
105
|
+
try {
|
|
106
|
+
const json = JSON.parse(await tailscale(['status', '--json']))
|
|
107
|
+
dnsName = json?.Self?.DNSName ? String(json.Self.DNSName).replace(/\.$/, '') : null
|
|
108
|
+
} catch { /* Tailscale 未安装/未登录 */ }
|
|
109
|
+
try {
|
|
110
|
+
const out = await tailscale(['serve', 'status'])
|
|
111
|
+
serveRaw = out
|
|
112
|
+
const match = out.match(/https:\/\/[^\s)]+/)
|
|
113
|
+
if (match) {
|
|
114
|
+
httpsUrl = match[0].replace(/\/$/, '')
|
|
115
|
+
source = 'cli'
|
|
116
|
+
} else {
|
|
117
|
+
await logLine(`serve status 无 https 匹配: ${JSON.stringify(out.slice(0, 200))}`)
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
await logLine(`serve status 异常: ${error?.message ?? error}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const pin = await readPin()
|
|
125
|
+
// 产品安全:二维码只包含连接地址,绝不把访问密码写进二维码/图片。
|
|
126
|
+
const qrUrl = httpsUrl
|
|
127
|
+
const qr = qrUrl ? await QRCode.toDataURL(qrUrl, { margin: 1, width: 360 }) : null
|
|
128
|
+
return { available: Boolean(dnsName) || Boolean(httpsUrl), dnsName, httpsUrl, pin, pinSet: Boolean(pin), qrUrl, qr, serveRaw, source }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function endpointFromPath(pathname) {
|
|
132
|
+
if (typeof pathname !== 'string' || !pathname.startsWith(`${CHANNEL}/`)) return undefined
|
|
133
|
+
const endpoint = pathname.slice(CHANNEL.length + 1)
|
|
134
|
+
if (!endpoint || endpoint.split('/').some((seg) => seg === '' || seg === '.' || seg === '..')) return undefined
|
|
135
|
+
return endpoint
|
|
136
|
+
}
|
|
7
137
|
|
|
138
|
+
async function readRawBody(req) {
|
|
139
|
+
const chunks = []
|
|
140
|
+
let size = 0
|
|
141
|
+
for await (const chunk of req) {
|
|
142
|
+
size += chunk.length
|
|
143
|
+
if (size > BODY_MAX) throw new Error('body too large')
|
|
144
|
+
chunks.push(chunk)
|
|
145
|
+
}
|
|
146
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readBody(req) {
|
|
150
|
+
return JSON.parse(await readRawBody(req))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function send(res, status, payload) {
|
|
154
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
155
|
+
res.end(JSON.stringify(payload))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 稳定本地入口:HTTP/WS 代理到当前 DSH web 端口(端口每次重启可能变化)。
|
|
161
|
+
* 外部 Tailscale HTTPS 只需固定指向本端口,不依赖 dsh-pocket。
|
|
162
|
+
*/
|
|
163
|
+
function attachStableProxy(ctx, handler) {
|
|
164
|
+
const dshPort = ctx.webServer?.port
|
|
165
|
+
if (!dshPort) {
|
|
166
|
+
ctx?.logger?.warn?.('dsh-pocket-ios: 拿不到 DSH web 端口,稳定入口未启动')
|
|
167
|
+
return () => {}
|
|
168
|
+
}
|
|
169
|
+
// DSH 的 browser-auth/可信 Host 校验只认 loopback:Tailscale 进来的公网 Host 必须改写,
|
|
170
|
+
// 否则 token 换完 cookie 后继续访问仍会 403(dsh-pocket 的代理同样做这件事)。
|
|
171
|
+
const forwardHeaders = (headers) => {
|
|
172
|
+
const copy = { ...headers }
|
|
173
|
+
copy.host = `127.0.0.1:${dshPort}`
|
|
174
|
+
if (typeof copy.origin === 'string') copy.origin = `http://127.0.0.1:${dshPort}`
|
|
175
|
+
if (typeof copy.referer === 'string') {
|
|
176
|
+
try {
|
|
177
|
+
const referer = new URL(copy.referer)
|
|
178
|
+
copy.referer = `http://127.0.0.1:${dshPort}${referer.pathname}${referer.search}`
|
|
179
|
+
} catch { /* 非法 Referer 直接丢弃 */ }
|
|
180
|
+
}
|
|
181
|
+
delete copy['x-forwarded-for']
|
|
182
|
+
delete copy['x-forwarded-host']
|
|
183
|
+
delete copy['x-forwarded-proto']
|
|
184
|
+
return copy
|
|
185
|
+
}
|
|
186
|
+
const server = createServer((req, res) => {
|
|
187
|
+
try {
|
|
188
|
+
if ((req.url ?? '').startsWith(`${CHANNEL}/`)) {
|
|
189
|
+
handler(req, res)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const proxyReq = httpRequest({
|
|
193
|
+
host: '127.0.0.1', port: dshPort, method: req.method,
|
|
194
|
+
path: req.url, headers: forwardHeaders(req.headers),
|
|
195
|
+
}, (proxyRes) => {
|
|
196
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
|
|
197
|
+
proxyRes.pipe(res)
|
|
198
|
+
})
|
|
199
|
+
proxyReq.on('error', () => {
|
|
200
|
+
try { res.writeHead(502, { 'content-type': 'text/plain' }); res.end('bad gateway') } catch { /* 已结束 */ }
|
|
201
|
+
})
|
|
202
|
+
req.pipe(proxyReq)
|
|
203
|
+
} catch {
|
|
204
|
+
try { res.writeHead(502); res.end() } catch { /* 已结束 */ }
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
server.on('upgrade', (req, socket, head) => {
|
|
208
|
+
const proxyReq = httpRequest({
|
|
209
|
+
host: '127.0.0.1', port: dshPort, method: req.method,
|
|
210
|
+
path: req.url, headers: forwardHeaders(req.headers),
|
|
211
|
+
})
|
|
212
|
+
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
213
|
+
const lines = [`HTTP/1.1 101 Switching Protocols`]
|
|
214
|
+
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
|
215
|
+
if (value !== undefined) lines.push(`${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
|
|
216
|
+
}
|
|
217
|
+
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
218
|
+
if (proxyHead?.length) socket.write(proxyHead)
|
|
219
|
+
if (head?.length) proxySocket.write(head)
|
|
220
|
+
proxySocket.pipe(socket)
|
|
221
|
+
socket.pipe(proxySocket)
|
|
222
|
+
})
|
|
223
|
+
proxyReq.on('error', () => socket.destroy())
|
|
224
|
+
proxyReq.end()
|
|
225
|
+
})
|
|
226
|
+
server.on('error', (error) => ctx?.logger?.warn?.(`dsh-pocket-ios: 稳定入口端口 ${PROXY_PORT} 不可用(${error?.message ?? error})`))
|
|
227
|
+
server.listen(PROXY_PORT, '127.0.0.1', () => {
|
|
228
|
+
ctx?.logger?.info?.(`dsh-pocket-ios: 稳定入口 http://127.0.0.1:${PROXY_PORT} → DSH ${dshPort}`)
|
|
229
|
+
})
|
|
230
|
+
return () => { try { server.close() } catch { /* 已关闭 */ } }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 挂载 RPC 路由。auth 用 connection.requestRejection(必须绑定 this,否则丢失 trustedHosts)。
|
|
235
|
+
* @param ctx - cordis 插件上下文。
|
|
236
|
+
*/
|
|
8
237
|
export function apply(ctx) {
|
|
9
|
-
|
|
238
|
+
const webServer = ctx?.webServer
|
|
239
|
+
const connection = ctx?.connection
|
|
240
|
+
if (!webServer || typeof webServer.register !== 'function') {
|
|
241
|
+
ctx?.logger?.warn?.('dsh-pocket-ios: webServer 不可用,Tailscale 卡片 RPC 未挂载')
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
const route = {
|
|
245
|
+
kind: 'prefix',
|
|
246
|
+
path: CHANNEL,
|
|
247
|
+
handler: async (req, res) => {
|
|
248
|
+
try {
|
|
249
|
+
const rawPath = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
|
250
|
+
const isLogin = rawPath === `${CHANNEL}/login` || rawPath === `${CHANNEL}/pair`
|
|
251
|
+
if (!isLogin && typeof connection?.requestRejection === 'function') {
|
|
252
|
+
const rejection = connection.requestRejection(req)
|
|
253
|
+
if (rejection !== undefined) {
|
|
254
|
+
send(res, rejection, { ok: false })
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const urlObject = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
259
|
+
const endpoint = endpointFromPath(urlObject.pathname)
|
|
260
|
+
if (endpoint === undefined) {
|
|
261
|
+
send(res, 404, { ok: false })
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
// 登录端点:不需要已有浏览器会话,用本插件自己的 PIN 换 DSH 的认证 cookie。
|
|
265
|
+
if (endpoint === 'pair') {
|
|
266
|
+
if (req.method !== 'POST' && req.method !== 'GET') { send(res, 405, { ok: false }); return }
|
|
267
|
+
let token = urlObject.searchParams.get('token') ?? urlObject.searchParams.get('pair') ?? ''
|
|
268
|
+
if (req.method === 'POST') {
|
|
269
|
+
const raw = await readRawBody(req)
|
|
270
|
+
const contentType = String(req.headers['content-type'] ?? '')
|
|
271
|
+
if (contentType.includes('application/json')) {
|
|
272
|
+
try { token = JSON.parse(raw)?.token ?? JSON.parse(raw)?.pair ?? token } catch { /* ignore */ }
|
|
273
|
+
} else {
|
|
274
|
+
token = new URLSearchParams(raw).get('token') ?? new URLSearchParams(raw).get('pair') ?? token
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const record = pairTokens.get(String(token))
|
|
278
|
+
if (!record || record.expiresAt < Date.now()) {
|
|
279
|
+
pairTokens.delete(String(token))
|
|
280
|
+
await logLine('pair rejected (expired/unknown)')
|
|
281
|
+
send(res, 401, { ok: false })
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
pairTokens.delete(String(token)) // 一次性
|
|
285
|
+
const host = String(req.headers.host ?? '')
|
|
286
|
+
if (!host || typeof connection?.authenticatedUrl !== 'function') { send(res, 503, { ok: false }); return }
|
|
287
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0].trim()
|
|
288
|
+
const isLoopback = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)
|
|
289
|
+
const proto = forwardedProto || (isLoopback ? 'http' : 'https')
|
|
290
|
+
const target = connection.authenticatedUrl(`${proto}://${host}/`)
|
|
291
|
+
await logLine('pair login ok')
|
|
292
|
+
res.writeHead(302, { location: target, 'cache-control': 'no-store' })
|
|
293
|
+
res.end()
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (endpoint === 'login') {
|
|
297
|
+
if (req.method !== 'POST' && req.method !== 'GET') { send(res, 405, { ok: false }); return }
|
|
298
|
+
let pinAttempt = urlObject.searchParams.get('token') ?? ''
|
|
299
|
+
if (req.method === 'POST') {
|
|
300
|
+
const raw = await readRawBody(req)
|
|
301
|
+
const contentType = String(req.headers['content-type'] ?? '')
|
|
302
|
+
if (contentType.includes('application/json')) {
|
|
303
|
+
try {
|
|
304
|
+
const body = JSON.parse(raw)
|
|
305
|
+
pinAttempt = body?.token ?? body?.pin ?? pinAttempt
|
|
306
|
+
} catch { /* 非法 JSON 按未提供处理 */ }
|
|
307
|
+
} else {
|
|
308
|
+
pinAttempt = new URLSearchParams(raw).get('token') ?? pinAttempt
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const expected = await readPin()
|
|
312
|
+
const attempt = String(pinAttempt ?? '')
|
|
313
|
+
if (attempt !== expected) {
|
|
314
|
+
await logLine(`login 401 host=${String(req.headers.host ?? '')} attemptLen=${attempt.length}`)
|
|
315
|
+
send(res, 401, { ok: false })
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
// 重定向必须回到“客户端正在使用的地址”(localhost / 局域网 / Tailscale 都可),
|
|
319
|
+
// 不能固定用配置里的 Tailscale 地址,否则局域网登录会被带到不可达的域名。
|
|
320
|
+
const host = String(req.headers.host ?? '')
|
|
321
|
+
if (!host || typeof connection?.authenticatedUrl !== 'function') {
|
|
322
|
+
send(res, 503, { ok: false })
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0].trim()
|
|
326
|
+
const isLoopback = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)
|
|
327
|
+
const proto = forwardedProto || (isLoopback ? 'http' : 'https')
|
|
328
|
+
const target = connection.authenticatedUrl(`${proto}://${host}/`)
|
|
329
|
+
res.writeHead(302, { location: target, 'cache-control': 'no-store' })
|
|
330
|
+
res.end()
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
if (req.method !== 'POST') {
|
|
334
|
+
send(res, 404, { ok: false })
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
const body = await readBody(req)
|
|
338
|
+
const rpcId = typeof body?.rpcId === 'string' ? body.rpcId : ''
|
|
339
|
+
if (endpoint === 'pair.create') {
|
|
340
|
+
const pairToken = Array.from({ length: 32 }, () => {
|
|
341
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
|
342
|
+
return chars[Math.floor(Math.random() * chars.length)]
|
|
343
|
+
}).join('')
|
|
344
|
+
pairTokens.set(pairToken, { expiresAt: Date.now() + 60_000 })
|
|
345
|
+
// 优先用 Tailscale 固定地址;否则回退请求 Host。
|
|
346
|
+
const status = await tailscaleStatus()
|
|
347
|
+
let base = status.httpsUrl
|
|
348
|
+
if (!base) {
|
|
349
|
+
const host = String(req.headers.host ?? '')
|
|
350
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0].trim()
|
|
351
|
+
const isLoopback = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host)
|
|
352
|
+
base = `${forwardedProto || (isLoopback ? 'http' : 'https')}://${host}`
|
|
353
|
+
}
|
|
354
|
+
const pairURL = `${base}/?pair=${pairToken}`
|
|
355
|
+
const qr = await QRCode.toDataURL(pairURL, { margin: 1, width: 360 })
|
|
356
|
+
await logLine('pair token created')
|
|
357
|
+
send(res, 200, { type: 'server-response', rpcId, result: { ok: true, value: { url: pairURL, qr, expiresIn: 60 } } })
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
if (endpoint === 'pin.refresh') {
|
|
361
|
+
const nextPin = String(randomInt(10_000_000, 100_000_000))
|
|
362
|
+
await setPin(nextPin)
|
|
363
|
+
await logLine('pin refreshed')
|
|
364
|
+
const value = await tailscaleStatus()
|
|
365
|
+
send(res, 200, { type: 'server-response', rpcId, result: { ok: true, value } })
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
if (endpoint === 'pin.set') {
|
|
369
|
+
const args = body?.payload?.args ?? body?.payload ?? body?.args ?? body ?? {}
|
|
370
|
+
const requested = String(args.pin ?? '')
|
|
371
|
+
if (!PIN_PATTERN.test(requested)) {
|
|
372
|
+
send(res, 200, { type: 'server-response', rpcId, result: { ok: false, error: { code: 'bad-request', message: '密码需为 8-64 位英文字母或数字' } } })
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
await setPin(requested)
|
|
376
|
+
await logLine('pin set by user')
|
|
377
|
+
const value = await tailscaleStatus()
|
|
378
|
+
send(res, 200, { type: 'server-response', rpcId, result: { ok: true, value } })
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
if (endpoint !== 'tailscale.status') {
|
|
382
|
+
send(res, 200, { type: 'server-response', rpcId, result: { ok: false, error: { code: 'bad-request', message: `unknown endpoint ${endpoint}` } } })
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
const value = await tailscaleStatus()
|
|
386
|
+
send(res, 200, { type: 'server-response', rpcId, result: { ok: true, value } })
|
|
387
|
+
} catch (error) {
|
|
388
|
+
send(res, 200, { type: 'server-response', rpcId: '', result: { ok: false, error: { code: 'bad-request', message: String(error?.message ?? error) } } })
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
}
|
|
10
392
|
try {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
ctx?.logger?.
|
|
393
|
+
webServer.register(route)
|
|
394
|
+
ctx?.logger?.info?.('dsh-pocket-ios: Tailscale RPC 已挂载 /dsh-pocket-ios/tailscale.status')
|
|
395
|
+
} catch (error) {
|
|
396
|
+
ctx?.logger?.warn?.(`dsh-pocket-ios: 挂载 RPC 失败:${String(error?.message ?? error)}`)
|
|
15
397
|
}
|
|
398
|
+
return attachStableProxy(ctx, route.handler)
|
|
16
399
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pocket-ios",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "在 DSH 设置页加入 iOS 客户端下载二维码与连接引导(配合 dsh-pocket + DSH Pocket iOS)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -30,13 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"client": {
|
|
32
32
|
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-connection",
|
|
33
34
|
"@deepseek-ai/dsh-client-ui-slots"
|
|
34
35
|
],
|
|
35
36
|
"platform": "web"
|
|
36
37
|
}
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"
|
|
40
|
+
"qrcode": "^1.5.4"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
43
|
"@deepseek-ai/cordis": "^4.0.1"
|