rms-devremote 3.0.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 +154 -0
- package/dist/commands/attach.d.ts +2 -0
- package/dist/commands/attach.js +10 -0
- package/dist/commands/check.d.ts +2 -0
- package/dist/commands/check.js +210 -0
- package/dist/commands/clean.d.ts +2 -0
- package/dist/commands/clean.js +177 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +57 -0
- package/dist/commands/link.d.ts +2 -0
- package/dist/commands/link.js +112 -0
- package/dist/commands/ping.d.ts +2 -0
- package/dist/commands/ping.js +21 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +54 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +65 -0
- package/dist/commands/unlink.d.ts +2 -0
- package/dist/commands/unlink.js +53 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +55 -0
- package/dist/server/auth.d.ts +6 -0
- package/dist/server/auth.js +32 -0
- package/dist/server/frontend.d.ts +4 -0
- package/dist/server/frontend.js +886 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +283 -0
- package/dist/server/terminal.d.ts +14 -0
- package/dist/server/terminal.js +43 -0
- package/dist/services/battery-worker.d.ts +1 -0
- package/dist/services/battery-worker.js +2 -0
- package/dist/services/battery.d.ts +27 -0
- package/dist/services/battery.js +152 -0
- package/dist/services/config.d.ts +63 -0
- package/dist/services/config.js +84 -0
- package/dist/services/docker.d.ts +25 -0
- package/dist/services/docker.js +75 -0
- package/dist/services/hooks.d.ts +15 -0
- package/dist/services/hooks.js +111 -0
- package/dist/services/ntfy.d.ts +19 -0
- package/dist/services/ntfy.js +63 -0
- package/dist/services/process.d.ts +30 -0
- package/dist/services/process.js +90 -0
- package/dist/services/proxy-worker.d.ts +1 -0
- package/dist/services/proxy-worker.js +12 -0
- package/dist/services/proxy.d.ts +4 -0
- package/dist/services/proxy.js +195 -0
- package/dist/services/shell.d.ts +22 -0
- package/dist/services/shell.js +47 -0
- package/dist/services/tmux.d.ts +30 -0
- package/dist/services/tmux.js +74 -0
- package/dist/services/ttyd.d.ts +28 -0
- package/dist/services/ttyd.js +71 -0
- package/dist/setup-server/routes.d.ts +4 -0
- package/dist/setup-server/routes.js +177 -0
- package/dist/setup-server/server.d.ts +4 -0
- package/dist/setup-server/server.js +32 -0
- package/docker/docker-compose.yml +24 -0
- package/docker/ntfy/server.yml +6 -0
- package/package.json +61 -0
- package/scripts/claude-remote.sh +583 -0
- package/scripts/hooks/notify.sh +68 -0
- package/scripts/notify.sh +54 -0
- package/scripts/startup.sh +29 -0
- package/scripts/update-check.sh +25 -0
- package/src/setup-server/public/index.html +21 -0
- package/src/setup-server/public/setup.css +475 -0
- package/src/setup-server/public/setup.js +687 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
7
|
+
import { basicAuth } from './auth.js';
|
|
8
|
+
import { createTerminalSession } from './terminal.js';
|
|
9
|
+
import { readConfig, readEnv } from '../services/config.js';
|
|
10
|
+
import { buildFrontendHTML } from './frontend.js';
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Server setup
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const config = readConfig();
|
|
17
|
+
const PORT = config.ttyd.port;
|
|
18
|
+
const app = express();
|
|
19
|
+
const httpServer = createServer(app);
|
|
20
|
+
// Apply basic auth to all HTTP routes
|
|
21
|
+
app.use(basicAuth);
|
|
22
|
+
// ── Serve xterm.js assets from node_modules ────────────────────────────────
|
|
23
|
+
const pkgRoot = join(__dirname, '..', '..');
|
|
24
|
+
const xtermDir = join(pkgRoot, 'node_modules', '@xterm', 'xterm');
|
|
25
|
+
const fitDir = join(pkgRoot, 'node_modules', '@xterm', 'addon-fit');
|
|
26
|
+
const webLinksDir = join(pkgRoot, 'node_modules', '@xterm', 'addon-web-links');
|
|
27
|
+
app.get('/xterm.css', (_req, res) => {
|
|
28
|
+
res.setHeader('Content-Type', 'text/css');
|
|
29
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
30
|
+
res.sendFile(join(xtermDir, 'css', 'xterm.css'));
|
|
31
|
+
});
|
|
32
|
+
app.get('/xterm.js', (_req, res) => {
|
|
33
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
34
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
35
|
+
res.sendFile(join(xtermDir, 'lib', 'xterm.js'));
|
|
36
|
+
});
|
|
37
|
+
app.get('/xterm-addon-fit.js', (_req, res) => {
|
|
38
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
39
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
40
|
+
res.sendFile(join(fitDir, 'lib', 'addon-fit.js'));
|
|
41
|
+
});
|
|
42
|
+
app.get('/xterm-addon-web-links.js', (_req, res) => {
|
|
43
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
44
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
45
|
+
res.sendFile(join(webLinksDir, 'lib', 'addon-web-links.js'));
|
|
46
|
+
});
|
|
47
|
+
// ── PWA assets ─────────────────────────────────────────────────────────────
|
|
48
|
+
app.get('/manifest.json', (_req, res) => {
|
|
49
|
+
res.json({
|
|
50
|
+
name: 'devremote',
|
|
51
|
+
short_name: 'devremote',
|
|
52
|
+
description: 'Remote terminal access',
|
|
53
|
+
start_url: '/',
|
|
54
|
+
display: 'standalone',
|
|
55
|
+
orientation: 'portrait',
|
|
56
|
+
background_color: '#000000',
|
|
57
|
+
theme_color: '#000000',
|
|
58
|
+
icons: [
|
|
59
|
+
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
app.get('/icon.svg', (_req, res) => {
|
|
64
|
+
res.setHeader('Content-Type', 'image/svg+xml');
|
|
65
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
66
|
+
res.send(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
67
|
+
<rect width="512" height="512" rx="96" fill="#000000"/>
|
|
68
|
+
<text x="128" y="340" font-family="monospace" font-size="240" font-weight="bold" fill="#00ffaa">>_</text>
|
|
69
|
+
</svg>`);
|
|
70
|
+
});
|
|
71
|
+
app.get('/sw.js', (_req, res) => {
|
|
72
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
73
|
+
res.send(`
|
|
74
|
+
var CACHE = 'devremote-v2';
|
|
75
|
+
var ASSETS = ['/', '/xterm.css', '/xterm.js', '/xterm-addon-fit.js', '/xterm-addon-web-links.js', '/manifest.json', '/icon.svg'];
|
|
76
|
+
|
|
77
|
+
self.addEventListener('install', function(e) {
|
|
78
|
+
e.waitUntil(caches.open(CACHE).then(function(c) { return c.addAll(ASSETS); }));
|
|
79
|
+
self.skipWaiting();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
self.addEventListener('activate', function(e) {
|
|
83
|
+
e.waitUntil(caches.keys().then(function(names) {
|
|
84
|
+
return Promise.all(names.filter(function(n) { return n !== CACHE; }).map(function(n) { return caches.delete(n); }));
|
|
85
|
+
}));
|
|
86
|
+
self.clients.claim();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
self.addEventListener('fetch', function(e) {
|
|
90
|
+
var url = new URL(e.request.url);
|
|
91
|
+
if (url.pathname === '/ws' || url.pathname === '/health' || url.pathname === '/status') return;
|
|
92
|
+
if (e.request.method !== 'GET') return;
|
|
93
|
+
if (url.pathname === '/') {
|
|
94
|
+
e.respondWith(fetch(e.request).catch(function() { return caches.match(e.request); }));
|
|
95
|
+
} else {
|
|
96
|
+
e.respondWith(caches.match(e.request).then(function(r) { return r || fetch(e.request); }));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
`);
|
|
100
|
+
});
|
|
101
|
+
// ── Main page ──────────────────────────────────────────────────────────────
|
|
102
|
+
app.get('/', (_req, res) => {
|
|
103
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
104
|
+
res.send(buildFrontendHTML());
|
|
105
|
+
});
|
|
106
|
+
// ── Health / Status ────────────────────────────────────────────────────────
|
|
107
|
+
app.get('/health', (_req, res) => {
|
|
108
|
+
res.json({ status: 'ok', uptime: process.uptime() });
|
|
109
|
+
});
|
|
110
|
+
app.get('/status', (_req, res) => {
|
|
111
|
+
// Get tmux current pane path
|
|
112
|
+
let tmuxPath = '';
|
|
113
|
+
try {
|
|
114
|
+
tmuxPath = execFileSync('tmux', ['display-message', '-t', 'devremote', '-p', '#{pane_current_path}'], {
|
|
115
|
+
encoding: 'utf8', timeout: 3000,
|
|
116
|
+
}).trim();
|
|
117
|
+
}
|
|
118
|
+
catch { /* ignore */ }
|
|
119
|
+
res.json({
|
|
120
|
+
tmux: isTmuxAlive(),
|
|
121
|
+
clientConnected: activeClient !== null && activeClient.readyState === WebSocket.OPEN,
|
|
122
|
+
cwd: tmuxPath,
|
|
123
|
+
hostname: process.env.HOSTNAME || '',
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// WebSocket — single terminal session
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
130
|
+
let activeClient = null;
|
|
131
|
+
let activeSession = null;
|
|
132
|
+
// Verify basic auth on WebSocket upgrade
|
|
133
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
134
|
+
const authHeader = req.headers.authorization;
|
|
135
|
+
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
136
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="devremote"\r\n\r\n');
|
|
137
|
+
socket.destroy();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const encoded = authHeader.slice(6);
|
|
141
|
+
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
|
|
142
|
+
const colonIdx = decoded.indexOf(':');
|
|
143
|
+
if (colonIdx === -1) {
|
|
144
|
+
socket.destroy();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const env = readEnv();
|
|
148
|
+
const user = decoded.slice(0, colonIdx);
|
|
149
|
+
const pass = decoded.slice(colonIdx + 1);
|
|
150
|
+
if (user !== (env.TTYD_USER ?? 'admin') || pass !== (env.TTYD_PASSWORD ?? 'changeme')) {
|
|
151
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
152
|
+
socket.destroy();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (req.url !== '/ws') {
|
|
156
|
+
socket.destroy();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Reject if another client is already connected
|
|
160
|
+
if (activeClient && activeClient.readyState === WebSocket.OPEN) {
|
|
161
|
+
socket.write('HTTP/1.1 409 Conflict\r\n\r\n');
|
|
162
|
+
socket.destroy();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
166
|
+
wss.emit('connection', ws, req);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
wss.on('connection', (ws) => {
|
|
170
|
+
activeClient = ws;
|
|
171
|
+
ws.on('message', (raw) => {
|
|
172
|
+
const msg = typeof raw === 'string' ? raw : raw.toString('utf8');
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(msg);
|
|
175
|
+
if (parsed.type === 'init' && !activeSession) {
|
|
176
|
+
const cols = parsed.cols || 80;
|
|
177
|
+
const rows = parsed.rows || 24;
|
|
178
|
+
activeSession = createTerminalSession(cols, rows);
|
|
179
|
+
activeSession.onData((data) => {
|
|
180
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
181
|
+
ws.send(JSON.stringify({ type: 'output', data }));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
activeSession.onExit((_code) => {
|
|
185
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
186
|
+
ws.send(JSON.stringify({ type: 'exit' }));
|
|
187
|
+
ws.close();
|
|
188
|
+
}
|
|
189
|
+
activeSession = null;
|
|
190
|
+
activeClient = null;
|
|
191
|
+
// Don't shutdown — server stays up for future connections.
|
|
192
|
+
// The tmux watcher handles shutdown if tmux truly dies.
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (parsed.type === 'input' && activeSession) {
|
|
197
|
+
activeSession.write(parsed.data);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (parsed.type === 'resize' && activeSession) {
|
|
201
|
+
activeSession.resize(parsed.cols, parsed.rows);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Ignore malformed messages
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
ws.on('close', () => {
|
|
210
|
+
if (activeSession) {
|
|
211
|
+
activeSession.kill();
|
|
212
|
+
activeSession = null;
|
|
213
|
+
}
|
|
214
|
+
activeClient = null;
|
|
215
|
+
});
|
|
216
|
+
ws.on('error', () => {
|
|
217
|
+
if (activeSession) {
|
|
218
|
+
activeSession.kill();
|
|
219
|
+
activeSession = null;
|
|
220
|
+
}
|
|
221
|
+
activeClient = null;
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Watch tmux session — auto-unlink if it dies
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
function isTmuxAlive() {
|
|
228
|
+
try {
|
|
229
|
+
execFileSync('tmux', ['has-session', '-t', 'devremote'], {
|
|
230
|
+
stdio: 'ignore',
|
|
231
|
+
timeout: 5000,
|
|
232
|
+
env: { ...process.env, PATH: process.env.PATH || '/usr/bin:/usr/local/bin:/bin' },
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Only shut down after 3 consecutive failures (avoids false positives)
|
|
241
|
+
let tmuxFailCount = 0;
|
|
242
|
+
const tmuxWatcher = setInterval(() => {
|
|
243
|
+
if (isTmuxAlive()) {
|
|
244
|
+
tmuxFailCount = 0;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
tmuxFailCount++;
|
|
248
|
+
if (tmuxFailCount >= 3) {
|
|
249
|
+
console.log('tmux session "devremote" ended — shutting down server');
|
|
250
|
+
shutdown();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}, 5000);
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Graceful shutdown
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
function shutdown() {
|
|
258
|
+
clearInterval(tmuxWatcher);
|
|
259
|
+
// Close active WebSocket
|
|
260
|
+
if (activeClient && activeClient.readyState === WebSocket.OPEN) {
|
|
261
|
+
activeClient.send(JSON.stringify({ type: 'exit' }));
|
|
262
|
+
activeClient.close();
|
|
263
|
+
}
|
|
264
|
+
// Kill PTY
|
|
265
|
+
if (activeSession) {
|
|
266
|
+
activeSession.kill();
|
|
267
|
+
activeSession = null;
|
|
268
|
+
}
|
|
269
|
+
// Close server
|
|
270
|
+
httpServer.close(() => {
|
|
271
|
+
process.exit(0);
|
|
272
|
+
});
|
|
273
|
+
// Force exit after 3s if graceful close hangs
|
|
274
|
+
setTimeout(() => process.exit(0), 3000);
|
|
275
|
+
}
|
|
276
|
+
process.on('SIGTERM', shutdown);
|
|
277
|
+
process.on('SIGINT', shutdown);
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Start
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
httpServer.listen(PORT, '0.0.0.0', () => {
|
|
282
|
+
console.log(`devremote server listening on port ${PORT}`);
|
|
283
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IPty } from 'node-pty';
|
|
2
|
+
export interface TerminalSession {
|
|
3
|
+
pty: IPty;
|
|
4
|
+
onData: (cb: (data: string) => void) => void;
|
|
5
|
+
onExit: (cb: (code: number) => void) => void;
|
|
6
|
+
write: (data: string) => void;
|
|
7
|
+
resize: (cols: number, rows: number) => void;
|
|
8
|
+
kill: () => void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Spawn a PTY that attaches to the devremote tmux session.
|
|
12
|
+
* If the session doesn't exist, falls back to a plain shell.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createTerminalSession(cols?: number, rows?: number): TerminalSession;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as nodePty from 'node-pty';
|
|
2
|
+
/**
|
|
3
|
+
* Spawn a PTY that attaches to the devremote tmux session.
|
|
4
|
+
* If the session doesn't exist, falls back to a plain shell.
|
|
5
|
+
*/
|
|
6
|
+
export function createTerminalSession(cols = 80, rows = 24) {
|
|
7
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
8
|
+
const pty = nodePty.spawn('tmux', ['attach-session', '-t', 'devremote'], {
|
|
9
|
+
name: 'xterm-256color',
|
|
10
|
+
cols,
|
|
11
|
+
rows,
|
|
12
|
+
cwd: process.env.HOME || '/',
|
|
13
|
+
env: process.env,
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
pty,
|
|
17
|
+
onData(cb) {
|
|
18
|
+
pty.onData(cb);
|
|
19
|
+
},
|
|
20
|
+
onExit(cb) {
|
|
21
|
+
pty.onExit(({ exitCode }) => cb(exitCode));
|
|
22
|
+
},
|
|
23
|
+
write(data) {
|
|
24
|
+
pty.write(data);
|
|
25
|
+
},
|
|
26
|
+
resize(c, r) {
|
|
27
|
+
try {
|
|
28
|
+
pty.resize(c, r);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Ignore resize errors on dead PTY
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
kill() {
|
|
35
|
+
try {
|
|
36
|
+
pty.kill();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Already dead
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface BatteryInfo {
|
|
2
|
+
percent: number;
|
|
3
|
+
charging: boolean;
|
|
4
|
+
present: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read current battery state.
|
|
8
|
+
* On Linux: reads from /sys/class/power_supply/BAT0.
|
|
9
|
+
* On macOS: parses `pmset -g batt` output.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getBatteryInfo(): BatteryInfo;
|
|
12
|
+
/**
|
|
13
|
+
* Spawn a sleep-inhibiting process in the background.
|
|
14
|
+
* Linux: systemd-inhibit. macOS: caffeinate.
|
|
15
|
+
* Saves the spawned PID to INHIBIT_PID_PATH.
|
|
16
|
+
*/
|
|
17
|
+
export declare function inhibitSleep(): void;
|
|
18
|
+
/**
|
|
19
|
+
* Release the sleep inhibitor by killing the process whose PID is in INHIBIT_PID_PATH.
|
|
20
|
+
*/
|
|
21
|
+
export declare function releaseSleep(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Start a battery watcher that periodically checks battery state and sends
|
|
24
|
+
* ntfy notifications on low battery, unplug, or replug events.
|
|
25
|
+
* Writes its own PID to BATTERY_PID_PATH.
|
|
26
|
+
*/
|
|
27
|
+
export declare function startBatteryWatcher(): void;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { platform } from 'os';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { run } from './shell.js';
|
|
5
|
+
import { writePid, killByPidFile } from './process.js';
|
|
6
|
+
import { INHIBIT_PID_PATH, BATTERY_PID_PATH } from './config.js';
|
|
7
|
+
import { sendNotification } from './ntfy.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Battery info
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* Read current battery state.
|
|
13
|
+
* On Linux: reads from /sys/class/power_supply/BAT0.
|
|
14
|
+
* On macOS: parses `pmset -g batt` output.
|
|
15
|
+
*/
|
|
16
|
+
export function getBatteryInfo() {
|
|
17
|
+
const os = platform();
|
|
18
|
+
if (os === 'linux') {
|
|
19
|
+
return getLinuxBatteryInfo();
|
|
20
|
+
}
|
|
21
|
+
if (os === 'darwin') {
|
|
22
|
+
return getMacBatteryInfo();
|
|
23
|
+
}
|
|
24
|
+
// Unsupported platform — return a safe default
|
|
25
|
+
return { percent: 100, charging: true, present: false };
|
|
26
|
+
}
|
|
27
|
+
function getLinuxBatteryInfo() {
|
|
28
|
+
const basePath = '/sys/class/power_supply/BAT0';
|
|
29
|
+
if (!existsSync(basePath)) {
|
|
30
|
+
return { percent: 100, charging: true, present: false };
|
|
31
|
+
}
|
|
32
|
+
const readSys = (file) => {
|
|
33
|
+
try {
|
|
34
|
+
return readFileSync(`${basePath}/${file}`, 'utf8').trim();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const capacityRaw = readSys('capacity');
|
|
41
|
+
const statusRaw = readSys('status');
|
|
42
|
+
const percent = parseInt(capacityRaw, 10);
|
|
43
|
+
const charging = statusRaw === 'Charging' || statusRaw === 'Full';
|
|
44
|
+
return {
|
|
45
|
+
percent: isNaN(percent) ? 100 : percent,
|
|
46
|
+
charging,
|
|
47
|
+
present: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function getMacBatteryInfo() {
|
|
51
|
+
try {
|
|
52
|
+
const output = run('pmset', ['-g', 'batt']);
|
|
53
|
+
// Example line: "Now drawing from 'Battery Power'"
|
|
54
|
+
const charging = output.includes('AC Power') || output.includes('charging');
|
|
55
|
+
// Example: "100%;"
|
|
56
|
+
const percentMatch = output.match(/(\d+)%/);
|
|
57
|
+
const percent = percentMatch ? parseInt(percentMatch[1], 10) : 100;
|
|
58
|
+
return { percent, charging, present: true };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { percent: 100, charging: true, present: false };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Sleep inhibit
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Spawn a sleep-inhibiting process in the background.
|
|
69
|
+
* Linux: systemd-inhibit. macOS: caffeinate.
|
|
70
|
+
* Saves the spawned PID to INHIBIT_PID_PATH.
|
|
71
|
+
*/
|
|
72
|
+
export function inhibitSleep() {
|
|
73
|
+
const os = platform();
|
|
74
|
+
let cmd;
|
|
75
|
+
let args;
|
|
76
|
+
if (os === 'linux') {
|
|
77
|
+
cmd = 'systemd-inhibit';
|
|
78
|
+
args = ['--what=sleep:idle', '--who=rms-devremote', '--why=Remote session active', 'sleep', 'infinity'];
|
|
79
|
+
}
|
|
80
|
+
else if (os === 'darwin') {
|
|
81
|
+
cmd = 'caffeinate';
|
|
82
|
+
args = ['-i'];
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
throw new Error(`Sleep inhibit not supported on platform: ${os}`);
|
|
86
|
+
}
|
|
87
|
+
const child = spawn(cmd, args, {
|
|
88
|
+
detached: true,
|
|
89
|
+
stdio: 'ignore',
|
|
90
|
+
});
|
|
91
|
+
child.unref();
|
|
92
|
+
if (child.pid === undefined) {
|
|
93
|
+
throw new Error('Failed to spawn sleep inhibitor: no PID returned');
|
|
94
|
+
}
|
|
95
|
+
writePid(INHIBIT_PID_PATH, child.pid);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Release the sleep inhibitor by killing the process whose PID is in INHIBIT_PID_PATH.
|
|
99
|
+
*/
|
|
100
|
+
export function releaseSleep() {
|
|
101
|
+
killByPidFile(INHIBIT_PID_PATH);
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Battery watcher
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
const LOW_BATTERY_THRESHOLD = 20;
|
|
107
|
+
const CRITICAL_BATTERY_THRESHOLD = 10;
|
|
108
|
+
/**
|
|
109
|
+
* Start a battery watcher that periodically checks battery state and sends
|
|
110
|
+
* ntfy notifications on low battery, unplug, or replug events.
|
|
111
|
+
* Writes its own PID to BATTERY_PID_PATH.
|
|
112
|
+
*/
|
|
113
|
+
export function startBatteryWatcher() {
|
|
114
|
+
writePid(BATTERY_PID_PATH, process.pid);
|
|
115
|
+
let previousCharging = null;
|
|
116
|
+
let lowNotified = false;
|
|
117
|
+
let criticalNotified = false;
|
|
118
|
+
const check = async () => {
|
|
119
|
+
const info = getBatteryInfo();
|
|
120
|
+
if (!info.present)
|
|
121
|
+
return;
|
|
122
|
+
// Detect charging state change
|
|
123
|
+
if (previousCharging !== null && previousCharging !== info.charging) {
|
|
124
|
+
if (info.charging) {
|
|
125
|
+
await sendNotification(`Laptop plugged in — battery at ${info.percent}%`, 'low');
|
|
126
|
+
// Reset low-battery notifications on replug
|
|
127
|
+
lowNotified = false;
|
|
128
|
+
criticalNotified = false;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
await sendNotification(`Laptop unplugged — battery at ${info.percent}%`, 'default');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
previousCharging = info.charging;
|
|
135
|
+
// Low battery alerts (only when discharging)
|
|
136
|
+
if (!info.charging) {
|
|
137
|
+
if (info.percent <= CRITICAL_BATTERY_THRESHOLD && !criticalNotified) {
|
|
138
|
+
await sendNotification(`CRITICAL: Battery at ${info.percent}% — connect charger now!`, 'urgent');
|
|
139
|
+
criticalNotified = true;
|
|
140
|
+
}
|
|
141
|
+
else if (info.percent <= LOW_BATTERY_THRESHOLD &&
|
|
142
|
+
!lowNotified &&
|
|
143
|
+
!criticalNotified) {
|
|
144
|
+
await sendNotification(`Low battery: ${info.percent}% remaining`, 'high');
|
|
145
|
+
lowNotified = true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
// Run immediately then on interval
|
|
150
|
+
void check();
|
|
151
|
+
setInterval(() => { void check(); }, 60_000);
|
|
152
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export declare const DATA_DIR: string;
|
|
2
|
+
export declare const CONFIG_PATH: string;
|
|
3
|
+
export declare const ENV_PATH: string;
|
|
4
|
+
export declare const HOOKS_PATH: string;
|
|
5
|
+
export declare const PIN_HASH_PATH: string;
|
|
6
|
+
export declare const DOCKER_COMPOSE_PATH: string;
|
|
7
|
+
export declare const NTFY_CONFIG_PATH: string;
|
|
8
|
+
export declare const NOTIFY_SCRIPT_PATH: string;
|
|
9
|
+
export declare const INHIBIT_PID_PATH: string;
|
|
10
|
+
export declare const BATTERY_PID_PATH: string;
|
|
11
|
+
export interface Config {
|
|
12
|
+
domains: {
|
|
13
|
+
terminal: string;
|
|
14
|
+
notify: string;
|
|
15
|
+
};
|
|
16
|
+
ttyd: {
|
|
17
|
+
port: number;
|
|
18
|
+
};
|
|
19
|
+
ntfy: {
|
|
20
|
+
port: number;
|
|
21
|
+
};
|
|
22
|
+
battery: {
|
|
23
|
+
lowThreshold: number;
|
|
24
|
+
checkInterval: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface EnvVars {
|
|
28
|
+
CLOUDFLARE_TUNNEL_TOKEN: string;
|
|
29
|
+
TERMINAL_DOMAIN: string;
|
|
30
|
+
NOTIFY_DOMAIN: string;
|
|
31
|
+
TTYD_USER: string;
|
|
32
|
+
TTYD_PASSWORD: string;
|
|
33
|
+
NTFY_TOPIC: string;
|
|
34
|
+
NTFY_ADMIN_PASSWORD: string;
|
|
35
|
+
NTFY_PORT: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ensure the DATA_DIR (~/.rms-devremote/) exists.
|
|
39
|
+
* Creates it (and any parent directories) if it does not.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureDataDir(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Returns true when setup has been completed (config.json and .env both exist).
|
|
44
|
+
*/
|
|
45
|
+
export declare function isSetupDone(): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Read and parse config.json. Throws if the file does not exist.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readConfig(): Config;
|
|
50
|
+
/**
|
|
51
|
+
* Serialize and write config.json (pretty-printed, 2-space indent).
|
|
52
|
+
*/
|
|
53
|
+
export declare function writeConfig(config: Config): void;
|
|
54
|
+
/**
|
|
55
|
+
* Read and parse the .env file into an EnvVars object.
|
|
56
|
+
* Lines that are empty or start with '#' are ignored.
|
|
57
|
+
*/
|
|
58
|
+
export declare function readEnv(): Partial<EnvVars>;
|
|
59
|
+
/**
|
|
60
|
+
* Serialize an EnvVars object and write it to the .env file.
|
|
61
|
+
* Each entry is written as KEY="value".
|
|
62
|
+
*/
|
|
63
|
+
export declare function writeEnv(env: Partial<EnvVars>): void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Path constants
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export const DATA_DIR = join(homedir(), '.rms-devremote');
|
|
8
|
+
export const CONFIG_PATH = join(DATA_DIR, 'config.json');
|
|
9
|
+
export const ENV_PATH = join(DATA_DIR, '.env');
|
|
10
|
+
export const HOOKS_PATH = join(DATA_DIR, 'hooks.json');
|
|
11
|
+
export const PIN_HASH_PATH = join(DATA_DIR, 'pin.hash');
|
|
12
|
+
export const DOCKER_COMPOSE_PATH = join(DATA_DIR, 'docker-compose.yml');
|
|
13
|
+
export const NTFY_CONFIG_PATH = join(DATA_DIR, 'ntfy', 'server.yml');
|
|
14
|
+
export const NOTIFY_SCRIPT_PATH = join(DATA_DIR, 'notify.sh');
|
|
15
|
+
export const INHIBIT_PID_PATH = join(DATA_DIR, 'inhibit.pid');
|
|
16
|
+
export const BATTERY_PID_PATH = join(DATA_DIR, 'battery.pid');
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Ensure the DATA_DIR (~/.rms-devremote/) exists.
|
|
22
|
+
* Creates it (and any parent directories) if it does not.
|
|
23
|
+
*/
|
|
24
|
+
export function ensureDataDir() {
|
|
25
|
+
if (!existsSync(DATA_DIR)) {
|
|
26
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Returns true when setup has been completed (config.json and .env both exist).
|
|
31
|
+
*/
|
|
32
|
+
export function isSetupDone() {
|
|
33
|
+
return existsSync(CONFIG_PATH) && existsSync(ENV_PATH);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read and parse config.json. Throws if the file does not exist.
|
|
37
|
+
*/
|
|
38
|
+
export function readConfig() {
|
|
39
|
+
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Serialize and write config.json (pretty-printed, 2-space indent).
|
|
44
|
+
*/
|
|
45
|
+
export function writeConfig(config) {
|
|
46
|
+
ensureDataDir();
|
|
47
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read and parse the .env file into an EnvVars object.
|
|
51
|
+
* Lines that are empty or start with '#' are ignored.
|
|
52
|
+
*/
|
|
53
|
+
export function readEnv() {
|
|
54
|
+
if (!existsSync(ENV_PATH))
|
|
55
|
+
return {};
|
|
56
|
+
const lines = readFileSync(ENV_PATH, 'utf8').split('\n');
|
|
57
|
+
const result = {};
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
61
|
+
continue;
|
|
62
|
+
const eqIndex = trimmed.indexOf('=');
|
|
63
|
+
if (eqIndex === -1)
|
|
64
|
+
continue;
|
|
65
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
66
|
+
// Strip optional surrounding quotes from the value
|
|
67
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
68
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
69
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
70
|
+
value = value.slice(1, -1);
|
|
71
|
+
}
|
|
72
|
+
result[key] = value;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Serialize an EnvVars object and write it to the .env file.
|
|
78
|
+
* Each entry is written as KEY="value".
|
|
79
|
+
*/
|
|
80
|
+
export function writeEnv(env) {
|
|
81
|
+
ensureDataDir();
|
|
82
|
+
const lines = Object.entries(env).map(([k, v]) => `${k}="${v ?? ''}"`);
|
|
83
|
+
writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
|
|
84
|
+
}
|