@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/cordis.patch.yml +16 -0
- package/lib/auth.mjs +180 -0
- package/lib/client.js +219 -0
- package/lib/control.mjs +226 -0
- package/lib/desktop-api.mjs +102 -0
- package/lib/gateway-client.mjs +236 -0
- package/lib/gateway.mjs +319 -0
- package/lib/index.mjs +246 -0
- package/lib/preview.mjs +149 -0
- package/lib/push.mjs +173 -0
- package/lib/relay-tunnel.mjs +256 -0
- package/lib/sessions.mjs +131 -0
- package/lib/state.mjs +187 -0
- package/lib/tls-store.mjs +107 -0
- package/lib/upstream.mjs +61 -0
- package/lib/vendor/qrcode.mjs +2291 -0
- package/lib/web-sessions.mjs +113 -0
- package/lib/x509.mjs +228 -0
- package/package.json +63 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-mobile-bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **English summary.** DeepSeek Harness (DSH) host plugin for the DSH Mobile Android app. It serves its own HTTPS gateway for the phone (private CA, pairing QR from a sidebar button, WebView proxy of the stock DSH Web UI), joins the DSH Gateway event stream as an in-process owner client so approvals and questions in the live desktop session can be answered from the phone, and pushes approval requests, questions and turn completion through a relay over an outbound WebSocket. No third-party plugin is required. Requires DSH `>=0.2.1-rc.2 <0.3.0`.
|
|
4
|
+
>
|
|
5
|
+
> Install from the DSH **Plugins** page, or: `dsh plugin --profile desktop add @kinocode/dsh-mobile-bridge`
|
|
6
|
+
>
|
|
7
|
+
> The npm package is scoped because the unscoped name `dsh-mobile-bridge` belongs to an unrelated package. The source repository is private; the rest of this README is in Japanese.
|
|
8
|
+
|
|
9
|
+
npm パッケージ名: `@kinocode/dsh-mobile-bridge`(DSH 内の拡張 ID とログの表示名は `dsh-mobile-bridge`)。ソースの GitHub リポジトリは非公開です。
|
|
10
|
+
|
|
11
|
+
DeepSeek Harness のホストプラグインです。DSH Mobile(Android)から、デスクトップと同じ生きたセッションの承認・質問に答えられるようにし、承認待ち・質問・ターン完了をプッシュします。
|
|
12
|
+
|
|
13
|
+
設計: [../docs/ADR-0001-architecture.md](../docs/ADR-0001-architecture.md) / 通信仕様: [../docs/PROTOCOL.md](../docs/PROTOCOL.md) / 導入: [../docs/SETUP.md](../docs/SETUP.md)
|
|
14
|
+
|
|
15
|
+
## しくみ
|
|
16
|
+
|
|
17
|
+
- **接続口**: PC のプライベート IPv4(LAN と Tailscale)で HTTPS を開き(既定 3543、自前 CA)、ペアリング・直結の制御 API・DSH 純正 Web 画面の中継を行います。DSH 自身の認証は接続口がサーバー側で持ち、スマホには渡しません。
|
|
18
|
+
- **ペアリング**: DSH のサイドバー左下の「スマホを接続」で QR(CA 指紋・一回限りのコード・接続先)を出します。端末一覧と削除も同じパネルです。
|
|
19
|
+
- DSH Gateway の転送イベント(`$events`)に、プロセス内の owner クライアントとして参加します。GUI のウィンドウがもう一つ増えたのと同じ扱いです。
|
|
20
|
+
- `approval/request` / `user-questions/request` のリスナーは登録しません。承認を横取りすることも、GUI を飛ばすこともありません。
|
|
21
|
+
- 回答は `$events/result` で返します。Gateway が最初の回答だけを採用し、ほかの画面(デスクトップ GUI、WebView)のカードを閉じます。
|
|
22
|
+
- 承認リクエストにはコマンド本文がないため、`session/event` の `tool/call` を `callId` で引いてプレビューを作ります。
|
|
23
|
+
- スマホからの制御リクエストは、リレーへの外向き WebSocket で受け取り、端末鍵の署名(ECDSA P-256)を検証します。
|
|
24
|
+
|
|
25
|
+
## 依存
|
|
26
|
+
|
|
27
|
+
- DeepSeek Harness 0.2.1-rc.2 以上 0.3 未満(Gateway の `wireStream` / `$events/result` は公開前の内部 API なので、範囲を固定しています)
|
|
28
|
+
- DSH の Web サーバー(`webserver`)。接続口はここから画面を中継します
|
|
29
|
+
- Node 22.4 以上の `WebSocket`(Electron 版 DSH に含まれます)
|
|
30
|
+
|
|
31
|
+
## 設定
|
|
32
|
+
|
|
33
|
+
[SETUP.md §2.2](../docs/SETUP.md) を参照してください。すべて任意です。
|
|
34
|
+
|
|
35
|
+
## ブラウザ側
|
|
36
|
+
|
|
37
|
+
`lib/client.js` は DSH Web に読み込まれ、(1) サイドバーに「スマホを接続」ボタンを足して QR と端末一覧のパネルを出し、(2) アプリから `dsh-mobile-bridge:open-session` イベントを受けたときに `uiWorkspace.openSession()` を呼びます(通知タップで該当セッションを開くため)。QR の生成には同梱の qrcode-generator(MIT)を使います。
|
|
38
|
+
|
|
39
|
+
## テスト
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
node --test test/*.test.mjs
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 既知の制限
|
|
46
|
+
|
|
47
|
+
- Gateway の内部 API に依存しています。DSH 0.3 で変わった場合は、`/connect` + `/api/remote.mux` を使う外部クライアント方式へ切り替えます(ADR §4)。
|
|
48
|
+
- セッション名は `session/title` イベントと Session Controller の一覧から取ります。どちらにもない新しいセッションは「DeepSeek Harness」と表示します。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @kinocode/dsh-mobile-bridge bundle layer. Every field is optional. To enable push and notification
|
|
2
|
+
# actions, add this to your profile's cordis.patch.yml (a profile patch replaces the whole
|
|
3
|
+
# config, so list every field you want to keep):
|
|
4
|
+
#
|
|
5
|
+
# - id: mobile-bridge
|
|
6
|
+
# config:
|
|
7
|
+
# relayUrl: https://dsh-relay.example.com
|
|
8
|
+
# relayEnrollToken: '...'
|
|
9
|
+
#
|
|
10
|
+
# Other fields: stateDir (default $DSH_HOME/mobile-bridge), gatewayEnabled (default true),
|
|
11
|
+
# gatewayPort (default 3543), gatewayHosts (fixed IPv4 listen addresses instead of auto-detection),
|
|
12
|
+
# hostLabel (default: computer name), previewMaxChars (default 400).
|
|
13
|
+
- insert:
|
|
14
|
+
- id: mobile-bridge
|
|
15
|
+
name: '@kinocode/dsh-mobile-bridge'
|
|
16
|
+
config: {}
|
package/lib/auth.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Device authentication for control requests (docs/PROTOCOL.md §2-§3).
|
|
2
|
+
|
|
3
|
+
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
export const SIGNATURE_VERSION = 'DSHMB1'
|
|
6
|
+
export const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
|
7
|
+
export const NONCE_TTL_MS = 10 * 60 * 1000
|
|
8
|
+
export const REGISTRATION_CODE_TTL_MS = 300 * 1000
|
|
9
|
+
|
|
10
|
+
const DEVICE_ID = /^[a-f0-9]{32}$/
|
|
11
|
+
const NONCE = /^[A-Za-z0-9_-]{22,128}$/
|
|
12
|
+
const B64URL = /^[A-Za-z0-9_-]+$/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the exact string a device signs.
|
|
16
|
+
* @param {{method: string, path: string, hostId: string, deviceId: string, timestamp: string, nonce: string, body: Buffer|string}} input
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function canonicalString({ method, path, hostId, deviceId, timestamp, nonce, body }) {
|
|
20
|
+
const digest = createHash('sha256').update(body ?? '').digest('hex')
|
|
21
|
+
return [SIGNATURE_VERSION, method.toUpperCase(), path, hostId, deviceId, timestamp, nonce, digest].join('\n')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse and validate an SPKI DER (base64url) EC P-256 public key.
|
|
26
|
+
* @param {string} encoded
|
|
27
|
+
* @returns {import('node:crypto').KeyObject}
|
|
28
|
+
*/
|
|
29
|
+
export function parseDevicePublicKey(encoded) {
|
|
30
|
+
if (typeof encoded !== 'string' || encoded.length < 80 || encoded.length > 400 || !B64URL.test(encoded)) {
|
|
31
|
+
throw new AuthError(400, 'invalid_request', 'publicKey must be SPKI DER base64url')
|
|
32
|
+
}
|
|
33
|
+
let key
|
|
34
|
+
try {
|
|
35
|
+
key = createPublicKey({ key: Buffer.from(encoded, 'base64url'), format: 'der', type: 'spki' })
|
|
36
|
+
} catch {
|
|
37
|
+
throw new AuthError(400, 'invalid_request', 'publicKey is not a valid SPKI key')
|
|
38
|
+
}
|
|
39
|
+
if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
|
|
40
|
+
throw new AuthError(400, 'invalid_request', 'publicKey must be EC P-256')
|
|
41
|
+
}
|
|
42
|
+
return key
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Typed failure mapped to an HTTP status and stable error code. */
|
|
46
|
+
export class AuthError extends Error {
|
|
47
|
+
/**
|
|
48
|
+
* @param {number} status
|
|
49
|
+
* @param {string} code
|
|
50
|
+
* @param {string} [message]
|
|
51
|
+
*/
|
|
52
|
+
constructor(status, code, message = code) {
|
|
53
|
+
super(message)
|
|
54
|
+
this.status = status
|
|
55
|
+
this.code = code
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Short-lived nonce memory that rejects replays inside the clock-skew window. */
|
|
60
|
+
export class NonceCache {
|
|
61
|
+
constructor(now = Date.now) {
|
|
62
|
+
this.now = now
|
|
63
|
+
/** @type {Map<string, number>} */
|
|
64
|
+
this.seen = new Map()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Record a nonce; returns false when it was already seen.
|
|
69
|
+
* @param {string} key
|
|
70
|
+
*/
|
|
71
|
+
remember(key) {
|
|
72
|
+
const now = this.now()
|
|
73
|
+
if (this.seen.size > 10_000 || this.seen.size % 256 === 0) {
|
|
74
|
+
for (const [k, expiry] of this.seen) if (expiry <= now) this.seen.delete(k)
|
|
75
|
+
}
|
|
76
|
+
const existing = this.seen.get(key)
|
|
77
|
+
if (existing !== undefined && existing > now) return false
|
|
78
|
+
this.seen.set(key, now + NONCE_TTL_MS)
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One-time pairing codes shown in the desktop QR. */
|
|
84
|
+
export class RegistrationCodes {
|
|
85
|
+
constructor(now = Date.now, ttlMs = REGISTRATION_CODE_TTL_MS) {
|
|
86
|
+
this.now = now
|
|
87
|
+
this.ttlMs = ttlMs
|
|
88
|
+
/** @type {Map<string, {owner: string, expiresAt: number}>} */
|
|
89
|
+
this.codes = new Map()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Issue a code. Issuing again for the same `owner` invalidates that owner's earlier unused
|
|
94
|
+
* code, so only the QR currently on screen works.
|
|
95
|
+
* @param {string} owner
|
|
96
|
+
* @returns {{code: string, expiresAt: number}}
|
|
97
|
+
*/
|
|
98
|
+
issue(owner) {
|
|
99
|
+
this.prune()
|
|
100
|
+
for (const [digest, entry] of this.codes) if (entry.owner === owner) this.codes.delete(digest)
|
|
101
|
+
if (this.codes.size >= 64) throw new AuthError(429, 'too_many_codes')
|
|
102
|
+
const code = randomBytes(32).toString('base64url')
|
|
103
|
+
const expiresAt = this.now() + this.ttlMs
|
|
104
|
+
this.codes.set(digestOf(code), { owner, expiresAt })
|
|
105
|
+
return { code, expiresAt }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Consume a code exactly once.
|
|
110
|
+
* @param {unknown} code
|
|
111
|
+
* @returns {string} the owner the code was issued for
|
|
112
|
+
*/
|
|
113
|
+
consume(code) {
|
|
114
|
+
if (typeof code !== 'string' || code.length !== 43 || !B64URL.test(code)) throw new AuthError(401, 'invalid_code')
|
|
115
|
+
this.prune()
|
|
116
|
+
const digest = digestOf(code)
|
|
117
|
+
const entry = this.codes.get(digest)
|
|
118
|
+
if (entry === undefined) throw new AuthError(401, 'invalid_code')
|
|
119
|
+
this.codes.delete(digest)
|
|
120
|
+
return entry.owner
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
prune() {
|
|
124
|
+
const now = this.now()
|
|
125
|
+
for (const [digest, entry] of this.codes) if (entry.expiresAt <= now) this.codes.delete(digest)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function digestOf(value) {
|
|
130
|
+
return createHash('sha256').update(value).digest('hex')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Verify one signed control request.
|
|
135
|
+
* @param {object} input
|
|
136
|
+
* @param {string} input.method
|
|
137
|
+
* @param {string} input.path
|
|
138
|
+
* @param {Record<string, string|undefined>} input.headers - lower-case header names
|
|
139
|
+
* @param {Buffer|string} input.body
|
|
140
|
+
* @param {string} input.hostId
|
|
141
|
+
* @param {(deviceId: string) => {publicKey: string, revoked?: boolean} | undefined} input.lookup
|
|
142
|
+
* @param {NonceCache} input.nonces
|
|
143
|
+
* @param {() => number} [input.now]
|
|
144
|
+
* @returns {string} the authenticated device id
|
|
145
|
+
*/
|
|
146
|
+
export function verifySignedRequest({ method, path, headers, body, hostId, lookup, nonces, now = Date.now }) {
|
|
147
|
+
const deviceId = headers['x-dshmb-device']
|
|
148
|
+
const timestamp = headers['x-dshmb-timestamp']
|
|
149
|
+
const nonce = headers['x-dshmb-nonce']
|
|
150
|
+
const signature = headers['x-dshmb-signature']
|
|
151
|
+
if (typeof deviceId !== 'string' || !DEVICE_ID.test(deviceId)) throw new AuthError(401, 'unknown_device')
|
|
152
|
+
if (typeof timestamp !== 'string' || !/^\d{12,16}$/.test(timestamp)) throw new AuthError(401, 'stale_request')
|
|
153
|
+
if (typeof nonce !== 'string' || !NONCE.test(nonce)) throw new AuthError(401, 'bad_signature')
|
|
154
|
+
if (typeof signature !== 'string' || signature.length > 200 || !B64URL.test(signature)) throw new AuthError(401, 'bad_signature')
|
|
155
|
+
const device = lookup(deviceId)
|
|
156
|
+
if (device === undefined || device.revoked === true) throw new AuthError(401, 'unknown_device')
|
|
157
|
+
if (Math.abs(now() - Number(timestamp)) > MAX_CLOCK_SKEW_MS) throw new AuthError(401, 'stale_request')
|
|
158
|
+
const data = Buffer.from(canonicalString({ method, path, hostId, deviceId, timestamp, nonce, body }), 'utf8')
|
|
159
|
+
let ok = false
|
|
160
|
+
try {
|
|
161
|
+
ok = verify('sha256', data, { key: parseDevicePublicKey(device.publicKey), dsaEncoding: 'der' }, Buffer.from(signature, 'base64url'))
|
|
162
|
+
} catch {
|
|
163
|
+
ok = false
|
|
164
|
+
}
|
|
165
|
+
if (!ok) throw new AuthError(401, 'bad_signature')
|
|
166
|
+
// Only a correctly signed request consumes its nonce, so garbage cannot evict real ones.
|
|
167
|
+
if (!nonces.remember(`${deviceId}:${nonce}`)) throw new AuthError(401, 'replayed_request')
|
|
168
|
+
return deviceId
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Constant-time string comparison for secrets of equal expected length.
|
|
173
|
+
* @param {string} a
|
|
174
|
+
* @param {string} b
|
|
175
|
+
*/
|
|
176
|
+
export function safeEqual(a, b) {
|
|
177
|
+
const left = Buffer.from(a)
|
|
178
|
+
const right = Buffer.from(b)
|
|
179
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
180
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// dsh-mobile-bridge — browser half, loaded into the DSH Web UI.
|
|
2
|
+
// 1. Adds a sidebar footer button 「スマホを接続」 that shows the pairing QR and the paired phones
|
|
3
|
+
// (data from the owner-only /api/dsh-mobile-bridge/* routes; docs/PROTOCOL.md v2 §2.1).
|
|
4
|
+
// 2. Lets the DSH Mobile app open a session: the app's page script dispatches
|
|
5
|
+
// `dsh-mobile-bridge:open-session` and this calls the Client Workspace service.
|
|
6
|
+
window.__ModuleLoader__.load({
|
|
7
|
+
id: "@kinocode/dsh-mobile-bridge",
|
|
8
|
+
factory: (require) => {
|
|
9
|
+
var module = { exports: {} };
|
|
10
|
+
var exports = module.exports;
|
|
11
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
12
|
+
|
|
13
|
+
const react = require("react");
|
|
14
|
+
const inject = ["slots"];
|
|
15
|
+
const SESSION_ID = /^[A-Za-z0-9_:.-]{1,200}$/;
|
|
16
|
+
const API = "/api/dsh-mobile-bridge";
|
|
17
|
+
const PANEL_ID = "dsh-mobile-bridge-panel";
|
|
18
|
+
|
|
19
|
+
const STYLE = `
|
|
20
|
+
#${PANEL_ID}{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.45);font:14px/1.6 system-ui,-apple-system,"Hiragino Sans",sans-serif}
|
|
21
|
+
#${PANEL_ID} .dmb-card{background:#fff;color:#1a1a1a;border-radius:16px;width:min(420px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;padding:20px 22px;box-shadow:0 20px 60px rgba(0,0,0,.25)}
|
|
22
|
+
#${PANEL_ID} h2{font-size:17px;margin:0 0 4px}
|
|
23
|
+
#${PANEL_ID} .dmb-sub{color:#666;font-size:12.5px;margin:0 0 14px}
|
|
24
|
+
#${PANEL_ID} .dmb-qr{display:flex;justify-content:center;background:#fff;border-radius:12px;padding:8px;margin:6px 0 8px}
|
|
25
|
+
#${PANEL_ID} .dmb-qr svg{width:260px;height:260px}
|
|
26
|
+
#${PANEL_ID} .dmb-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 0;border-top:1px solid #eee}
|
|
27
|
+
#${PANEL_ID} .dmb-muted{color:#777;font-size:12px}
|
|
28
|
+
#${PANEL_ID} button{font:inherit;border-radius:8px;border:1px solid #ccc;background:#f6f6f6;padding:5px 12px;cursor:pointer}
|
|
29
|
+
#${PANEL_ID} button.dmb-primary{background:#0e6e62;border-color:#0e6e62;color:#fff}
|
|
30
|
+
#${PANEL_ID} .dmb-err{color:#b00020;margin:8px 0}
|
|
31
|
+
#${PANEL_ID} .dmb-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}
|
|
32
|
+
@media (prefers-color-scheme: dark){#${PANEL_ID} .dmb-card{background:#1f1f1f;color:#eee}#${PANEL_ID} .dmb-row{border-color:#333}#${PANEL_ID} button{background:#2b2b2b;border-color:#444;color:#eee}#${PANEL_ID} .dmb-sub,#${PANEL_ID} .dmb-muted{color:#aaa}}
|
|
33
|
+
.dmb-trigger{display:flex;align-items:center;gap:8px;width:100%;border:0;background:transparent;color:inherit;font:inherit;padding:6px 10px;border-radius:8px;cursor:pointer}
|
|
34
|
+
.dmb-trigger:hover{background:rgba(127,127,127,.12)}
|
|
35
|
+
.dmb-trigger.is-rail{justify-content:center;padding:8px 0}`;
|
|
36
|
+
|
|
37
|
+
function el(tag, attrs, children) {
|
|
38
|
+
const node = document.createElement(tag);
|
|
39
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
40
|
+
if (k === "text") node.textContent = v;
|
|
41
|
+
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
|
42
|
+
else node.setAttribute(k, v);
|
|
43
|
+
}
|
|
44
|
+
for (const child of children || []) if (child) node.append(child);
|
|
45
|
+
return node;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function api(path, init) {
|
|
49
|
+
// Same shape as DSH's own connection RPC (no `cache` option: the desktop app forwards
|
|
50
|
+
// `/api` through its dsh-app:// protocol handler; the routes already send no-store).
|
|
51
|
+
let response;
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(API + path, Object.assign({ headers: { "content-type": "application/json" } }, init || {}));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error("[dsh-mobile-bridge] fetch failed", API + path, e);
|
|
56
|
+
throw new Error("通信できませんでした(" + (e && e.message ? e.message : String(e)) + ")");
|
|
57
|
+
}
|
|
58
|
+
let body = {};
|
|
59
|
+
try { body = await response.json(); } catch (e) { /* empty body */ }
|
|
60
|
+
if (!response.ok) throw new Error(body && typeof body.error === "string" ? body.error : "HTTP " + response.status);
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatTime(ms) {
|
|
65
|
+
if (!ms) return "—";
|
|
66
|
+
try { return new Date(ms).toLocaleString("ja-JP", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch (e) { return "—"; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ERRORS = {
|
|
70
|
+
gateway_not_listening: "接続口が起動していません。Wi-Fi か Tailscale に接続しているか確認してください。",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function openPanel() {
|
|
74
|
+
if (document.getElementById(PANEL_ID)) return;
|
|
75
|
+
let timer = 0;
|
|
76
|
+
const qrBox = el("div", { class: "dmb-qr" }, [el("span", { class: "dmb-muted", text: "QR を準備しています…" })]);
|
|
77
|
+
let currentLink = "";
|
|
78
|
+
const copyButton = el("button", { type: "button", text: "リンクをコピー", title: "カメラが使えないときは、このリンクをアプリの「URL を貼り付け」に入れてください", onclick: async () => {
|
|
79
|
+
if (!currentLink) return;
|
|
80
|
+
try {
|
|
81
|
+
await navigator.clipboard.writeText(currentLink);
|
|
82
|
+
copyButton.textContent = "コピーしました";
|
|
83
|
+
setTimeout(() => { copyButton.textContent = "リンクをコピー"; }, 2000);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
window.prompt("このリンクをコピーしてください", currentLink);
|
|
86
|
+
}
|
|
87
|
+
} });
|
|
88
|
+
const expiry = el("p", { class: "dmb-muted" });
|
|
89
|
+
const error = el("p", { class: "dmb-err" });
|
|
90
|
+
const devices = el("div");
|
|
91
|
+
const status = el("p", { class: "dmb-muted" });
|
|
92
|
+
const close = () => { clearInterval(timer); overlay.remove(); document.removeEventListener("keydown", onKey); };
|
|
93
|
+
const onKey = (e) => { if (e.key === "Escape") close(); };
|
|
94
|
+
const card = el("div", { class: "dmb-card", role: "dialog", "aria-modal": "true", "aria-label": "スマホを接続" }, [
|
|
95
|
+
el("h2", { text: "スマホを接続" }),
|
|
96
|
+
el("p", { class: "dmb-sub", text: "DSH Mobile アプリの「コンピューターを追加」でこの QR を読み取ってください。QR は 5 分間・1 回だけ使えます。" }),
|
|
97
|
+
qrBox, expiry, error, status,
|
|
98
|
+
el("h2", { text: "接続中の端末", style: "margin-top:16px;font-size:15px" }),
|
|
99
|
+
devices,
|
|
100
|
+
el("div", { class: "dmb-actions" }, [
|
|
101
|
+
copyButton,
|
|
102
|
+
el("button", { type: "button", text: "新しい QR", onclick: () => refreshQr() }),
|
|
103
|
+
el("button", { type: "button", class: "dmb-primary", text: "閉じる", onclick: close }),
|
|
104
|
+
]),
|
|
105
|
+
]);
|
|
106
|
+
const overlay = el("div", { id: PANEL_ID, onclick: (e) => { if (e.target === overlay) close(); } }, [card]);
|
|
107
|
+
document.body.append(overlay);
|
|
108
|
+
document.addEventListener("keydown", onKey);
|
|
109
|
+
|
|
110
|
+
async function refreshQr() {
|
|
111
|
+
error.textContent = "";
|
|
112
|
+
try {
|
|
113
|
+
const pairing = await api("/pairing", { method: "POST", body: "{}" });
|
|
114
|
+
qrBox.innerHTML = pairing.qrSvg;
|
|
115
|
+
currentLink = pairing.qr;
|
|
116
|
+
const until = pairing.expiresAt;
|
|
117
|
+
clearInterval(timer);
|
|
118
|
+
const tick = () => {
|
|
119
|
+
const left = Math.max(0, Math.round((until - Date.now()) / 1000));
|
|
120
|
+
expiry.textContent = left > 0 ? "有効期限まで " + Math.floor(left / 60) + " 分 " + (left % 60) + " 秒(接続先: " + pairing.origins.join(", ") + ")" : "期限切れです。「新しい QR」を押してください。";
|
|
121
|
+
if (left === 0) clearInterval(timer);
|
|
122
|
+
};
|
|
123
|
+
tick();
|
|
124
|
+
timer = setInterval(tick, 1000);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
qrBox.textContent = "";
|
|
127
|
+
error.textContent = ERRORS[e.message] || ("QR を作れませんでした: " + e.message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function refreshDevices() {
|
|
132
|
+
try {
|
|
133
|
+
const [list, st] = await Promise.all([api("/devices"), api("/status")]);
|
|
134
|
+
status.textContent = "PC 名: " + st.hostLabel + " / 通知: " + (st.relay.configured ? (st.relay.connected ? "リレー接続中" : "リレー未接続") : "リレー未設定(アプリ内の承認のみ)");
|
|
135
|
+
devices.replaceChildren();
|
|
136
|
+
if (list.devices.length === 0) devices.append(el("p", { class: "dmb-muted", text: "まだ端末はありません。" }));
|
|
137
|
+
for (const d of list.devices) {
|
|
138
|
+
devices.append(el("div", { class: "dmb-row" }, [
|
|
139
|
+
el("div", {}, [
|
|
140
|
+
el("div", { text: d.label || "Android" }),
|
|
141
|
+
el("div", { class: "dmb-muted", text: "最終接続 " + formatTime(d.lastSeenAt) + (d.hasPush ? " ・ 通知あり" : " ・ 通知なし") }),
|
|
142
|
+
]),
|
|
143
|
+
el("button", { type: "button", text: "削除", onclick: async () => {
|
|
144
|
+
if (!window.confirm((d.label || "この端末") + " の接続を削除しますか?")) return;
|
|
145
|
+
try { await api("/devices/revoke", { method: "POST", body: JSON.stringify({ deviceId: d.deviceId }) }); } catch (e) { error.textContent = "削除できませんでした: " + e.message; }
|
|
146
|
+
refreshDevices();
|
|
147
|
+
} }),
|
|
148
|
+
]));
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
error.textContent = "状態を取得できませんでした: " + e.message;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
refreshQr();
|
|
156
|
+
refreshDevices();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function PhoneIcon(size) {
|
|
160
|
+
return react.createElement("svg", { "aria-hidden": true, width: size, height: size, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" },
|
|
161
|
+
react.createElement("rect", { x: 4, y: 1.5, width: 8, height: 13, rx: 1.6 }),
|
|
162
|
+
react.createElement("path", { d: "M7 12.2h2" }));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function Trigger(props) {
|
|
166
|
+
const wide = props && props.wide;
|
|
167
|
+
return react.createElement("button", {
|
|
168
|
+
type: "button",
|
|
169
|
+
className: "dmb-trigger" + (wide ? "" : " is-rail"),
|
|
170
|
+
title: "スマホを接続",
|
|
171
|
+
"aria-label": "スマホを接続",
|
|
172
|
+
onClick: openPanel,
|
|
173
|
+
}, PhoneIcon(wide ? 16 : 18), wide ? react.createElement("span", null, "スマホを接続") : null);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param ctx - client root context.
|
|
178
|
+
*/
|
|
179
|
+
function apply(ctx) {
|
|
180
|
+
ctx.effect(() => {
|
|
181
|
+
const style = document.createElement("style");
|
|
182
|
+
style.dataset.plugin = "dsh-mobile-bridge";
|
|
183
|
+
style.textContent = STYLE;
|
|
184
|
+
document.head.append(style);
|
|
185
|
+
const onOpen = (event) => {
|
|
186
|
+
const sessionId = event && event.detail && event.detail.sessionId;
|
|
187
|
+
if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId)) return;
|
|
188
|
+
const workspace = ctx.get("uiWorkspace");
|
|
189
|
+
try {
|
|
190
|
+
if (workspace) workspace.openSession(sessionId);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.warn("[dsh-mobile-bridge] openSession failed", error && error.message);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
window.addEventListener("dsh-mobile-bridge:open-session", onOpen);
|
|
196
|
+
window.__dshMobileBridgeClient = 1;
|
|
197
|
+
return () => {
|
|
198
|
+
window.removeEventListener("dsh-mobile-bridge:open-session", onOpen);
|
|
199
|
+
if (window.__dshMobileBridgeClient === 1) delete window.__dshMobileBridgeClient;
|
|
200
|
+
style.remove();
|
|
201
|
+
const panel = document.getElementById(PANEL_ID);
|
|
202
|
+
if (panel) panel.remove();
|
|
203
|
+
};
|
|
204
|
+
}, "mobile-bridge: page hooks");
|
|
205
|
+
|
|
206
|
+
// The pairing button only makes sense on the desktop, not inside the phone's WebView.
|
|
207
|
+
if (navigator.userAgent.indexOf("DSHMobile/") === -1) {
|
|
208
|
+
ctx.effect(() => ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
|
|
209
|
+
name: "sidebar.footer.action",
|
|
210
|
+
id: "dsh-mobile-bridge",
|
|
211
|
+
}, Trigger)), "mobile-bridge: sidebar button");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
exports.apply = apply;
|
|
216
|
+
exports.inject = inject;
|
|
217
|
+
return module.exports;
|
|
218
|
+
}
|
|
219
|
+
});
|