miki-moni 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/README.zh-CN.md +275 -0
- package/README.zh-TW.md +275 -0
- package/bin/miki.mjs +49 -0
- package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
- package/dist/web/assets/index--89DkyV1.css +1 -0
- package/dist/web/assets/index-CyPlxvOn.js +64 -0
- package/dist/web/index.html +20 -0
- package/dist/web/pair-info.html +138 -0
- package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
- package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
- package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
- package/dist/web-phone/index.html +20 -0
- package/hooks/miki-emit.ps1 +56 -0
- package/package.json +89 -0
- package/shared/i18n.ts +915 -0
- package/src/cli/i18n-cli.ts +149 -0
- package/src/cli/miki.ts +168 -0
- package/src/cli/pair.ts +534 -0
- package/src/cli/prompt.ts +6 -0
- package/src/cli/pushable-iter.ts +45 -0
- package/src/cli/setup-self-host.ts +292 -0
- package/src/cli/setup-wizard.ts +130 -0
- package/src/cli/wrap.ts +742 -0
- package/src/config.ts +121 -0
- package/src/crypto.ts +66 -0
- package/src/data-dir.ts +31 -0
- package/src/ext-registry.ts +47 -0
- package/src/hook-handler.ts +86 -0
- package/src/index.ts +279 -0
- package/src/install-hooks.ts +107 -0
- package/src/notifier.ts +21 -0
- package/src/pairing.ts +100 -0
- package/src/protocol-ext.ts +46 -0
- package/src/relay-client.ts +468 -0
- package/src/relay-protocol.ts +57 -0
- package/src/server.ts +1134 -0
- package/src/session-resolver.ts +437 -0
- package/src/session-store.ts +131 -0
- package/src/types.ts +33 -0
- package/src/vscode-bridge.ts +407 -0
- package/src/wrap-process.ts +183 -0
- package/tools/tray.ps1 +286 -0
- package/worker/package.json +24 -0
- package/worker/src/daemon-relay.ts +348 -0
- package/worker/src/env.ts +11 -0
- package/worker/src/handshake.ts +63 -0
- package/worker/src/index.ts +81 -0
- package/worker/src/pairing-code.ts +39 -0
- package/worker/src/pairing-coordinator.ts +145 -0
- package/worker/wrangler-selfhost.toml +36 -0
- package/worker/wrangler.toml +29 -0
package/tools/tray.ps1
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
<#
|
|
2
|
+
miki-moni system-tray helper.
|
|
3
|
+
|
|
4
|
+
Spawned by the daemon (src/index.ts) on Windows. Owns a NotifyIcon in the
|
|
5
|
+
system tray for as long as the daemon process is alive — when the daemon
|
|
6
|
+
PID disappears, this helper exits and the icon vanishes.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
-DaemonPid <int> PID to monitor. When it exits, this script exits too.
|
|
10
|
+
-Port <int> Daemon HTTP port (used by the "Open dashboard" menu).
|
|
11
|
+
|
|
12
|
+
The cat icon is rendered at runtime via GDI+ — same path data as the SVG
|
|
13
|
+
in web/assets/miki-cat.svg, just translated to GraphicsPath calls. No
|
|
14
|
+
external icon file needed, so the daemon stays "single binary"-ish.
|
|
15
|
+
#>
|
|
16
|
+
|
|
17
|
+
param(
|
|
18
|
+
[Parameter(Mandatory = $true)][int]$DaemonPid,
|
|
19
|
+
[Parameter(Mandatory = $false)][int]$Port = 8765
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
$ErrorActionPreference = 'Stop'
|
|
23
|
+
|
|
24
|
+
# Write all output to ~/.miki-moni/tray.log so silent failures can be
|
|
25
|
+
# diagnosed post-mortem. Append so successive spawns don't clobber history.
|
|
26
|
+
$trayLogPath = Join-Path $env:USERPROFILE ".miki-moni\tray.log"
|
|
27
|
+
try {
|
|
28
|
+
$logDir = Split-Path $trayLogPath -Parent
|
|
29
|
+
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
|
|
30
|
+
Start-Transcript -Path $trayLogPath -Append -Force | Out-Null
|
|
31
|
+
Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] tray.ps1 starting (DaemonPid=$DaemonPid, Port=$Port, PSVersion=$($PSVersionTable.PSVersion))"
|
|
32
|
+
} catch { }
|
|
33
|
+
|
|
34
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
35
|
+
Add-Type -AssemblyName System.Drawing
|
|
36
|
+
|
|
37
|
+
# ── Render the sleeping-cat icon ────────────────────────────────────────
|
|
38
|
+
# SVG coords are in a 24×24 viewBox; we render to 32×32 (standard tray icon
|
|
39
|
+
# size) and rely on GDI+ antialiasing for the curves. GetHicon() returns an
|
|
40
|
+
# HICON that NotifyIcon can own; we wrap it in System.Drawing.Icon.
|
|
41
|
+
function New-CatIcon {
|
|
42
|
+
$size = 32
|
|
43
|
+
$scale = $size / 24.0
|
|
44
|
+
$bmp = New-Object System.Drawing.Bitmap $size, $size
|
|
45
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
46
|
+
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
|
47
|
+
$g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
|
|
48
|
+
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
49
|
+
|
|
50
|
+
$color = [System.Drawing.Color]::FromArgb(255, 28, 32, 36)
|
|
51
|
+
$pen = New-Object System.Drawing.Pen $color, ([float](1.6 * $scale))
|
|
52
|
+
$pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
53
|
+
$pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
54
|
+
$pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
|
|
55
|
+
|
|
56
|
+
function P([double]$x, [double]$y) {
|
|
57
|
+
New-Object System.Drawing.PointF ([float]($x * $scale)), ([float]($y * $scale))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# ── Body + two ear peaks (one continuous closed path) ────────────────
|
|
61
|
+
$body = New-Object System.Drawing.Drawing2D.GraphicsPath
|
|
62
|
+
# M 4.5,13.2 → L peaks → close with Beziers
|
|
63
|
+
$body.AddLine((P 4.5 13.2), (P 6 10))
|
|
64
|
+
$body.AddLine((P 6 10), (P 7.6 13))
|
|
65
|
+
$body.AddLine((P 7.6 13), (P 9.6 11))
|
|
66
|
+
$body.AddLine((P 9.6 11), (P 11.4 13))
|
|
67
|
+
# C 14.5,13 18.5,13.6 20,15.8
|
|
68
|
+
$body.AddBezier((P 11.4 13), (P 14.5 13), (P 18.5 13.6), (P 20 15.8))
|
|
69
|
+
# C 20.8,17 20.4,18.3 18.6,18.7
|
|
70
|
+
$body.AddBezier((P 20 15.8), (P 20.8 17), (P 20.4 18.3), (P 18.6 18.7))
|
|
71
|
+
# C 16,19.2 7,19.2 5.4,18.4
|
|
72
|
+
$body.AddBezier((P 18.6 18.7), (P 16 19.2), (P 7 19.2), (P 5.4 18.4))
|
|
73
|
+
# C 3.8,17.5 3.6,14.5 4.5,13.2
|
|
74
|
+
$body.AddBezier((P 5.4 18.4), (P 3.8 17.5), (P 3.6 14.5), (P 4.5 13.2))
|
|
75
|
+
$body.CloseFigure()
|
|
76
|
+
$g.DrawPath($pen, $body)
|
|
77
|
+
|
|
78
|
+
# ── Closed sleeping eye: q 0.9,0.7 1.8,0 starting at (6.8, 15.2) ─────
|
|
79
|
+
# SVG quadratic q → endpoint at (6.8+1.8, 15.2+0) = (8.6, 15.2)
|
|
80
|
+
# Convert quadratic Bezier (P0, C, P2) to cubic for GDI+:
|
|
81
|
+
# CP1 = P0 + 2/3 (C - P0); CP2 = P2 + 2/3 (C - P2)
|
|
82
|
+
# P0=(6.8,15.2) C=(7.7,15.9) P2=(8.6,15.2)
|
|
83
|
+
$eyePen = New-Object System.Drawing.Pen $color, ([float](1.3 * $scale))
|
|
84
|
+
$eyePen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
85
|
+
$eyePen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
86
|
+
$eye = New-Object System.Drawing.Drawing2D.GraphicsPath
|
|
87
|
+
# CP1 = (6.8 + 2/3*(7.7-6.8), 15.2 + 2/3*(15.9-15.2)) = (7.4, 15.667)
|
|
88
|
+
# CP2 = (8.6 + 2/3*(7.7-8.6), 15.2 + 2/3*(15.9-15.2)) = (8.0, 15.667)
|
|
89
|
+
$eye.AddBezier((P 6.8 15.2), (P 7.4 15.667), (P 8.0 15.667), (P 8.6 15.2))
|
|
90
|
+
$g.DrawPath($eyePen, $eye)
|
|
91
|
+
|
|
92
|
+
# ── Two Z's drifting up: each is "h L h" — three line segments ──────
|
|
93
|
+
$zPen1 = New-Object System.Drawing.Pen $color, ([float](1.3 * $scale))
|
|
94
|
+
$zPen1.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
95
|
+
$zPen1.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
96
|
+
$zPen1.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
|
|
97
|
+
$z1 = New-Object System.Drawing.Drawing2D.GraphicsPath
|
|
98
|
+
# M 15,7 h 2.4 l -2.4,2.6 h 2.4 → (15,7)→(17.4,7)→(15,9.6)→(17.4,9.6)
|
|
99
|
+
$z1.AddLine((P 15 7), (P 17.4 7))
|
|
100
|
+
$z1.AddLine((P 17.4 7), (P 15 9.6))
|
|
101
|
+
$z1.AddLine((P 15 9.6), (P 17.4 9.6))
|
|
102
|
+
$g.DrawPath($zPen1, $z1)
|
|
103
|
+
|
|
104
|
+
$zPen2 = New-Object System.Drawing.Pen $color, ([float](1.1 * $scale))
|
|
105
|
+
$zPen2.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
106
|
+
$zPen2.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
|
107
|
+
$zPen2.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
|
|
108
|
+
$z2 = New-Object System.Drawing.Drawing2D.GraphicsPath
|
|
109
|
+
# M 18.4,4.5 h 1.6 l -1.6,1.8 h 1.6 → (18.4,4.5)→(20,4.5)→(18.4,6.3)→(20,6.3)
|
|
110
|
+
$z2.AddLine((P 18.4 4.5), (P 20 4.5))
|
|
111
|
+
$z2.AddLine((P 20 4.5), (P 18.4 6.3))
|
|
112
|
+
$z2.AddLine((P 18.4 6.3), (P 20 6.3))
|
|
113
|
+
$g.DrawPath($zPen2, $z2)
|
|
114
|
+
|
|
115
|
+
$g.Dispose()
|
|
116
|
+
$hicon = $bmp.GetHicon()
|
|
117
|
+
$icon = [System.Drawing.Icon]::FromHandle($hicon)
|
|
118
|
+
return @{ Icon = $icon; Bitmap = $bmp; Handle = $hicon }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# ── Build the NotifyIcon ────────────────────────────────────────────────
|
|
122
|
+
$catIcon = New-CatIcon
|
|
123
|
+
$notify = New-Object System.Windows.Forms.NotifyIcon
|
|
124
|
+
$notify.Icon = $catIcon.Icon
|
|
125
|
+
$notify.Text = "miki-moni · running · pid $DaemonPid"
|
|
126
|
+
$notify.Visible = $true
|
|
127
|
+
|
|
128
|
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
129
|
+
$openItem = $menu.Items.Add("Open dashboard")
|
|
130
|
+
$pairItem = $menu.Items.Add("Show pairing QR")
|
|
131
|
+
$rotateItem = $menu.Items.Add("Rotate pairing token (new QR / password)")
|
|
132
|
+
$restartItem = $menu.Items.Add("Restart daemon")
|
|
133
|
+
$null = $menu.Items.Add("-") # separator
|
|
134
|
+
$quitItem = $menu.Items.Add("Quit daemon")
|
|
135
|
+
|
|
136
|
+
$openItem.Add_Click({
|
|
137
|
+
Start-Process "http://127.0.0.1:$Port" | Out-Null
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
# Read pair_token + worker_url + phone_pwa_url from config.json and open the
|
|
141
|
+
# static pair-info.html page that the daemon serves out of dist/web/. The page
|
|
142
|
+
# renders the QR in-browser, no server endpoint needed.
|
|
143
|
+
$pairItem.Add_Click({
|
|
144
|
+
try {
|
|
145
|
+
$cfgPath = Join-Path $env:USERPROFILE ".miki-moni\config.json"
|
|
146
|
+
if (-not (Test-Path $cfgPath)) {
|
|
147
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
148
|
+
"config.json not found at $cfgPath. Run ``miki setup`` first.",
|
|
149
|
+
"miki-moni", "OK", "Warning") | Out-Null
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
# PS 5.1 defaults to system ANSI codepage; force UTF-8 or Chinese device
|
|
153
|
+
# names (and any non-ASCII path) break ConvertFrom-Json with a cryptic
|
|
154
|
+
# "傳入了無效的物件,必須有 ':' 或 '}'" error.
|
|
155
|
+
$cfg = Get-Content -Raw -Encoding UTF8 $cfgPath | ConvertFrom-Json
|
|
156
|
+
$token = $cfg.remote.pair_token
|
|
157
|
+
$relay = $cfg.remote.worker_url
|
|
158
|
+
$pwa = if ($cfg.remote.phone_pwa_url) { $cfg.remote.phone_pwa_url } else { "https://miki-moni.pages.dev/" }
|
|
159
|
+
if (-not $token -or -not $relay) {
|
|
160
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
161
|
+
"No pair_token or worker_url in config. Run ``miki pair`` to create one.",
|
|
162
|
+
"miki-moni", "OK", "Warning") | Out-Null
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
Add-Type -AssemblyName System.Web
|
|
166
|
+
$q = "t=" + [System.Web.HttpUtility]::UrlEncode($token) +
|
|
167
|
+
"&r=" + [System.Web.HttpUtility]::UrlEncode($relay) +
|
|
168
|
+
"&pwa=" + [System.Web.HttpUtility]::UrlEncode($pwa)
|
|
169
|
+
Start-Process "http://127.0.0.1:$Port/pair-info.html?$q" | Out-Null
|
|
170
|
+
} catch {
|
|
171
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
172
|
+
"Could not open pairing page: $_",
|
|
173
|
+
"miki-moni", "OK", "Error") | Out-Null
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
$rotateItem.Add_Click({
|
|
178
|
+
# Confirm before rotating — old QR / URL / 16-char code immediately invalid.
|
|
179
|
+
# Already-paired phones still work (signing key, not token, re-auths them).
|
|
180
|
+
$confirm = [System.Windows.Forms.MessageBox]::Show(
|
|
181
|
+
"Generate a new pairing token? The current QR / URL / 16-char code will stop working immediately. Already-paired phones are unaffected.",
|
|
182
|
+
"miki-moni · rotate pairing token",
|
|
183
|
+
"OKCancel", "Question")
|
|
184
|
+
if ($confirm -ne "OK") { return }
|
|
185
|
+
try {
|
|
186
|
+
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/admin/rotate-pair" `
|
|
187
|
+
-Method Post -TimeoutSec 5 -ContentType "application/json"
|
|
188
|
+
$token = $r.token
|
|
189
|
+
$relay = $r.worker_url
|
|
190
|
+
$pwa = if ($r.phone_pwa_url) { $r.phone_pwa_url } else { "https://miki-moni.pages.dev/" }
|
|
191
|
+
Add-Type -AssemblyName System.Web
|
|
192
|
+
$q = "t=" + [System.Web.HttpUtility]::UrlEncode($token) +
|
|
193
|
+
"&r=" + [System.Web.HttpUtility]::UrlEncode($relay) +
|
|
194
|
+
"&pwa=" + [System.Web.HttpUtility]::UrlEncode($pwa)
|
|
195
|
+
# Daemon restarts itself after rotate so RelayClient re-registers the new
|
|
196
|
+
# token. Give it ~2s to come back up, then open the new QR page.
|
|
197
|
+
Start-Sleep -Seconds 2
|
|
198
|
+
Start-Process "http://127.0.0.1:$Port/pair-info.html?$q" | Out-Null
|
|
199
|
+
} catch {
|
|
200
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
201
|
+
"Rotate failed: $_",
|
|
202
|
+
"miki-moni", "OK", "Error") | Out-Null
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
$restartItem.Add_Click({
|
|
207
|
+
# POST /admin/restart so the daemon can decide how to relaunch itself.
|
|
208
|
+
# Best-effort: fire and forget; if the endpoint doesn't exist we just
|
|
209
|
+
# exit and rely on `miki claude` to respawn on next use.
|
|
210
|
+
try {
|
|
211
|
+
Invoke-WebRequest -Uri "http://127.0.0.1:$Port/admin/restart" `
|
|
212
|
+
-Method Post -UseBasicParsing -TimeoutSec 2 | Out-Null
|
|
213
|
+
} catch { }
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
$quitItem.Add_Click({
|
|
217
|
+
try {
|
|
218
|
+
Invoke-WebRequest -Uri "http://127.0.0.1:$Port/admin/quit" `
|
|
219
|
+
-Method Post -UseBasicParsing -TimeoutSec 2 | Out-Null
|
|
220
|
+
} catch { }
|
|
221
|
+
# If the daemon ignored /admin/quit, fall back to killing the PID directly.
|
|
222
|
+
# Use the latest watched pid (in case daemon respawned since tray launch).
|
|
223
|
+
Start-Sleep -Milliseconds 800
|
|
224
|
+
$pidToKill = if ($script:DaemonPidWatched) { $script:DaemonPidWatched } else { $DaemonPid }
|
|
225
|
+
if (Get-Process -Id $pidToKill -ErrorAction SilentlyContinue) {
|
|
226
|
+
Stop-Process -Id $pidToKill -Force -ErrorAction SilentlyContinue
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
# Single-click also opens the dashboard — matches most tray-app conventions.
|
|
231
|
+
$notify.Add_MouseClick({
|
|
232
|
+
param($sender, $e)
|
|
233
|
+
if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
|
|
234
|
+
Start-Process "http://127.0.0.1:$Port" | Out-Null
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
$notify.ContextMenuStrip = $menu
|
|
238
|
+
|
|
239
|
+
# ── PID watcher ─────────────────────────────────────────────────────────
|
|
240
|
+
# Poll the watched PID once per second. When it dies, give a grace window
|
|
241
|
+
# for a replacement daemon (rotate / restart respawns) to come up — query
|
|
242
|
+
# /admin/pid and if a NEW daemon is alive on the same port, follow it.
|
|
243
|
+
# Only exit if the daemon stays gone for `$maxGraceTicks` consecutive polls.
|
|
244
|
+
# A WinForms Timer fires on the UI thread so we can touch $notify safely.
|
|
245
|
+
$script:DaemonPidWatched = $DaemonPid
|
|
246
|
+
$script:GraceTicks = 0
|
|
247
|
+
$script:MaxGraceTicks = 10 # ~10s window for respawn
|
|
248
|
+
$timer = New-Object System.Windows.Forms.Timer
|
|
249
|
+
$timer.Interval = 1000
|
|
250
|
+
$timer.Add_Tick({
|
|
251
|
+
if (Get-Process -Id $script:DaemonPidWatched -ErrorAction SilentlyContinue) {
|
|
252
|
+
$script:GraceTicks = 0
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
# Watched pid is gone — see if a replacement daemon answered /admin/pid.
|
|
256
|
+
try {
|
|
257
|
+
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/admin/pid" -TimeoutSec 1
|
|
258
|
+
if ($r.pid -and $r.pid -ne $script:DaemonPidWatched) {
|
|
259
|
+
$script:DaemonPidWatched = [int]$r.pid
|
|
260
|
+
$notify.Text = "miki-moni · running · pid $($script:DaemonPidWatched)"
|
|
261
|
+
$script:GraceTicks = 0
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
# daemon unreachable — keep counting toward grace deadline
|
|
266
|
+
}
|
|
267
|
+
$script:GraceTicks++
|
|
268
|
+
if ($script:GraceTicks -ge $script:MaxGraceTicks) {
|
|
269
|
+
$timer.Stop()
|
|
270
|
+
$notify.Visible = $false
|
|
271
|
+
$notify.Dispose()
|
|
272
|
+
[System.Windows.Forms.Application]::Exit()
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
$timer.Start()
|
|
276
|
+
|
|
277
|
+
# Run the WinForms message pump. Application.Exit() above breaks out cleanly.
|
|
278
|
+
try {
|
|
279
|
+
[System.Windows.Forms.Application]::Run()
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
$timer.Stop()
|
|
283
|
+
if ($notify) { $notify.Visible = $false; $notify.Dispose() }
|
|
284
|
+
if ($catIcon.Icon) { $catIcon.Icon.Dispose() }
|
|
285
|
+
if ($catIcon.Bitmap) { $catIcon.Bitmap.Dispose() }
|
|
286
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "miki-relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Cloudflare Worker + Durable Objects for miki-moni E2E relay",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"deploy": "wrangler deploy",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:watch": "vitest",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"tweetnacl": "^1.0.3",
|
|
15
|
+
"tweetnacl-util": "^0.15.1"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@cloudflare/vitest-pool-workers": "^0.7.0",
|
|
19
|
+
"@cloudflare/workers-types": "^4.20250515.0",
|
|
20
|
+
"typescript": "^5.6.2",
|
|
21
|
+
"vitest": "^2.0.5",
|
|
22
|
+
"wrangler": "^3.85.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { Env } from "./env.js";
|
|
2
|
+
import {
|
|
3
|
+
generateChallenge,
|
|
4
|
+
buildChallengeMessage,
|
|
5
|
+
toBase64,
|
|
6
|
+
fromBase64,
|
|
7
|
+
deriveDaemonId,
|
|
8
|
+
CHALLENGE_TTL_MS,
|
|
9
|
+
type Challenge,
|
|
10
|
+
} from "./handshake.js";
|
|
11
|
+
import nacl from "tweetnacl";
|
|
12
|
+
|
|
13
|
+
interface DaemonAttachment {
|
|
14
|
+
role: "daemon";
|
|
15
|
+
pubkey_b64: string; // Ed25519 signing pubkey (challenge-response + daemon_id derivation)
|
|
16
|
+
enc_pubkey_b64?: string; // X25519 encryption pubkey (sent to phones for ECDH in pair_init)
|
|
17
|
+
challenge?: Challenge;
|
|
18
|
+
authed: boolean;
|
|
19
|
+
daemon_id: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PhoneAttachment {
|
|
23
|
+
role: "phone";
|
|
24
|
+
phone_id: string; // signing pubkey b64 (reconnect-mode auth + revoke_self routing)
|
|
25
|
+
peer_id?: string; // computePeerId(encryption_pubkey) — addresses daemon→phone envelopes
|
|
26
|
+
pairing_token?: string;
|
|
27
|
+
authed: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Attachment = DaemonAttachment | PhoneAttachment;
|
|
31
|
+
|
|
32
|
+
export class DaemonRelay implements DurableObject {
|
|
33
|
+
constructor(private state: DurableObjectState, private env: Env) {}
|
|
34
|
+
|
|
35
|
+
async fetch(req: Request): Promise<Response> {
|
|
36
|
+
const url = new URL(req.url);
|
|
37
|
+
if (url.pathname.endsWith("/v1/daemon")) return this.acceptDaemon(req);
|
|
38
|
+
if (url.pathname.endsWith("/v1/phone")) return this.acceptPhone(req);
|
|
39
|
+
return new Response("not_found", { status: 404 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async acceptDaemon(req: Request): Promise<Response> {
|
|
43
|
+
const pubkey_b64 = req.headers.get("X-Daemon-Pubkey");
|
|
44
|
+
if (!pubkey_b64) return new Response("missing X-Daemon-Pubkey", { status: 400 });
|
|
45
|
+
let pubkey: Uint8Array;
|
|
46
|
+
try {
|
|
47
|
+
pubkey = fromBase64(pubkey_b64);
|
|
48
|
+
if (pubkey.length !== 32) throw new Error("bad length");
|
|
49
|
+
} catch {
|
|
50
|
+
return new Response("bad X-Daemon-Pubkey", { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
// X-Daemon-Enc-Pubkey: daemon's X25519 encryption pubkey. Required for
|
|
53
|
+
// phones to derive a working shared secret in pair_offer — without this
|
|
54
|
+
// they'd ECDH against the signing key (different curve) and get garbage.
|
|
55
|
+
const enc_pubkey_b64 = req.headers.get("X-Daemon-Enc-Pubkey") ?? undefined;
|
|
56
|
+
const daemon_id = this.state.id.name ?? await deriveDaemonId(pubkey);
|
|
57
|
+
|
|
58
|
+
const pair = new WebSocketPair();
|
|
59
|
+
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
60
|
+
|
|
61
|
+
const challenge = generateChallenge();
|
|
62
|
+
const att: DaemonAttachment = {
|
|
63
|
+
role: "daemon", pubkey_b64, enc_pubkey_b64,
|
|
64
|
+
challenge, authed: false, daemon_id,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
this.state.acceptWebSocket(server, ["daemon"]);
|
|
68
|
+
server.serializeAttachment(att);
|
|
69
|
+
|
|
70
|
+
server.send(JSON.stringify({
|
|
71
|
+
type: "challenge",
|
|
72
|
+
nonce: toBase64(challenge.nonce),
|
|
73
|
+
issued_at_ms: challenge.issued_at_ms,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async acceptPhone(req: Request): Promise<Response> {
|
|
80
|
+
const url = new URL(req.url);
|
|
81
|
+
const pairing_token = req.headers.get("X-Pairing-Token") ?? url.searchParams.get("token");
|
|
82
|
+
const phone_pubkey_hdr = req.headers.get("X-Phone-Pubkey") ?? url.searchParams.get("phone_pubkey");
|
|
83
|
+
const sig_hdr = req.headers.get("X-Sig") ?? url.searchParams.get("sig");
|
|
84
|
+
|
|
85
|
+
const pair = new WebSocketPair();
|
|
86
|
+
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
87
|
+
|
|
88
|
+
if (!pairing_token && !phone_pubkey_hdr) {
|
|
89
|
+
server.close(4000, "missing_pairing_token_or_phone_pubkey");
|
|
90
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (pairing_token) {
|
|
94
|
+
const phone_id = pairing_token;
|
|
95
|
+
const att: PhoneAttachment = { role: "phone", phone_id, pairing_token, authed: false };
|
|
96
|
+
this.state.acceptWebSocket(server, ["phone", phone_id]);
|
|
97
|
+
server.serializeAttachment(att);
|
|
98
|
+
|
|
99
|
+
// Prefer the X25519 encryption pubkey (post-fix daemons). Older daemons
|
|
100
|
+
// only stored the signing pubkey — phones paired against those have
|
|
101
|
+
// broken shared secrets and must re-pair after the daemon upgrades.
|
|
102
|
+
const daemonEncPubkey = await this.state.storage.get<string>("daemon_enc_pubkey_b64");
|
|
103
|
+
const daemonPubkey = daemonEncPubkey ?? await this.state.storage.get<string>("daemon_pubkey_b64");
|
|
104
|
+
const pending = await this.state.storage.get<{ token: string; expires_at_ms: number }>("pending_pair");
|
|
105
|
+
if (!daemonPubkey || !pending || pending.token !== pairing_token || Date.now() > pending.expires_at_ms) {
|
|
106
|
+
server.close(4002, "pairing_token_invalid");
|
|
107
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
server.send(JSON.stringify({ type: "pair_init", daemon_pubkey: daemonPubkey }));
|
|
111
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Reconnect mode (sig verification)
|
|
115
|
+
if (!phone_pubkey_hdr || !sig_hdr) {
|
|
116
|
+
return new Response("missing phone_pubkey or sig", { status: 400 });
|
|
117
|
+
}
|
|
118
|
+
const phone_id = phone_pubkey_hdr;
|
|
119
|
+
const att: PhoneAttachment = { role: "phone", phone_id, authed: false };
|
|
120
|
+
this.state.acceptWebSocket(server, ["phone", phone_id]);
|
|
121
|
+
server.serializeAttachment(att);
|
|
122
|
+
const paired = await this.state.storage.get<Record<string, string>>("paired_phones");
|
|
123
|
+
if (!paired || !paired[phone_pubkey_hdr]) {
|
|
124
|
+
server.close(4001, "unknown_phone");
|
|
125
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
126
|
+
}
|
|
127
|
+
if (!this.verifyReconnectSig(phone_pubkey_hdr, sig_hdr)) {
|
|
128
|
+
server.close(4001, "bad_sig");
|
|
129
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
130
|
+
}
|
|
131
|
+
att.authed = true;
|
|
132
|
+
server.serializeAttachment(att);
|
|
133
|
+
server.send(JSON.stringify({ type: "ready" }));
|
|
134
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private verifyReconnectSig(phone_pubkey_b64: string, sig_b64: string): boolean {
|
|
138
|
+
try {
|
|
139
|
+
const pubkey = fromBase64(phone_pubkey_b64);
|
|
140
|
+
const sig = fromBase64(sig_b64);
|
|
141
|
+
const daemon_id = this.state.id.name!;
|
|
142
|
+
const daemonIdBytes = new TextEncoder().encode(daemon_id);
|
|
143
|
+
const nowMinute = Math.floor(Date.now() / 60_000);
|
|
144
|
+
for (const m of [nowMinute, nowMinute - 1]) {
|
|
145
|
+
const msg = new Uint8Array(daemonIdBytes.length + 8);
|
|
146
|
+
msg.set(daemonIdBytes, 0);
|
|
147
|
+
new DataView(msg.buffer, daemonIdBytes.length, 8).setBigUint64(0, BigInt(m), false);
|
|
148
|
+
if (nacl.sign.detached.verify(msg, sig, pubkey)) return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Hibernating WebSocket handlers ────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
159
|
+
const att = ws.deserializeAttachment() as Attachment | undefined;
|
|
160
|
+
if (!att) { ws.close(1011, "no_attachment"); return; }
|
|
161
|
+
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
162
|
+
let msg: any;
|
|
163
|
+
try { msg = JSON.parse(text); } catch { ws.close(1008, "bad_json"); return; }
|
|
164
|
+
|
|
165
|
+
if (att.role === "daemon") return this.handleDaemonMessage(ws, att, msg);
|
|
166
|
+
return this.handlePhoneMessage(ws, att, msg);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async handleDaemonMessage(ws: WebSocket, att: DaemonAttachment, msg: any): Promise<void> {
|
|
170
|
+
if (!att.authed) {
|
|
171
|
+
if (msg.type !== "challenge_response") { ws.close(1008, "expected_challenge_response"); return; }
|
|
172
|
+
const pubkey = fromBase64(att.pubkey_b64);
|
|
173
|
+
const sig = fromBase64(msg.sig);
|
|
174
|
+
const ch = att.challenge!;
|
|
175
|
+
const sigMsg = buildChallengeMessage(ch.nonce, ch.issued_at_ms);
|
|
176
|
+
if (Date.now() > ch.issued_at_ms + CHALLENGE_TTL_MS) { ws.close(4001, "challenge_expired"); return; }
|
|
177
|
+
if (!nacl.sign.detached.verify(sigMsg, sig, pubkey)) { ws.close(4001, "bad_sig"); return; }
|
|
178
|
+
att.authed = true;
|
|
179
|
+
att.challenge = undefined;
|
|
180
|
+
ws.serializeAttachment(att);
|
|
181
|
+
await this.state.storage.put("daemon_pubkey_b64", att.pubkey_b64);
|
|
182
|
+
// Store the X25519 encryption pubkey separately so pair_init returns the
|
|
183
|
+
// right key for phone-side ECDH. Older daemons that don't send the
|
|
184
|
+
// header → field stays undefined and we fall back in acceptPhone.
|
|
185
|
+
if (att.enc_pubkey_b64) {
|
|
186
|
+
await this.state.storage.put("daemon_enc_pubkey_b64", att.enc_pubkey_b64);
|
|
187
|
+
}
|
|
188
|
+
ws.send(JSON.stringify({ type: "ready", daemon_id: att.daemon_id }));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (msg.type === "register_pairing") {
|
|
193
|
+
const token = String(msg.token ?? "");
|
|
194
|
+
// `persistent: true` makes the token survive both TTL sweeps and claims,
|
|
195
|
+
// so the QR can be permanent until the user rotates. DO storage gets the
|
|
196
|
+
// same flag for its own consistency check in acceptPhone().
|
|
197
|
+
const persistent = msg.persistent === true;
|
|
198
|
+
const expires_at_ms = persistent ? Number.MAX_SAFE_INTEGER : Date.now() + 10 * 60 * 1000;
|
|
199
|
+
await this.state.storage.put("pending_pair", { token, expires_at_ms, persistent });
|
|
200
|
+
if (this.env.PAIRING) {
|
|
201
|
+
const coordId = this.env.PAIRING.idFromName("coordinator");
|
|
202
|
+
const coordStub = this.env.PAIRING.get(coordId);
|
|
203
|
+
await coordStub.fetch("https://x/register", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
body: JSON.stringify({ token, daemon_id: att.daemon_id, persistent }),
|
|
206
|
+
headers: { "content-type": "application/json" },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (msg.type === "pair_ack") {
|
|
213
|
+
for (const phone of this.state.getWebSockets("phone")) {
|
|
214
|
+
const p = phone.deserializeAttachment() as PhoneAttachment;
|
|
215
|
+
if (p && p.pairing_token) {
|
|
216
|
+
// Forward the WHOLE message so daemon_id flows through to phone.
|
|
217
|
+
phone.send(JSON.stringify(msg));
|
|
218
|
+
p.authed = true;
|
|
219
|
+
p.pairing_token = undefined;
|
|
220
|
+
phone.serializeAttachment(p);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Persistent tokens are kept — same QR can pair the next device.
|
|
224
|
+
// Ephemeral tokens are consumed once.
|
|
225
|
+
const pending = await this.state.storage.get<{ token: string; expires_at_ms: number; persistent?: boolean }>("pending_pair");
|
|
226
|
+
if (!pending?.persistent) {
|
|
227
|
+
await this.state.storage.delete("pending_pair");
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (msg.type === "revoke_phone") {
|
|
233
|
+
// Daemon kicks a previously-paired phone: drop from paired_phones map and
|
|
234
|
+
// close any live WS for that phone.
|
|
235
|
+
const signPk = String(msg.phone_pubkey_b64 ?? "");
|
|
236
|
+
if (signPk) {
|
|
237
|
+
const paired = (await this.state.storage.get<Record<string, string>>("paired_phones")) ?? {};
|
|
238
|
+
if (paired[signPk]) {
|
|
239
|
+
delete paired[signPk];
|
|
240
|
+
await this.state.storage.put("paired_phones", paired);
|
|
241
|
+
}
|
|
242
|
+
for (const ph of this.state.getWebSockets("phone")) {
|
|
243
|
+
const p = ph.deserializeAttachment() as PhoneAttachment | undefined;
|
|
244
|
+
if (p && p.phone_id === signPk) {
|
|
245
|
+
try { ph.send(JSON.stringify({ type: "phone_revoked", by: "daemon" })); } catch { /* */ }
|
|
246
|
+
try { ph.close(4003, "revoked"); } catch { /* */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Envelope or other daemon-originated message. Route by `to` field if it
|
|
254
|
+
// identifies a specific phone; otherwise broadcast to all authed phones.
|
|
255
|
+
// to: "phone:<peer_id>" → only the matching phone (avoids decryption noise
|
|
256
|
+
// on other paired phones that share this DO)
|
|
257
|
+
// to: anything else / absent → broadcast (back-compat for unaddressed sends)
|
|
258
|
+
const target = typeof msg?.to === "string" ? msg.to : "";
|
|
259
|
+
const peerMatch = /^phone:(.+)$/.exec(target);
|
|
260
|
+
if (peerMatch) {
|
|
261
|
+
const targetPeerId = peerMatch[1];
|
|
262
|
+
for (const phone of this.state.getWebSockets("phone")) {
|
|
263
|
+
const p = phone.deserializeAttachment() as PhoneAttachment;
|
|
264
|
+
if (p && p.authed && p.peer_id === targetPeerId) {
|
|
265
|
+
phone.send(JSON.stringify(msg));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
for (const phone of this.state.getWebSockets("phone")) {
|
|
271
|
+
const p = phone.deserializeAttachment() as PhoneAttachment;
|
|
272
|
+
if (p && p.authed) phone.send(JSON.stringify(msg));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async handlePhoneMessage(ws: WebSocket, att: PhoneAttachment, msg: any): Promise<void> {
|
|
277
|
+
if (!att.authed) {
|
|
278
|
+
if (msg.type === "pair_offer" && att.pairing_token) {
|
|
279
|
+
const signPk = String(msg.phone_sign_pubkey ?? msg.phone_pubkey ?? "");
|
|
280
|
+
if (signPk) {
|
|
281
|
+
const paired = (await this.state.storage.get<Record<string, string>>("paired_phones")) ?? {};
|
|
282
|
+
paired[signPk] = String(Date.now());
|
|
283
|
+
await this.state.storage.put("paired_phones", paired);
|
|
284
|
+
// Re-key attachment to the signing pubkey so a later revoke_self on
|
|
285
|
+
// this same socket can identify itself without a disconnect+reconnect.
|
|
286
|
+
att.phone_id = signPk;
|
|
287
|
+
ws.serializeAttachment(att);
|
|
288
|
+
}
|
|
289
|
+
this.broadcastToDaemons(msg);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
ws.close(1008, "expected_pair_offer");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (msg.type === "register_peer_id") {
|
|
297
|
+
// Phone tells the relay its addressable peer_id so daemon→phone envelopes
|
|
298
|
+
// addressed `to: "phone:<peer_id>"` can be routed precisely instead of
|
|
299
|
+
// broadcast-and-discard-noise on every other paired phone.
|
|
300
|
+
const peer_id = String(msg.peer_id ?? "");
|
|
301
|
+
if (peer_id) {
|
|
302
|
+
att.peer_id = peer_id;
|
|
303
|
+
ws.serializeAttachment(att);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (msg.type === "revoke_self") {
|
|
309
|
+
// Phone wants to unpair. att.phone_id IS the signing pubkey in reconnect mode.
|
|
310
|
+
const signPk = att.phone_id;
|
|
311
|
+
if (signPk) {
|
|
312
|
+
const paired = (await this.state.storage.get<Record<string, string>>("paired_phones")) ?? {};
|
|
313
|
+
if (paired[signPk]) {
|
|
314
|
+
delete paired[signPk];
|
|
315
|
+
await this.state.storage.put("paired_phones", paired);
|
|
316
|
+
}
|
|
317
|
+
// Tell daemon to clean up its local config too.
|
|
318
|
+
this.broadcastToDaemons({ type: "phone_revoked", phone_pubkey_b64: signPk });
|
|
319
|
+
}
|
|
320
|
+
try { ws.send(JSON.stringify({ type: "revoked_ok" })); } catch { /* */ }
|
|
321
|
+
ws.close(1000, "revoked");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.broadcastToDaemons(msg);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Multiple daemon WSes can accumulate (e.g. pair CLI restarts before the prior WS is GC'd).
|
|
329
|
+
* Broadcast to all — only the live process replies. Dead sockets either no-op or get cleaned up
|
|
330
|
+
* in webSocketClose. */
|
|
331
|
+
/** Broadcast to every daemon WS in the tag. Stale sockets from crashed CLI
|
|
332
|
+
* sessions can accumulate; sending to a dead one throws and is ignored. The
|
|
333
|
+
* live daemon(s) — typically one — receive and respond. */
|
|
334
|
+
private broadcastToDaemons(msg: any): void {
|
|
335
|
+
const payload = JSON.stringify(msg);
|
|
336
|
+
for (const d of this.state.getWebSockets("daemon")) {
|
|
337
|
+
try { d.send(payload); } catch { /* dead socket; CF GCs on close */ }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean): Promise<void> {}
|
|
342
|
+
async webSocketError(_ws: WebSocket, _err: unknown): Promise<void> {}
|
|
343
|
+
|
|
344
|
+
private daemonWs(): WebSocket | null {
|
|
345
|
+
const arr = this.state.getWebSockets("daemon");
|
|
346
|
+
return arr[0] ?? null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Bindings exposed by wrangler.toml to the Worker runtime.
|
|
2
|
+
export interface Env {
|
|
3
|
+
PAIRING: DurableObjectNamespace;
|
|
4
|
+
RELAY: DurableObjectNamespace;
|
|
5
|
+
RATE_LIMITER: RateLimit;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// CF rate-limit binding (not in workers-types yet).
|
|
9
|
+
export interface RateLimit {
|
|
10
|
+
limit(opts: { key: string }): Promise<{ success: boolean }>;
|
|
11
|
+
}
|