omp-mobile 0.1.0 → 0.1.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/README.md +8 -6
- package/package.json +8 -2
- package/src/omp-extension.mjs +270 -0
- package/src/rpc-session.mjs +10 -1
- package/src/server.mjs +71 -3
package/README.md
CHANGED
|
@@ -19,29 +19,31 @@ OMP Mobile is a native phone app for [oh-my-pi](https://github.com/can1357/oh-my
|
|
|
19
19
|
- **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
|
|
20
20
|
- **All your machines:** Pair each computer by QR code, switch between them from the session list. A reopened chat shows at once, also offline.
|
|
21
21
|
- **Tailscale only:** The daemon listens on the computer's Tailscale address and admits only your own devices. No relay or cloud service of its own.
|
|
22
|
+
- **An OMP plugin:** `/mobile` in OMP starts the daemon in the background and shows the pairing code. The daemon keeps running after the terminal closes.
|
|
22
23
|
|
|
23
24
|
## Getting started
|
|
24
25
|
|
|
25
26
|
### Prerequisites
|
|
26
27
|
|
|
27
|
-
- On every computer:
|
|
28
|
+
- On every computer: OMP 18.x
|
|
28
29
|
- [Tailscale](https://tailscale.com) signed in to the same account on the computer and the phone
|
|
29
30
|
- An Android phone
|
|
30
31
|
|
|
31
32
|
### Computer
|
|
32
33
|
|
|
33
|
-
Install the
|
|
34
|
+
Install the plugin into OMP on every computer:
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
|
-
|
|
37
|
-
omp-mobile --host 100.x.y.z --cwd ~/project
|
|
37
|
+
omp plugin install omp-mobile
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
It
|
|
40
|
+
Then in OMP, run `/mobile`. It starts the daemon in the background, bound to the computer's Tailscale address, and shows a pairing code above the composer until the phone connects. The daemon runs on the Bun inside OMP and outlives the terminal and OMP; `/mobile status` shows it and `/mobile stop` ends it. Phone sessions start in the directory where you ran `/mobile` the first time.
|
|
41
|
+
|
|
42
|
+
Without the plugin, the same daemon runs from npm (Node.js 22.19 or newer) in the foreground: `npm install -g omp-mobile`, then `omp-mobile --host tailscale --cwd ~/project`. It prints the same code.
|
|
41
43
|
|
|
42
44
|
### Phone
|
|
43
45
|
|
|
44
|
-
Install the APK from the [releases page](https://github.com/Rinai-R/omp-mobile/releases). In the app, tap **Scan to pair** and scan the code
|
|
46
|
+
Install the APK from the [releases page](https://github.com/Rinai-R/omp-mobile/releases). In the app, tap **Scan to pair** and scan the code. **Enter address** takes the address and token by hand, or a pasted pairing link.
|
|
45
47
|
|
|
46
48
|
For notifications, open **Settings → Notifications** in the app.
|
|
47
49
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-mobile",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
|
|
5
5
|
"keywords": [
|
|
6
|
+
"mobile",
|
|
6
7
|
"oh-my-pi",
|
|
7
8
|
"omp",
|
|
8
|
-
"
|
|
9
|
+
"omp-plugin",
|
|
9
10
|
"remote",
|
|
10
11
|
"tailscale"
|
|
11
12
|
],
|
|
@@ -27,6 +28,11 @@
|
|
|
27
28
|
"bin": {
|
|
28
29
|
"omp-mobile": "src/server.mjs"
|
|
29
30
|
},
|
|
31
|
+
"omp": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/omp-extension.mjs"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
30
36
|
"files": [
|
|
31
37
|
"src",
|
|
32
38
|
"NOTICE"
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OMP extension of omp-mobile: `/mobile` in the interactive OMP.
|
|
3
|
+
*
|
|
4
|
+
* `/mobile` starts the daemon in the background if it is not running, then
|
|
5
|
+
* shows its pairing QR code above the composer until a phone connects. The
|
|
6
|
+
* daemon is its own process (detached, in its own session, logging to
|
|
7
|
+
* `~/.omp-mobile/daemon.log`), so it keeps serving the phone after this
|
|
8
|
+
* terminal and this OMP exit; `/mobile stop` ends it.
|
|
9
|
+
*
|
|
10
|
+
* It runs on the Bun that ships inside omp (`BUN_BE_BUN=1`), so no separate
|
|
11
|
+
* Node.js is needed, and binds this computer's Tailscale IPv4 (`--host tailscale`).
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { delimiter, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { renderQr } from "./qr.mjs";
|
|
19
|
+
|
|
20
|
+
const HOME = join(homedir(), ".omp-mobile");
|
|
21
|
+
const STATE_FILE = join(HOME, "daemon.json");
|
|
22
|
+
const LOG_FILE = join(HOME, "daemon.log");
|
|
23
|
+
const SERVER = fileURLToPath(new URL("./server.mjs", import.meta.url));
|
|
24
|
+
const WIDGET = "omp-mobile";
|
|
25
|
+
const START_TIMEOUT_MS = 20_000;
|
|
26
|
+
const POLL_MS = 1_500;
|
|
27
|
+
/** How long the code stays up without a phone connecting. */
|
|
28
|
+
const SHOW_MS = 5 * 60_000;
|
|
29
|
+
const SUBCOMMANDS = [
|
|
30
|
+
{ value: "status", label: "status", description: "Is the daemon running, and where" },
|
|
31
|
+
{ value: "stop", label: "stop", description: "Stop the background daemon" },
|
|
32
|
+
{ value: "hide", label: "hide", description: "Hide the pairing code" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The running daemon's record, if its process is alive.
|
|
37
|
+
* @returns {{pid: number, version: string, url: string, pair: string, startedAt: number} | undefined}
|
|
38
|
+
*/
|
|
39
|
+
export function readState(path = STATE_FILE) {
|
|
40
|
+
let state;
|
|
41
|
+
try {
|
|
42
|
+
state = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (typeof state?.pid !== "number" || typeof state.url !== "string" || typeof state.pair !== "string") return undefined;
|
|
47
|
+
return alive(state.pid) ? state : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function alive(pid) {
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// EPERM: it exists but belongs to someone else.
|
|
56
|
+
return error?.code === "EPERM";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The token inside a pairing link. */
|
|
61
|
+
export function tokenOf(pair) {
|
|
62
|
+
return new URL(pair.replace(/^omp-mobile:\/\//, "http://x/")).searchParams.get("token") ?? "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The daemon's `/status`, or undefined when it does not answer. */
|
|
66
|
+
async function status(state) {
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(`${state.url}/status`, {
|
|
69
|
+
headers: { authorization: `Bearer ${tokenOf(state.pair)}`, "x-omp-mobile-probe": "1" },
|
|
70
|
+
signal: AbortSignal.timeout(3_000),
|
|
71
|
+
});
|
|
72
|
+
return response.ok ? await response.json() : undefined;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function logTail(lines = 6) {
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync(LOG_FILE, "utf8").trimEnd().split("\n").slice(-lines).join("\n");
|
|
81
|
+
} catch {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The omp the daemon starts sessions with: `omp` on PATH when there is one, since
|
|
88
|
+
* a package manager's link survives an upgrade that removes the versioned binary
|
|
89
|
+
* this process runs from (Homebrew's Cellar path, for one); else this binary.
|
|
90
|
+
*/
|
|
91
|
+
function ompBinary() {
|
|
92
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
93
|
+
if (dir === "") continue;
|
|
94
|
+
const candidate = join(dir, "omp");
|
|
95
|
+
try {
|
|
96
|
+
accessSync(candidate, constants.X_OK);
|
|
97
|
+
return candidate;
|
|
98
|
+
} catch {
|
|
99
|
+
// Not in this directory.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return process.execPath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Start the daemon detached from this terminal and wait until it serves.
|
|
107
|
+
* @param {string} cwd Default directory for sessions the phone starts.
|
|
108
|
+
*/
|
|
109
|
+
async function startDaemon(cwd) {
|
|
110
|
+
mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
111
|
+
const log = openSync(LOG_FILE, "a", 0o600);
|
|
112
|
+
let child;
|
|
113
|
+
try {
|
|
114
|
+
child = spawn(process.execPath, [SERVER, "--host", "tailscale", "--cwd", cwd, "--omp", ompBinary(), "--no-qr"], {
|
|
115
|
+
cwd,
|
|
116
|
+
detached: true,
|
|
117
|
+
stdio: ["ignore", log, log],
|
|
118
|
+
env: { ...process.env, BUN_BE_BUN: "1" },
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
closeSync(log);
|
|
122
|
+
}
|
|
123
|
+
let exited = false;
|
|
124
|
+
child.on("exit", () => {
|
|
125
|
+
exited = true;
|
|
126
|
+
});
|
|
127
|
+
child.on("error", () => {
|
|
128
|
+
exited = true;
|
|
129
|
+
});
|
|
130
|
+
child.unref();
|
|
131
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
132
|
+
while (Date.now() < deadline && !exited) {
|
|
133
|
+
const state = readState();
|
|
134
|
+
if (state?.pid === child.pid && (await status(state)) !== undefined) return state;
|
|
135
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
136
|
+
}
|
|
137
|
+
const reason = exited ? "it exited" : "it did not answer in time";
|
|
138
|
+
throw new Error(`The omp-mobile daemon did not start (${reason}).\n${logTail()}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function stopDaemon(state) {
|
|
142
|
+
process.kill(state.pid, "SIGTERM");
|
|
143
|
+
const deadline = Date.now() + 5_000;
|
|
144
|
+
while (Date.now() < deadline && alive(state.pid)) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
145
|
+
if (alive(state.pid)) process.kill(state.pid, "SIGKILL");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What the pairing widget shows: a title, the code, and where it points. */
|
|
149
|
+
export function pairingContent(state) {
|
|
150
|
+
const host = new URL(state.url).host;
|
|
151
|
+
return {
|
|
152
|
+
title: "OMP Mobile · in the app: Machines → Scan to pair",
|
|
153
|
+
code: renderQr(state.pair).split("\n"),
|
|
154
|
+
notes: [
|
|
155
|
+
`${host} · pid ${state.pid} · keeps running after you exit`,
|
|
156
|
+
"/mobile hide closes this · /mobile stop stops the daemon",
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `text` cut to `width` columns (the widget's lines are plain, one column per character). */
|
|
162
|
+
function fit(text, width) {
|
|
163
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The pairing widget as a TUI component: a plain line widget is cut at ten
|
|
168
|
+
* lines, which a QR code needs more than. A code cut at the terminal's edge
|
|
169
|
+
* would not scan, so a terminal narrower than the code is asked to widen;
|
|
170
|
+
* the text lines are shortened to fit instead.
|
|
171
|
+
*/
|
|
172
|
+
export function pairingWidget({ title, code, notes }) {
|
|
173
|
+
const codeWidth = Math.max(...code.map((line) => line.length)) + 1;
|
|
174
|
+
return () => ({
|
|
175
|
+
render(width) {
|
|
176
|
+
const text = (line) => fit(` ${line}`, width);
|
|
177
|
+
if (width < codeWidth) return [text(`OMP Mobile: widen the terminal to ${codeWidth} columns to show the code.`)];
|
|
178
|
+
return [text(title), "", ...code.map((line) => ` ${line}`), "", ...notes.map(text)];
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function ompMobile(pi) {
|
|
184
|
+
/** The pairing code on screen: its watcher and when it appeared. */
|
|
185
|
+
let showing;
|
|
186
|
+
|
|
187
|
+
const hide = (ctx) => {
|
|
188
|
+
if (showing === undefined) return;
|
|
189
|
+
ctx.clearTimer(showing.timer);
|
|
190
|
+
showing = undefined;
|
|
191
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const show = (ctx, state) => {
|
|
195
|
+
hide(ctx);
|
|
196
|
+
if (ctx.mode !== "tui") {
|
|
197
|
+
ctx.ui.notify(`OMP Mobile: open this link on the phone: ${state.pair}`, "info");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const shownAt = Date.now();
|
|
201
|
+
ctx.ui.setWidget(WIDGET, pairingWidget(pairingContent(state)), { placement: "aboveEditor" });
|
|
202
|
+
const timer = ctx.setInterval(async () => {
|
|
203
|
+
if (showing?.timer !== timer) return;
|
|
204
|
+
if (Date.now() - shownAt > SHOW_MS) {
|
|
205
|
+
hide(ctx);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const now = await status(state);
|
|
209
|
+
if (showing?.timer !== timer) return;
|
|
210
|
+
if (now === undefined) {
|
|
211
|
+
hide(ctx);
|
|
212
|
+
ctx.ui.notify("OMP Mobile: the daemon stopped answering.", "warning");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (now.lastPhone !== null && now.lastPhone.at >= shownAt) {
|
|
216
|
+
hide(ctx);
|
|
217
|
+
ctx.ui.notify(`OMP Mobile: phone connected from ${now.lastPhone.address}.`, "info");
|
|
218
|
+
}
|
|
219
|
+
}, POLL_MS);
|
|
220
|
+
showing = { timer };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
pi.registerCommand("mobile", {
|
|
224
|
+
description: "Pair your phone with this computer (OMP Mobile)",
|
|
225
|
+
getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((item) => item.value.startsWith(prefix.trim())),
|
|
226
|
+
handler: async (args, ctx) => {
|
|
227
|
+
const sub = args.trim();
|
|
228
|
+
const state = readState();
|
|
229
|
+
if (sub === "hide") {
|
|
230
|
+
hide(ctx);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (sub === "stop") {
|
|
234
|
+
hide(ctx);
|
|
235
|
+
if (state === undefined) {
|
|
236
|
+
ctx.ui.notify("OMP Mobile: the daemon is not running.", "info");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await stopDaemon(state);
|
|
240
|
+
ctx.ui.notify(`OMP Mobile: stopped the daemon (pid ${state.pid}).`, "info");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (sub === "status") {
|
|
244
|
+
const now = state === undefined ? undefined : await status(state);
|
|
245
|
+
if (state === undefined || now === undefined) {
|
|
246
|
+
ctx.ui.notify("OMP Mobile: the daemon is not running. /mobile starts it.", "info");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const phone = now.lastPhone === null ? "no phone yet" : `last phone ${now.lastPhone.address}`;
|
|
250
|
+
ctx.ui.notify(`OMP Mobile: ${now.url} · pid ${now.pid} · ${now.liveSessions} live sessions · ${phone}`, "info");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (sub !== "") {
|
|
254
|
+
ctx.ui.notify(`OMP Mobile: unknown "${sub}". Use /mobile, /mobile status, /mobile stop or /mobile hide.`, "warning");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let running = state !== undefined && (await status(state)) !== undefined ? state : undefined;
|
|
258
|
+
if (running === undefined) {
|
|
259
|
+
ctx.ui.notify("OMP Mobile: starting the daemon…", "info");
|
|
260
|
+
try {
|
|
261
|
+
running = await startDaemon(ctx.cwd);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
show(ctx, running);
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
package/src/rpc-session.mjs
CHANGED
|
@@ -11,6 +11,15 @@ import { randomUUID } from "node:crypto";
|
|
|
11
11
|
import { createInterface } from "node:readline";
|
|
12
12
|
|
|
13
13
|
const START_TIMEOUT_MS = 45_000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The environment without `BUN_BE_BUN`: /mobile starts the daemon under omp's
|
|
17
|
+
* embedded Bun with it set, and a child omp that inherits it would run as Bun.
|
|
18
|
+
*/
|
|
19
|
+
function withoutBunFlag(env) {
|
|
20
|
+
const { BUN_BE_BUN: _ignored, ...rest } = env;
|
|
21
|
+
return rest;
|
|
22
|
+
}
|
|
14
23
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
15
24
|
|
|
16
25
|
/** Events that carry no useful state to the phone. */
|
|
@@ -61,7 +70,7 @@ export class RpcSession extends EventEmitter {
|
|
|
61
70
|
if (this.sessionDir) args.push("--session-dir", this.sessionDir);
|
|
62
71
|
const child = spawn(this.ompBin, args, {
|
|
63
72
|
cwd: this.cwd,
|
|
64
|
-
env: { ...process.env, NO_COLOR: "1" },
|
|
73
|
+
env: { ...withoutBunFlag(process.env), NO_COLOR: "1" },
|
|
65
74
|
stdio: ["pipe", "pipe", "pipe"],
|
|
66
75
|
});
|
|
67
76
|
this.child = child;
|
package/src/server.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* to be exposed to the public internet.
|
|
9
9
|
*/
|
|
10
10
|
import { STATUS_CODES, createServer } from "node:http";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { homedir, hostname, platform } from "node:os";
|
|
14
14
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -25,6 +25,7 @@ const PROTOCOL = 1;
|
|
|
25
25
|
const DEFAULT_PORT = 7390;
|
|
26
26
|
const DEFAULT_TOKEN_FILE = join(homedir(), ".omp-mobile", "token");
|
|
27
27
|
const DEFAULT_REGISTRY_FILE = join(homedir(), ".omp-mobile", "sessions.json");
|
|
28
|
+
const DEFAULT_STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
|
|
28
29
|
const DEFAULT_OMP_HOME = join(homedir(), ".omp");
|
|
29
30
|
const MAX_BODY = 256 * 1024;
|
|
30
31
|
/** Terminal sessions listed next to the daemon's own, newest first. */
|
|
@@ -45,6 +46,7 @@ function parseArgs(argv) {
|
|
|
45
46
|
omp: "omp",
|
|
46
47
|
tokenFile: DEFAULT_TOKEN_FILE,
|
|
47
48
|
registryFile: DEFAULT_REGISTRY_FILE,
|
|
49
|
+
stateFile: DEFAULT_STATE_FILE,
|
|
48
50
|
token: "",
|
|
49
51
|
sessionDir: "",
|
|
50
52
|
ompHome: DEFAULT_OMP_HOME,
|
|
@@ -86,6 +88,10 @@ function parseArgs(argv) {
|
|
|
86
88
|
options.registryFile = resolve(value);
|
|
87
89
|
i += 1;
|
|
88
90
|
break;
|
|
91
|
+
case "--state-file":
|
|
92
|
+
options.stateFile = resolve(value);
|
|
93
|
+
i += 1;
|
|
94
|
+
break;
|
|
89
95
|
case "--session-dir":
|
|
90
96
|
options.sessionDir = resolve(value);
|
|
91
97
|
i += 1;
|
|
@@ -130,8 +136,7 @@ function printHelp() {
|
|
|
130
136
|
|
|
131
137
|
Usage: omp-mobile [options]
|
|
132
138
|
|
|
133
|
-
--host <address> Bind address (default 127.0.0.1
|
|
134
|
-
--port <port> Port (default ${DEFAULT_PORT})
|
|
139
|
+
--host <address> Bind address (default 127.0.0.1). \`tailscale\`: this computer's Tailscale IPv4
|
|
135
140
|
--cwd <dir> Default working directory for new sessions
|
|
136
141
|
--omp <path> omp executable (default "omp")
|
|
137
142
|
--session-dir <dir> OMP session directory for sessions this daemon creates
|
|
@@ -139,6 +144,7 @@ Usage: omp-mobile [options]
|
|
|
139
144
|
--token <token> Fixed bearer token (otherwise generated and stored)
|
|
140
145
|
--token-file <path> Token file (default ~/.omp-mobile/token)
|
|
141
146
|
--registry-file <path> Session registry (default ~/.omp-mobile/sessions.json)
|
|
147
|
+
--state-file <path> Where the running daemon records its pid and pairing link (default ~/.omp-mobile/daemon.json)
|
|
142
148
|
--auto-approve Pass --auto-approve to omp (no tool approvals reach the phone)
|
|
143
149
|
--approval-mode <m> Pass --approval-mode to omp (always-ask, always-ask-cached, write, yolo)
|
|
144
150
|
--tailscale <path> tailscale CLI used for admission (default "tailscale")
|
|
@@ -397,6 +403,10 @@ class Daemon {
|
|
|
397
403
|
this.token = token;
|
|
398
404
|
this.admission = identity.admission;
|
|
399
405
|
this.user = identity.user ?? "";
|
|
406
|
+
this.startedAt = Date.now();
|
|
407
|
+
/** The last authenticated request from a client (not `/mobile`'s own probe), for `/mobile` to see a phone connect. */
|
|
408
|
+
/** @type {{address: string, at: number} | null} */
|
|
409
|
+
this.lastPhone = null;
|
|
400
410
|
/** @type {Map<string, SessionEntry>} */
|
|
401
411
|
this.sessions = new Map();
|
|
402
412
|
/** @type {Map<string, object>} */
|
|
@@ -477,6 +487,19 @@ class Daemon {
|
|
|
477
487
|
sendJson(res, 401, { error: "unauthorized" });
|
|
478
488
|
return;
|
|
479
489
|
}
|
|
490
|
+
this.noteClient(req);
|
|
491
|
+
if (url.pathname === "/status" && req.method === "GET") {
|
|
492
|
+
sendJson(res, 200, {
|
|
493
|
+
pid: process.pid,
|
|
494
|
+
version: VERSION,
|
|
495
|
+
startedAt: this.startedAt,
|
|
496
|
+
url: this.baseUrl(),
|
|
497
|
+
liveSessions: this.sessions.size,
|
|
498
|
+
eventStreams: this.streams.size,
|
|
499
|
+
lastPhone: this.lastPhone,
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
480
503
|
if (url.pathname === "/events" && req.method === "GET") {
|
|
481
504
|
const end = serveEvents(this.attention, req, res);
|
|
482
505
|
this.streams.add(end);
|
|
@@ -653,6 +676,7 @@ class Daemon {
|
|
|
653
676
|
socket.destroy();
|
|
654
677
|
return;
|
|
655
678
|
}
|
|
679
|
+
this.noteClient(req);
|
|
656
680
|
void this.ensureLive(decodeURIComponent(match[1]))
|
|
657
681
|
.then((entry) => {
|
|
658
682
|
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -714,6 +738,17 @@ class Daemon {
|
|
|
714
738
|
}
|
|
715
739
|
}
|
|
716
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Remember an authenticated client request. `/mobile` marks its own status
|
|
743
|
+
* polls with `x-omp-mobile-probe`; anything else is the app. An address cannot
|
|
744
|
+
* tell them apart: an emulator's requests leave from this computer's own address.
|
|
745
|
+
*/
|
|
746
|
+
noteClient(req) {
|
|
747
|
+
if (req.headers["x-omp-mobile-probe"] !== undefined) return;
|
|
748
|
+
const address = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
|
|
749
|
+
this.lastPhone = { address, at: Date.now() };
|
|
750
|
+
}
|
|
751
|
+
|
|
717
752
|
listen() {
|
|
718
753
|
return new Promise((resolveListen) => {
|
|
719
754
|
this.server.listen(this.options.port, this.options.host, () => resolveListen());
|
|
@@ -774,14 +809,44 @@ function isTailnetAddress(host) {
|
|
|
774
809
|
return /\.ts\.net$/i.test(host);
|
|
775
810
|
}
|
|
776
811
|
|
|
812
|
+
/** This computer's Tailscale IPv4 address, for `--host tailscale`. */
|
|
813
|
+
async function tailscaleHost(options) {
|
|
814
|
+
let self;
|
|
815
|
+
try {
|
|
816
|
+
self = await new Tailscale({ binary: options.tailscale }).status();
|
|
817
|
+
} catch (error) {
|
|
818
|
+
throw startupError(`--host tailscale: cannot read tailscale status (${error instanceof Error ? error.message : String(error)})`);
|
|
819
|
+
}
|
|
820
|
+
const ipv4 = self.ips.find((ip) => isTailnetAddress(ip));
|
|
821
|
+
if (ipv4 === undefined) throw startupError("--host tailscale: this computer has no Tailscale IPv4 address");
|
|
822
|
+
return ipv4;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Record the running daemon for `/mobile` (owner-only: the pairing link carries the token). */
|
|
826
|
+
function writeState(path, state) {
|
|
827
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
828
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Remove the state file if it still describes this process. */
|
|
832
|
+
function clearState(path) {
|
|
833
|
+
try {
|
|
834
|
+
if (JSON.parse(readFileSync(path, "utf8")).pid === process.pid) unlinkSync(path);
|
|
835
|
+
} catch {
|
|
836
|
+
// Already gone or replaced by another daemon.
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
777
840
|
async function main() {
|
|
778
841
|
const options = parseArgs(process.argv.slice(2));
|
|
842
|
+
if (options.host === "tailscale") options.host = await tailscaleHost(options);
|
|
779
843
|
const token = loadToken(options);
|
|
780
844
|
const identity = await resolveIdentity(options);
|
|
781
845
|
const daemon = new Daemon(options, token, identity);
|
|
782
846
|
await daemon.listen();
|
|
783
847
|
const url = daemon.baseUrl();
|
|
784
848
|
const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
|
|
849
|
+
writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, startedAt: daemon.startedAt });
|
|
785
850
|
console.log(`omp-mobile daemon ${VERSION}`);
|
|
786
851
|
console.log(` listening ${url}`);
|
|
787
852
|
console.log(` cwd ${options.cwd}`);
|
|
@@ -799,11 +864,14 @@ async function main() {
|
|
|
799
864
|
}
|
|
800
865
|
}
|
|
801
866
|
const shutdown = async () => {
|
|
867
|
+
clearState(options.stateFile);
|
|
802
868
|
await daemon.close();
|
|
803
869
|
process.exit(0);
|
|
804
870
|
};
|
|
805
871
|
process.on("SIGINT", () => void shutdown());
|
|
806
872
|
process.on("SIGTERM", () => void shutdown());
|
|
873
|
+
// A daemon started by /mobile has no terminal; a closed one must not stop it.
|
|
874
|
+
process.on("SIGHUP", () => undefined);
|
|
807
875
|
}
|
|
808
876
|
|
|
809
877
|
// npm runs the `omp-mobile` bin through a symlink; compare real paths.
|