@goodandready/dsh-lanmode 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -28
- package/cordis.patch.yml +16 -4
- package/lib/bridge.js +92 -0
- package/lib/index.js +113 -23
- package/lib/shim.js +78 -28
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,61 +1,109 @@
|
|
|
1
1
|
# dsh-lanmode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Open the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI from another device — a phone, a tablet, the laptop on the other side of the room — and have **all of it** work, including the parts the UI switches off when the page is not `localhost`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Works whether you put a reverse proxy in front of the harness or let this plugin serve the network itself. Pick a mode, restart, done.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## What breaks without it
|
|
8
|
+
|
|
9
|
+
Reach the UI at a LAN address and you get some mix of:
|
|
10
|
+
|
|
11
|
+
- **Settings → Models**: `settings are unavailable in this browser`
|
|
12
|
+
- **Settings → Plugins → Plugin configuration**: empty, no cards at all
|
|
13
|
+
- every plugin's settings card: blank, and saving silently does nothing
|
|
14
|
+
- on plain HTTP, nothing loads at all: sessions and models never render
|
|
15
|
+
|
|
16
|
+
None of that is the server refusing. The server serves settings over the network perfectly well.
|
|
8
17
|
|
|
9
18
|
## Why it happens
|
|
10
19
|
|
|
11
|
-
|
|
20
|
+
Two independent things.
|
|
21
|
+
|
|
22
|
+
**The settings service turns itself off.** The UI decides from the page's hostname:
|
|
12
23
|
|
|
13
24
|
```js
|
|
14
|
-
isLoopback: pageLocation === undefined || isLoopbackHostname(
|
|
25
|
+
isLoopback: pageLocation === undefined || isLoopbackHostname(hostname)
|
|
15
26
|
```
|
|
16
27
|
|
|
17
|
-
`isLoopbackHostname` accepts `localhost`, `[::1]` and `127.0.0.0/8
|
|
28
|
+
`isLoopbackHostname` accepts `localhost`, `[::1]` and `127.0.0.0/8`, nothing else. Anything else puts the settings service into a process-local mode where the shared mirror of the settings document is never read, every namespace reports `status: "unavailable"` for the life of the page, and writes are dropped before they reach the wire.
|
|
18
29
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
**Some Web APIs only exist on a secure context.** `crypto.randomUUID` is called on boot-critical paths, and `navigator.clipboard` behind the copy buttons. Over plain HTTP from a LAN address the browser withholds both.
|
|
31
|
+
|
|
32
|
+
## What the plugin does
|
|
33
|
+
|
|
34
|
+
Everything happens through the web server's official index tap: one script is injected into `index.html` ahead of the boot manifest. No product source is modified, and removing the plugin restores the page exactly.
|
|
35
|
+
|
|
36
|
+
| Piece | Setting | What it does |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| Settings | `settings` | Hands the two settings packages a connection whose `isLoopback` reads `true`. Everything downstream — the shared mirror, every namespace scope, the core's own pages — then behaves as it does on localhost. |
|
|
39
|
+
| `crypto.randomUUID` | `randomUuid` | Provides an RFC 4122 v4 implementation over `crypto.getRandomValues`, which insecure origins do have. A no-op where the real one exists. |
|
|
40
|
+
| `navigator.clipboard` | `clipboard` | Provides a `writeText` fallback so the copy buttons keep working. A no-op where the real one exists. |
|
|
41
|
+
|
|
42
|
+
The settings substitution is deliberately narrow. Three packages read that flag, and the third is deliverables, where it decides whether a produced file may be opened locally — forcing it there would ask the Host to open paths on the server's desktop. Only the settings packages see the substitute.
|
|
43
|
+
|
|
44
|
+
## Two modes
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
- id: dsh-lanmode
|
|
48
|
+
config:
|
|
49
|
+
mode: proxy # proxy | direct
|
|
23
50
|
```
|
|
24
51
|
|
|
25
|
-
|
|
52
|
+
**`proxy`** (default) — something already listens on the network in front of the harness: nginx, Caddy, Tailscale serve, an SSH tunnel. The plugin only repairs the page and touches nothing else. This is the safe default: it cannot collide with whatever you already run.
|
|
26
53
|
|
|
27
|
-
|
|
54
|
+
**`direct`** — no proxy anywhere. The plugin opens a listener of its own and forwards to the harness, rewriting `Host` and `Origin` to the loopback authority so the harness's same-origin fence is satisfied:
|
|
28
55
|
|
|
29
|
-
|
|
56
|
+
```yaml
|
|
57
|
+
- id: dsh-lanmode
|
|
58
|
+
config:
|
|
59
|
+
mode: direct
|
|
60
|
+
directHost: '0.0.0.0' # every interface
|
|
61
|
+
directPort: 3088
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then open `http://<the machine's IP>:3088` from any device on the network.
|
|
65
|
+
|
|
66
|
+
A listener rather than rebinding the harness itself, for two reasons: a bind host lives in the config tree and cannot be a switch inside the plugin, and rebinding to `0.0.0.0` collides with a reverse proxy already holding that port.
|
|
67
|
+
|
|
68
|
+
Changing the mode takes effect on restart.
|
|
30
69
|
|
|
31
|
-
|
|
70
|
+
## Settings
|
|
32
71
|
|
|
33
|
-
|
|
72
|
+
All of it can be edited as the `dsh-lanmode` namespace — in `$DSH_HOME/settings.yaml`, or from the UI once the settings pages work.
|
|
34
73
|
|
|
35
|
-
|
|
74
|
+
| Setting | Default | Meaning |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `mode` | `proxy` | `proxy` or `direct` |
|
|
77
|
+
| `directHost` | `0.0.0.0` | `direct`: which address to listen on |
|
|
78
|
+
| `directPort` | `3088` | `direct`: which port to listen on |
|
|
79
|
+
| `settings` | `true` | return the settings service |
|
|
80
|
+
| `randomUuid` | `true` | provide `crypto.randomUUID` on plain HTTP |
|
|
81
|
+
| `clipboard` | `true` | provide a clipboard fallback on plain HTTP |
|
|
36
82
|
|
|
37
83
|
## Checking it
|
|
38
84
|
|
|
39
|
-
- `?lanmode=invert` — report the flag as `false` even on
|
|
40
|
-
- `?lanmode=off` — stand the plugin down entirely.
|
|
85
|
+
- `?lanmode=invert` — report the loopback flag as `false` even on localhost. The harness then fails exactly the way it does over the LAN; this is how the plugin is verified.
|
|
86
|
+
- `?lanmode=off` — stand the plugin down entirely, to see the page as it would be without it.
|
|
41
87
|
|
|
42
88
|
On a loopback page the plugin substitutes `true` where the real value is already `true`, so it cannot change behaviour there.
|
|
43
89
|
|
|
44
90
|
## What it is not
|
|
45
91
|
|
|
46
|
-
Not authentication
|
|
92
|
+
**Not authentication.** It adds no password and does not widen what the server accepts — the harness answers those same calls with or without it. `direct` mode does make the harness reachable by anyone who can reach that port, exactly as a reverse proxy would. If other people share your network, put a real gate in front of the harness. A plugin cannot do that job: the web server hands plugins their own routes and no way to intercept anyone else's.
|
|
47
93
|
|
|
48
|
-
|
|
94
|
+
**No microphone over plain HTTP.** Browsers withhold `navigator.mediaDevices` outside a secure context, so voice input plugins stop working on a plain-HTTP LAN address. Nothing can polyfill that. If you need the microphone, keep HTTPS in front and use `proxy` mode.
|
|
49
95
|
|
|
96
|
+
## Install
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
dsh plugin --profile web add @goodandready/dsh-lanmode
|
|
50
100
|
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
└── LICENSE # MIT
|
|
58
|
-
```
|
|
101
|
+
|
|
102
|
+
Restart the harness, then reload the browser.
|
|
103
|
+
|
|
104
|
+
## Credit
|
|
105
|
+
|
|
106
|
+
The index-tap approach and the `crypto.randomUUID` polyfill come from [dsh-web-lan-access](https://github.com/AcidGr/dsh-web-lan-access) (MIT), which solves the secure-context half of this problem. Several other plugins in the same space — `dsh-lan-access`, `dsh-lan`, `dsh-LAN`, `dsh-lan-gate`, `dsh-Remote` — cover binding, mobile layout and device approval; none of them return the settings service, which is what this one is for.
|
|
59
107
|
|
|
60
108
|
## License
|
|
61
109
|
|
package/cordis.patch.yml
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
# dsh-lanmode bundle layer: applied automatically when the package is installed
|
|
2
2
|
# as a profile bundle (package.json declares dsh.bundle.patch).
|
|
3
3
|
#
|
|
4
|
-
# `name` must stay the full npm package name: the
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# the
|
|
4
|
+
# `name` must stay the full npm package name: the loader entry name is how the
|
|
5
|
+
# served bundle is resolved.
|
|
6
|
+
#
|
|
7
|
+
# Everything is configurable in the `dsh-lanmode` settings namespace, so the
|
|
8
|
+
# row below stays empty. To pin the mode in the tree instead of the settings
|
|
9
|
+
# document, override it in your own profile patch:
|
|
10
|
+
#
|
|
11
|
+
# - id: dsh-lanmode
|
|
12
|
+
# config:
|
|
13
|
+
# mode: direct
|
|
14
|
+
# directPort: 3088
|
|
15
|
+
#
|
|
16
|
+
# Note there is deliberately no webserver override here. Other plugins in this
|
|
17
|
+
# space rebind the harness to 0.0.0.0; that cannot be switched from inside a
|
|
18
|
+
# plugin and collides with a reverse proxy already holding the port. Direct
|
|
19
|
+
# mode opens a listener of its own instead.
|
|
8
20
|
- insert:
|
|
9
21
|
- id: dsh-lanmode
|
|
10
22
|
name: '@goodandready/dsh-lanmode'
|
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Прямой режим: слушатель на сетевом адресе, который передаёт всё харнессу.
|
|
2
|
+
//
|
|
3
|
+
// Зачем не привязка харнесса к 0.0.0.0, как делают соседние плагины. Привязка
|
|
4
|
+
// задаётся в дереве конфигурации и меняется только перезапуском, то есть
|
|
5
|
+
// «переключателем в плагине» быть не может. Хуже того, если перед харнессом
|
|
6
|
+
// уже стоит обратный прокси на том же порту, привязка столкнётся с ним лбами.
|
|
7
|
+
// Отдельный слушатель включается и гасится вместе со строкой плагина и живёт
|
|
8
|
+
// рядом с любым прокси.
|
|
9
|
+
//
|
|
10
|
+
// Заголовки Host и Origin переписываются на локальные: харнесс пропускает
|
|
11
|
+
// запрос, только когда Origin совпадает с адресом, по которому он слушает.
|
|
12
|
+
// Это ровно то, что делает любой обратный прокси, и это же означает, что
|
|
13
|
+
// прямой режим открывает харнесс всем, кто дотянется до этого порта, — как и
|
|
14
|
+
// nginx сегодня. Пароля здесь нет и быть не может: плагин чужие маршруты не
|
|
15
|
+
// перехватывает.
|
|
16
|
+
|
|
17
|
+
import http from 'node:http'
|
|
18
|
+
|
|
19
|
+
function rewritten(headers, authority) {
|
|
20
|
+
const out = { ...headers, host: authority }
|
|
21
|
+
if (out.origin) out.origin = 'http://' + authority
|
|
22
|
+
if (out.referer) out.referer = String(out.referer).replace(/^https?:\/\/[^/]+/, 'http://' + authority)
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param ctx контекст плагина (нужен ctx.webServer.port)
|
|
28
|
+
* @param options {{host: string, port: number, log: (message: string) => void}}
|
|
29
|
+
* @returns функция остановки
|
|
30
|
+
*/
|
|
31
|
+
export function startDirectBridge(ctx, options) {
|
|
32
|
+
const upstreamPort = ctx.webServer.port
|
|
33
|
+
if (!upstreamPort) {
|
|
34
|
+
options.log('прямой режим не поднят: веб-сервер ещё не сообщил порт')
|
|
35
|
+
return () => {}
|
|
36
|
+
}
|
|
37
|
+
const authority = '127.0.0.1:' + upstreamPort
|
|
38
|
+
|
|
39
|
+
const bridge = http.createServer((req, res) => {
|
|
40
|
+
const upstream = http.request({
|
|
41
|
+
host: '127.0.0.1',
|
|
42
|
+
port: upstreamPort,
|
|
43
|
+
method: req.method,
|
|
44
|
+
path: req.url,
|
|
45
|
+
headers: rewritten(req.headers, authority),
|
|
46
|
+
}, (answer) => {
|
|
47
|
+
res.writeHead(answer.statusCode || 502, answer.headers)
|
|
48
|
+
answer.pipe(res)
|
|
49
|
+
})
|
|
50
|
+
upstream.on('error', () => {
|
|
51
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
52
|
+
res.end('dsh-lanmode: харнесс не отвечает')
|
|
53
|
+
})
|
|
54
|
+
req.pipe(upstream)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Веб-сокеты интерфейса идут через Upgrade: их надо передать сырыми.
|
|
58
|
+
bridge.on('upgrade', (req, socket, head) => {
|
|
59
|
+
const upstream = http.request({
|
|
60
|
+
host: '127.0.0.1',
|
|
61
|
+
port: upstreamPort,
|
|
62
|
+
method: req.method,
|
|
63
|
+
path: req.url,
|
|
64
|
+
headers: rewritten(req.headers, authority),
|
|
65
|
+
})
|
|
66
|
+
upstream.on('upgrade', (answer, upstreamSocket, upstreamHead) => {
|
|
67
|
+
const lines = ['HTTP/1.1 101 Switching Protocols']
|
|
68
|
+
for (const [key, value] of Object.entries(answer.headers)) lines.push(key + ': ' + value)
|
|
69
|
+
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
70
|
+
if (upstreamHead && upstreamHead.length) socket.unshift(upstreamHead)
|
|
71
|
+
upstreamSocket.pipe(socket)
|
|
72
|
+
socket.pipe(upstreamSocket)
|
|
73
|
+
const drop = () => { try { upstreamSocket.destroy() } catch (already) { /* уже мертво */ } }
|
|
74
|
+
socket.on('error', drop)
|
|
75
|
+
socket.on('close', drop)
|
|
76
|
+
})
|
|
77
|
+
upstream.on('error', () => { try { socket.destroy() } catch (already) { /* уже мертво */ } })
|
|
78
|
+
if (head && head.length) upstream.write(head)
|
|
79
|
+
upstream.end()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
bridge.on('error', (failure) => {
|
|
83
|
+
options.log('прямой режим не поднялся: ' + String(failure && failure.message || failure))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
bridge.listen(options.port, options.host, () => {
|
|
87
|
+
options.log('прямой режим: слушаю ' + options.host + ':' + options.port
|
|
88
|
+
+ ', передаю на ' + authority)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return () => { bridge.close() }
|
|
92
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,50 +1,140 @@
|
|
|
1
1
|
// dsh-lanmode — хост-половина.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Плагин возвращает веб-интерфейсу то, что он сам себе запрещает на странице,
|
|
4
|
+
// открытой не с localhost. Три независимых куска, каждый выключается отдельно.
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
+
// 1. Настройки. Интерфейс решает по имени хоста страницы:
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
+
// isLoopback: pageLocation === undefined || isLoopbackHostname(hostname)
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// плагинов» и страница «Модели» с надписью
|
|
14
|
-
//
|
|
10
|
+
// и своими считает только localhost, [::1] и 127.0.0.0/8. Дальше сервис
|
|
11
|
+
// настроек уходит в режим «памяти»: общий вид документа не читается никогда,
|
|
12
|
+
// каждый раздел получает статус "unavailable", запись выбрасывается до
|
|
13
|
+
// отправки. Наружу это выглядит как пустые карточки всех плагинов, пустая
|
|
14
|
+
// вкладка «Настройки плагинов» и страница «Модели» с надписью
|
|
15
|
+
// "settings are unavailable in this browser". Сервер это ограничение не
|
|
16
|
+
// разделяет: describe и mutate по сети отвечают штатно.
|
|
15
17
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
18
|
+
// 2. crypto.randomUUID. Существует только на защищённом соединении, а
|
|
19
|
+
// интерфейс зовёт его на пути загрузки. На чистом HTTP по сетевому адресу
|
|
20
|
+
// его нет, и каждый вызов к серверу падает. Приём и заготовка заплатки
|
|
21
|
+
// взяты у dsh-web-lan-access (AcidGr), MIT.
|
|
19
22
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// 3. navigator.clipboard. Тоже только для защищённого соединения — без него
|
|
24
|
+
// кнопки «Копировать» молчат. Подставляем запасной путь.
|
|
25
|
+
//
|
|
26
|
+
// Всё это вставляется в index.html через официальную точку webServer.tapIndex.
|
|
27
|
+
//
|
|
28
|
+
// Чего здесь намеренно НЕТ — привязки к 0.0.0.0. Она нужна тем, кто ходит на
|
|
29
|
+
// харнесс напрямую, и ставится отдельным слоем в cordis.patch.yml профиля
|
|
30
|
+
// (образец — в нашем cordis.patch.yml). Включать её вслепую нельзя: если
|
|
31
|
+
// перед харнессом стоит обратный прокси на том же порту, они столкнутся.
|
|
25
32
|
|
|
33
|
+
import z from '@deepseek-ai/schemastery'
|
|
26
34
|
import { readFileSync } from 'node:fs'
|
|
27
35
|
import path from 'node:path'
|
|
28
36
|
import { fileURLToPath } from 'node:url'
|
|
37
|
+
import { startDirectBridge } from './bridge.js'
|
|
29
38
|
|
|
30
39
|
export const name = 'dsh-lanmode'
|
|
31
40
|
export const inject = ['webServer']
|
|
32
41
|
|
|
42
|
+
export const Config = z.object({
|
|
43
|
+
mode: z
|
|
44
|
+
.string()
|
|
45
|
+
.description('How the browser reaches the harness. '
|
|
46
|
+
+ '"proxy": something in front of it already listens on the network (nginx and friends) — '
|
|
47
|
+
+ 'the plugin only repairs the page. '
|
|
48
|
+
+ '"direct": the plugin also opens a listener of its own on the network and forwards to the '
|
|
49
|
+
+ 'harness, so nothing else is needed.')
|
|
50
|
+
.default('proxy'),
|
|
51
|
+
directHost: z
|
|
52
|
+
.string()
|
|
53
|
+
.description('mode=direct: which address to listen on. 0.0.0.0 means every interface.')
|
|
54
|
+
.default('0.0.0.0'),
|
|
55
|
+
directPort: z
|
|
56
|
+
.number()
|
|
57
|
+
.description('mode=direct: which port to listen on. Keep it clear of whatever else is running.')
|
|
58
|
+
.default(3088),
|
|
59
|
+
settings: z
|
|
60
|
+
.boolean()
|
|
61
|
+
.description('Return the settings service on pages that are not localhost. '
|
|
62
|
+
+ 'This is the part no other plugin does.')
|
|
63
|
+
.default(true),
|
|
64
|
+
randomUuid: z
|
|
65
|
+
.boolean()
|
|
66
|
+
.description('Provide crypto.randomUUID where the browser withholds it (plain HTTP). '
|
|
67
|
+
+ 'A no-op on HTTPS and on localhost.')
|
|
68
|
+
.default(true),
|
|
69
|
+
clipboard: z
|
|
70
|
+
.boolean()
|
|
71
|
+
.description('Provide a fallback for navigator.clipboard.writeText on plain HTTP, '
|
|
72
|
+
+ 'so the copy buttons keep working. A no-op where the real one exists.')
|
|
73
|
+
.default(true),
|
|
74
|
+
})
|
|
75
|
+
|
|
33
76
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
34
77
|
|
|
35
|
-
/**
|
|
78
|
+
/** Заплатка лежит рядом обычным файлом: так её видно и правится она как код. */
|
|
36
79
|
function shimSource() {
|
|
37
80
|
return readFileSync(path.join(here, 'shim.js'), 'utf8')
|
|
38
81
|
}
|
|
39
82
|
|
|
40
|
-
|
|
41
|
-
|
|
83
|
+
/** Namespace, который плагин объявляет: через него режим правится настройками. */
|
|
84
|
+
const NS = 'dsh-lanmode'
|
|
85
|
+
|
|
86
|
+
export function apply(ctx, config) {
|
|
87
|
+
// Значения берём из сервиса настроек, если он есть: тогда режим правится
|
|
88
|
+
// в настройках, а не только в дереве плагинов. Смена режима требует
|
|
89
|
+
// перезапуска — слушатель поднимается один раз при старте.
|
|
90
|
+
ctx.inject(['settings'], (sctx) => {
|
|
91
|
+
let effective = config
|
|
92
|
+
try {
|
|
93
|
+
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
94
|
+
effective = scope.get() ?? config
|
|
95
|
+
} catch (alreadyRegistered) {
|
|
96
|
+
effective = config
|
|
97
|
+
}
|
|
98
|
+
start(sctx, effective)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Без сервиса настроек тоже должно работать — тогда только дерево плагинов.
|
|
102
|
+
ctx.effect(() => {
|
|
103
|
+
if (ctx.get && ctx.get('settings')) return () => {}
|
|
104
|
+
start(ctx, config)
|
|
105
|
+
return () => {}
|
|
106
|
+
}, 'dsh-lanmode: запуск без сервиса настроек')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function start(ctx, config) {
|
|
110
|
+
// Прямой режим: свой слушатель на сетевом адресе. В режиме прокси не
|
|
111
|
+
// поднимаем ничего — сеть уже обслуживает кто-то другой.
|
|
112
|
+
if (config.mode === 'direct') {
|
|
113
|
+
ctx.effect(() => startDirectBridge(ctx, {
|
|
114
|
+
host: config.directHost || '0.0.0.0',
|
|
115
|
+
port: config.directPort || 3088,
|
|
116
|
+
// eslint-disable-next-line no-console
|
|
117
|
+
log: (message) => console.info('[dsh-lanmode] ' + message),
|
|
118
|
+
}), 'dsh-lanmode: слушатель прямого режима')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pieces = {
|
|
122
|
+
settings: config.settings !== false,
|
|
123
|
+
randomUuid: config.randomUuid !== false,
|
|
124
|
+
clipboard: config.clipboard !== false,
|
|
125
|
+
}
|
|
126
|
+
// Ничего не включено — и вставлять нечего.
|
|
127
|
+
if (!pieces.settings && !pieces.randomUuid && !pieces.clipboard) return
|
|
128
|
+
|
|
129
|
+
const script = '<script data-dsh-lanmode="1">'
|
|
130
|
+
+ 'window.__DSH_LANMODE__=' + JSON.stringify(pieces) + ';'
|
|
131
|
+
+ shimSource()
|
|
132
|
+
+ '</script>'
|
|
42
133
|
|
|
43
134
|
ctx.effect(() => ctx.webServer.tapIndex((html) => {
|
|
44
135
|
if (html.includes('data-dsh-lanmode')) return html
|
|
45
|
-
// Сразу за <head>:
|
|
46
|
-
//
|
|
47
|
-
// существовать — заплатка это учитывает.
|
|
136
|
+
// Сразу за <head>: заплатка должна отработать раньше всего остального,
|
|
137
|
+
// иначе заглушка приедет после первого же вызова, который её ждёт.
|
|
48
138
|
const at = html.indexOf('<head>')
|
|
49
139
|
if (at === -1) return script + html
|
|
50
140
|
return html.slice(0, at + '<head>'.length) + script + html.slice(at + '<head>'.length)
|
package/lib/shim.js
CHANGED
|
@@ -1,29 +1,85 @@
|
|
|
1
1
|
(function () {
|
|
2
|
-
// Заплатка выполняется
|
|
2
|
+
// Заплатка выполняется первой на странице, до загрузки чего бы то ни было.
|
|
3
|
+
// Что включено — решает хост-половина, значения приезжают в __DSH_LANMODE__.
|
|
4
|
+
var options = window.__DSH_LANMODE__ || {}
|
|
5
|
+
|
|
6
|
+
// Отладочные переключатели в адресной строке:
|
|
7
|
+
// ?lanmode=off — не вмешиваться совсем (посмотреть, как без плагина);
|
|
8
|
+
// ?lanmode=invert — притвориться чужой страницей на своей же
|
|
9
|
+
// (так проверяется, что заплатка правда управляет флагом).
|
|
10
|
+
var mode = ''
|
|
11
|
+
try { mode = new URLSearchParams(location.search).get('lanmode') || '' } catch (noSearch) { mode = '' }
|
|
12
|
+
if (mode === 'off') return
|
|
13
|
+
var loopbackAnswer = mode !== 'invert'
|
|
14
|
+
|
|
15
|
+
// ------------------------------------------------------- crypto.randomUUID
|
|
3
16
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
17
|
+
// Приём и заготовка — из dsh-web-lan-access (AcidGr), MIT. Существует только
|
|
18
|
+
// на защищённом соединении, а интерфейс зовёт его при загрузке: на чистом
|
|
19
|
+
// HTTP по сетевому адресу без этого не поднимается вообще ничего.
|
|
20
|
+
if (options.randomUuid) {
|
|
21
|
+
var crypto_ = globalThis.crypto
|
|
22
|
+
if (!crypto_) { try { crypto_ = globalThis.crypto = {} } catch (frozen) { crypto_ = null } }
|
|
23
|
+
if (crypto_ && typeof crypto_.randomUUID !== 'function' && typeof crypto_.getRandomValues === 'function') {
|
|
24
|
+
crypto_.randomUUID = function randomUUID() {
|
|
25
|
+
var bytes = new Uint8Array(16)
|
|
26
|
+
crypto_.getRandomValues(bytes)
|
|
27
|
+
bytes[6] = (bytes[6] & 15) | 64
|
|
28
|
+
bytes[8] = (bytes[8] & 63) | 128
|
|
29
|
+
var hex = ''
|
|
30
|
+
for (var i = 0; i < 16; i++) hex += bytes[i].toString(16).padStart(2, '0')
|
|
31
|
+
return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16)
|
|
32
|
+
+ '-' + hex.slice(16, 20) + '-' + hex.slice(20)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ------------------------------------------------------ navigator.clipboard
|
|
38
|
+
//
|
|
39
|
+
// Тоже только для защищённого соединения. Без него кнопки «Копировать»
|
|
40
|
+
// молча ничего не делают; подставляем старый способ через скрытое поле.
|
|
41
|
+
if (options.clipboard) {
|
|
42
|
+
var nav = window.navigator
|
|
43
|
+
if (nav && (!nav.clipboard || typeof nav.clipboard.writeText !== 'function')) {
|
|
44
|
+
var writeText = function (text) {
|
|
45
|
+
return new Promise(function (resolve, reject) {
|
|
46
|
+
try {
|
|
47
|
+
var area = document.createElement('textarea')
|
|
48
|
+
area.value = String(text)
|
|
49
|
+
area.setAttribute('readonly', '')
|
|
50
|
+
area.style.position = 'fixed'
|
|
51
|
+
area.style.opacity = '0'
|
|
52
|
+
document.body.appendChild(area)
|
|
53
|
+
area.select()
|
|
54
|
+
var copied = document.execCommand('copy')
|
|
55
|
+
document.body.removeChild(area)
|
|
56
|
+
copied ? resolve() : reject(new Error('copy rejected'))
|
|
57
|
+
} catch (failure) { reject(failure) }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
if (nav.clipboard) nav.clipboard.writeText = writeText
|
|
62
|
+
else Object.defineProperty(nav, 'clipboard', { configurable: true, value: { writeText: writeText } })
|
|
63
|
+
} catch (cannotDefine) { /* останется как было */ }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ------------------------------------------------------------- настройки
|
|
68
|
+
if (!options.settings) return
|
|
69
|
+
|
|
70
|
+
// Флаг читают трое, и третьему подменять нельзя: в результатах работы он
|
|
71
|
+
// решает, можно ли открыть файл локально, и с подменой браузер просил бы
|
|
72
|
+
// открыть путь на машине сервера. Поэтому — только два настроечных пакета.
|
|
8
73
|
var TARGETS = [
|
|
9
74
|
'@deepseek-ai/dsh-client-ui-settings',
|
|
10
75
|
'@deepseek-ai/dsh-client-ui-settings-general',
|
|
11
76
|
]
|
|
12
77
|
|
|
13
|
-
// Отладочные переключатели через адресную строку:
|
|
14
|
-
// ?lanmode=off — не вмешиваться (проверить, как было без плагина);
|
|
15
|
-
// ?lanmode=invert — притвориться чужим на своей же странице
|
|
16
|
-
// (так проверяется, что заплатка вообще управляет флагом).
|
|
17
|
-
var mode = ''
|
|
18
|
-
try { mode = new URLSearchParams(location.search).get('lanmode') || '' } catch (noSearch) { mode = '' }
|
|
19
|
-
if (mode === 'off') return
|
|
20
|
-
var forced = mode === 'invert' ? false : true
|
|
21
|
-
|
|
22
78
|
function connectionWithForcedFlag(connection) {
|
|
23
79
|
if (!connection || typeof connection !== 'object') return connection
|
|
24
80
|
return new Proxy(connection, {
|
|
25
|
-
get: function (target, prop
|
|
26
|
-
if (prop === 'isLoopback') return
|
|
81
|
+
get: function (target, prop) {
|
|
82
|
+
if (prop === 'isLoopback') return loopbackAnswer
|
|
27
83
|
var value = Reflect.get(target, prop, target)
|
|
28
84
|
return typeof value === 'function' ? value.bind(target) : value
|
|
29
85
|
},
|
|
@@ -32,7 +88,7 @@
|
|
|
32
88
|
|
|
33
89
|
function ctxWithForcedConnection(ctx) {
|
|
34
90
|
return new Proxy(ctx, {
|
|
35
|
-
get: function (target, prop
|
|
91
|
+
get: function (target, prop) {
|
|
36
92
|
if (prop === 'get') {
|
|
37
93
|
return function (nameRequested) {
|
|
38
94
|
var value = target.get(nameRequested)
|
|
@@ -64,11 +120,12 @@
|
|
|
64
120
|
return patched
|
|
65
121
|
}
|
|
66
122
|
|
|
67
|
-
// Загрузчик подменяет собственный load, когда переходит из режима очереди
|
|
68
|
-
//
|
|
123
|
+
// Загрузчик подменяет собственный load, когда переходит из режима очереди в
|
|
124
|
+
// рабочий, — обёртка при этом слетала бы. Поэтому ставим её через
|
|
69
125
|
// свойство-аксессор: любая новая функция оборачивается снова.
|
|
70
126
|
function install(loader) {
|
|
71
127
|
if (!loader || typeof loader !== 'object') return loader
|
|
128
|
+
if (loader.load && loader.load.__dshLanmode) return loader
|
|
72
129
|
var wrapped
|
|
73
130
|
|
|
74
131
|
function rewrap(fn) {
|
|
@@ -78,7 +135,6 @@
|
|
|
78
135
|
wrapped.__dshLanmode = true
|
|
79
136
|
}
|
|
80
137
|
|
|
81
|
-
if (loader.load && loader.load.__dshLanmode) return loader
|
|
82
138
|
rewrap(loader.load)
|
|
83
139
|
try {
|
|
84
140
|
Object.defineProperty(loader, 'load', {
|
|
@@ -96,15 +152,9 @@
|
|
|
96
152
|
loader.pendingQueue[i] = wrap(loader.pendingQueue[i])
|
|
97
153
|
}
|
|
98
154
|
}
|
|
99
|
-
try {
|
|
100
|
-
console.info('[dsh-lanmode] настройки будут работать на этой странице'
|
|
101
|
-
+ (forced ? '' : ' (перевёрнутый режим: притворяемся чужим)'))
|
|
102
|
-
} catch (noConsole) { /* незачем */ }
|
|
103
155
|
return loader
|
|
104
156
|
}
|
|
105
157
|
|
|
106
|
-
// Загрузчик объявляется встроенным скриптом страницы. Если он уже есть —
|
|
107
|
-
// оборачиваем сразу; если нет — дожидаемся присваивания.
|
|
108
158
|
if (window.__ModuleLoader__) {
|
|
109
159
|
install(window.__ModuleLoader__)
|
|
110
160
|
return
|
|
@@ -117,7 +167,7 @@
|
|
|
117
167
|
set: function (value) { held = install(value) },
|
|
118
168
|
})
|
|
119
169
|
} catch (cannotDefine) {
|
|
120
|
-
// Свойство неподатливо — молчим:
|
|
121
|
-
//
|
|
170
|
+
// Свойство неподатливо — молчим: страница останется такой же, какой была
|
|
171
|
+
// без плагина.
|
|
122
172
|
}
|
|
123
173
|
})()
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-lanmode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "LAN and reverse-proxy access for the DeepSeek Harness Web UI: returns the settings service on pages that are not localhost, fills in the Web APIs the browser withholds on plain HTTP, and can open a listener of its own so nothing else is needed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"deepseek-harness",
|
|
23
23
|
"settings",
|
|
24
24
|
"lan",
|
|
25
|
-
"remote"
|
|
25
|
+
"remote",
|
|
26
|
+
"proxy",
|
|
27
|
+
"nginx"
|
|
26
28
|
],
|
|
27
29
|
"repository": {
|
|
28
30
|
"type": "git",
|