@bakapiano/ccsm 0.17.11 → 0.18.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/lib/devices.js +215 -0
- package/lib/tunnel.js +253 -0
- package/package.json +1 -1
- package/public/css/layout.css +7 -0
- package/public/css/responsive.css +123 -3
- package/public/css/terminals.css +15 -1
- package/public/css/wco.css +14 -13
- package/public/css/widgets.css +276 -2
- package/public/js/api.js +43 -2
- package/public/js/backend.js +66 -10
- package/public/js/components/App.js +38 -2
- package/public/js/components/HealthOverlay.js +12 -0
- package/public/js/components/MobileNavFab.js +29 -0
- package/public/js/components/PendingApprovalOverlay.js +86 -0
- package/public/js/components/Sidebar.js +13 -4
- package/public/js/components/TerminalView.js +19 -3
- package/public/js/icons.js +24 -0
- package/public/js/main.js +94 -11
- package/public/js/pages/RemotePage.js +446 -0
- package/public/js/state.js +10 -0
- package/scripts/dev.js +11 -0
- package/server.js +214 -8
package/lib/devices.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Remote-device approval store. Each browser that arrives via the tunnel
|
|
4
|
+
// generates a UUID client-side (`ccsm.deviceId` in localStorage) and sends
|
|
5
|
+
// it as `X-Device-Id` on every API call. server.js' middleware feeds those
|
|
6
|
+
// arrivals through record() — known devices get their lastSeen bumped,
|
|
7
|
+
// new ones get inserted as `pending`. Until the host explicitly Approves
|
|
8
|
+
// from the Remote page, every non-loopback request returns 403 with the
|
|
9
|
+
// pending status.
|
|
10
|
+
//
|
|
11
|
+
// Stored at ~/.ccsm/devices.json:
|
|
12
|
+
// {
|
|
13
|
+
// "<uuid>": {
|
|
14
|
+
// id, status: 'pending'|'approved'|'rejected',
|
|
15
|
+
// userAgent, ip,
|
|
16
|
+
// firstSeen, lastSeen, approvedAt,
|
|
17
|
+
// label // user-set or auto-derived from UA
|
|
18
|
+
// },
|
|
19
|
+
// ...
|
|
20
|
+
// }
|
|
21
|
+
|
|
22
|
+
const { DATA_DIR } = require('./config');
|
|
23
|
+
const { createKeyedJsonStore } = require('./jsonStore');
|
|
24
|
+
const { withFileLock } = require('./atomicJson');
|
|
25
|
+
|
|
26
|
+
const store = createKeyedJsonStore({
|
|
27
|
+
dataDir: DATA_DIR,
|
|
28
|
+
filename: 'devices.json',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// `record()` runs on EVERY non-loopback API request. Two side effects
|
|
32
|
+
// without these guards:
|
|
33
|
+
// 1. Concurrent calls each do load→mutate→save independently; the
|
|
34
|
+
// parallel rename(tmp → target) collides on Windows and surfaces
|
|
35
|
+
// as `EPERM: operation not permitted`.
|
|
36
|
+
// 2. Even when serialized, writing on every request hammers the disk
|
|
37
|
+
// for a value that only needs ~minute-grained accuracy (lastSeen
|
|
38
|
+
// drives "seen 5m ago" labels in the UI).
|
|
39
|
+
// Fix: serialize all mutators through the shared per-file lock, and
|
|
40
|
+
// short-circuit lastSeen-only updates that landed within MIN_FLUSH_MS
|
|
41
|
+
// of the last persisted write for the same id.
|
|
42
|
+
const MIN_FLUSH_MS = 15_000;
|
|
43
|
+
const lastFlushAt = new Map(); // id → ms timestamp of last save
|
|
44
|
+
|
|
45
|
+
// Pending entries older than 24h are auto-pruned on each list() so a
|
|
46
|
+
// drive-by scanner doesn't grow the file forever. Rejected entries kept
|
|
47
|
+
// 1h so the host can see what got bounced and rename / un-reject if
|
|
48
|
+
// they realize it was legit.
|
|
49
|
+
const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
const REJECTED_TTL_MS = 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
// Quick UA → human-readable label. We keep this tiny on purpose — full
|
|
53
|
+
// UA parsing libraries are huge and the only consumer is one line in
|
|
54
|
+
// the approval UI. Order matters: Edge UA includes "Chrome" so detect
|
|
55
|
+
// Edge first.
|
|
56
|
+
function describeUA(ua) {
|
|
57
|
+
ua = String(ua || '');
|
|
58
|
+
const device =
|
|
59
|
+
/iPhone/.test(ua) ? 'iPhone'
|
|
60
|
+
: /iPad/.test(ua) ? 'iPad'
|
|
61
|
+
: /Android/.test(ua) ? 'Android'
|
|
62
|
+
: /Mac OS X/.test(ua) ? 'Mac'
|
|
63
|
+
: /Windows/.test(ua) ? 'Windows'
|
|
64
|
+
: /Linux/.test(ua) ? 'Linux'
|
|
65
|
+
: null;
|
|
66
|
+
const browser =
|
|
67
|
+
/Edg\//.test(ua) ? 'Edge'
|
|
68
|
+
: /OPR\//.test(ua) ? 'Opera'
|
|
69
|
+
: /Chrome\//.test(ua) ? 'Chrome'
|
|
70
|
+
: /Firefox\//.test(ua) ? 'Firefox'
|
|
71
|
+
: /Safari\//.test(ua) ? 'Safari'
|
|
72
|
+
: null;
|
|
73
|
+
if (device && browser) return `${device} · ${browser}`;
|
|
74
|
+
if (device) return device;
|
|
75
|
+
if (browser) return browser;
|
|
76
|
+
return 'Unknown device';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function pruneStale(map) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
let dirty = false;
|
|
82
|
+
for (const [id, d] of Object.entries(map)) {
|
|
83
|
+
if (d.status === 'pending' && now - d.firstSeen > PENDING_TTL_MS) { delete map[id]; dirty = true; }
|
|
84
|
+
if (d.status === 'rejected' && now - (d.rejectedAt || d.lastSeen) > REJECTED_TTL_MS) { delete map[id]; dirty = true; }
|
|
85
|
+
}
|
|
86
|
+
if (dirty) await store.save(map);
|
|
87
|
+
return map;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Upsert. Returns the (possibly newly-created) device record. Caller
|
|
91
|
+
// uses .status to decide whether to gate further work.
|
|
92
|
+
async function record(id, { userAgent, ip } = {}) {
|
|
93
|
+
if (!id) throw new Error('device id required');
|
|
94
|
+
return withFileLock(store.filePath, async () => {
|
|
95
|
+
const map = await store.load();
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const existing = map[id];
|
|
98
|
+
if (existing) {
|
|
99
|
+
// Throttled lastSeen update: if the only thing that would change
|
|
100
|
+
// is lastSeen and we flushed for this id within MIN_FLUSH_MS,
|
|
101
|
+
// return the in-memory copy without touching disk. Saves a
|
|
102
|
+
// disk write per request when remote pages poll at 2.5s.
|
|
103
|
+
const onlyLastSeen = (!userAgent || existing.userAgent) && (!ip || existing.ip === ip);
|
|
104
|
+
const recentlyFlushed = (now - (lastFlushAt.get(id) || 0)) < MIN_FLUSH_MS;
|
|
105
|
+
existing.lastSeen = now;
|
|
106
|
+
if (userAgent && !existing.userAgent) existing.userAgent = userAgent;
|
|
107
|
+
if (ip) existing.ip = ip;
|
|
108
|
+
if (onlyLastSeen && recentlyFlushed) return existing;
|
|
109
|
+
await store.save(map);
|
|
110
|
+
lastFlushAt.set(id, now);
|
|
111
|
+
return existing;
|
|
112
|
+
}
|
|
113
|
+
map[id] = {
|
|
114
|
+
id,
|
|
115
|
+
status: 'pending',
|
|
116
|
+
userAgent: userAgent || null,
|
|
117
|
+
ip: ip || null,
|
|
118
|
+
firstSeen: now,
|
|
119
|
+
lastSeen: now,
|
|
120
|
+
approvedAt: null,
|
|
121
|
+
rejectedAt: null,
|
|
122
|
+
label: describeUA(userAgent),
|
|
123
|
+
};
|
|
124
|
+
await store.save(map);
|
|
125
|
+
lastFlushAt.set(id, now);
|
|
126
|
+
return map[id];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function get(id) {
|
|
131
|
+
const map = await store.load();
|
|
132
|
+
return map[id] || null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function isApproved(id) {
|
|
136
|
+
const d = await get(id);
|
|
137
|
+
return !!(d && d.status === 'approved');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function approve(id, label) {
|
|
141
|
+
return withFileLock(store.filePath, async () => {
|
|
142
|
+
const map = await store.load();
|
|
143
|
+
const d = map[id];
|
|
144
|
+
if (!d) return null;
|
|
145
|
+
d.status = 'approved';
|
|
146
|
+
d.approvedAt = Date.now();
|
|
147
|
+
d.rejectedAt = null;
|
|
148
|
+
if (label) d.label = String(label);
|
|
149
|
+
await store.save(map);
|
|
150
|
+
lastFlushAt.set(id, Date.now());
|
|
151
|
+
return d;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function reject(id) {
|
|
156
|
+
return withFileLock(store.filePath, async () => {
|
|
157
|
+
const map = await store.load();
|
|
158
|
+
const d = map[id];
|
|
159
|
+
if (!d) return null;
|
|
160
|
+
d.status = 'rejected';
|
|
161
|
+
d.rejectedAt = Date.now();
|
|
162
|
+
d.approvedAt = null;
|
|
163
|
+
await store.save(map);
|
|
164
|
+
lastFlushAt.set(id, Date.now());
|
|
165
|
+
return d;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Identical to reject in storage terms, but separate API name so the UI
|
|
170
|
+
// can distinguish "I'm declining a new request" from "I'm taking back
|
|
171
|
+
// access from someone I'd previously approved". Both end up status:
|
|
172
|
+
// 'rejected' and clear approvedAt — once cleared, the device must
|
|
173
|
+
// request again from scratch.
|
|
174
|
+
async function revoke(id) {
|
|
175
|
+
return reject(id);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function rename(id, label) {
|
|
179
|
+
return withFileLock(store.filePath, async () => {
|
|
180
|
+
const map = await store.load();
|
|
181
|
+
const d = map[id];
|
|
182
|
+
if (!d) return null;
|
|
183
|
+
d.label = String(label || '').slice(0, 60);
|
|
184
|
+
await store.save(map);
|
|
185
|
+
return d;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function remove(id) {
|
|
190
|
+
return store.remove(id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function list() {
|
|
194
|
+
const map = await pruneStale(await store.load());
|
|
195
|
+
return Object.values(map).sort((a, b) => {
|
|
196
|
+
// Pending first (so the host sees them at the top), then approved
|
|
197
|
+
// by approvedAt desc, then rejected by rejectedAt desc.
|
|
198
|
+
const order = { pending: 0, approved: 1, rejected: 2 };
|
|
199
|
+
if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
|
|
200
|
+
return (b.lastSeen || 0) - (a.lastSeen || 0);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
record,
|
|
206
|
+
get,
|
|
207
|
+
isApproved,
|
|
208
|
+
approve,
|
|
209
|
+
reject,
|
|
210
|
+
revoke,
|
|
211
|
+
rename,
|
|
212
|
+
remove,
|
|
213
|
+
list,
|
|
214
|
+
describeUA,
|
|
215
|
+
};
|
package/lib/tunnel.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tunnel manager · spawns and supervises a cloudflared or devtunnel
|
|
4
|
+
// child to expose the local ccsm backend over a public URL. Captures
|
|
5
|
+
// the URL from stdout, exposes state to the API, and tears down the
|
|
6
|
+
// child on stop / server shutdown.
|
|
7
|
+
//
|
|
8
|
+
// Two providers, each with their own CLI quirk:
|
|
9
|
+
// cloudflared · `cloudflared tunnel --url http://localhost:<port>`
|
|
10
|
+
// Prints `https://*.trycloudflare.com` somewhere in
|
|
11
|
+
// the boot banner. No login required for quick tunnels.
|
|
12
|
+
// devtunnel · `devtunnel host -p <port> --allow-anonymous`
|
|
13
|
+
// Prints `Connect via browser: https://*.devtunnels.ms`.
|
|
14
|
+
// Host must be logged in (`devtunnel user login`).
|
|
15
|
+
//
|
|
16
|
+
// Discovery: scan PATH first via `where.exe`, then known winget install
|
|
17
|
+
// dirs. Returns the absolute path so we can spawn the child regardless
|
|
18
|
+
// of whether the post-install PATH refresh has reached this Node process.
|
|
19
|
+
|
|
20
|
+
const { spawn, execFileSync } = require('node:child_process');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
|
|
25
|
+
const PROVIDERS = {
|
|
26
|
+
cloudflared: {
|
|
27
|
+
id: 'cloudflared',
|
|
28
|
+
label: 'Cloudflare Tunnel',
|
|
29
|
+
wingetId: 'Cloudflare.cloudflared',
|
|
30
|
+
binary: 'cloudflared.exe',
|
|
31
|
+
knownPaths: [
|
|
32
|
+
path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'cloudflared', 'cloudflared.exe'),
|
|
33
|
+
path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'cloudflared', 'cloudflared.exe'),
|
|
34
|
+
],
|
|
35
|
+
args: (port) => ['tunnel', '--url', `http://localhost:${port}`],
|
|
36
|
+
urlRegex: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i,
|
|
37
|
+
},
|
|
38
|
+
devtunnel: {
|
|
39
|
+
id: 'devtunnel',
|
|
40
|
+
label: 'Microsoft Dev Tunnel',
|
|
41
|
+
wingetId: 'Microsoft.devtunnel',
|
|
42
|
+
binary: 'devtunnel.exe',
|
|
43
|
+
knownPaths: [
|
|
44
|
+
path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages',
|
|
45
|
+
'Microsoft.devtunnel_Microsoft.Winget.Source_8wekyb3d8bbwe', 'devtunnel.exe'),
|
|
46
|
+
],
|
|
47
|
+
args: (port) => ['host', '-p', String(port), '--allow-anonymous'],
|
|
48
|
+
urlRegex: /https:\/\/[a-z0-9-]+\.[a-z0-9-]+\.devtunnels\.ms/i,
|
|
49
|
+
needsLogin: true,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// In-memory state. Single tunnel at a time — switching providers tears
|
|
54
|
+
// down the old one first.
|
|
55
|
+
let current = null; // { provider, child, url, startedAt, log: string[] }
|
|
56
|
+
let token = null; // Remote-access bearer token. Null = no remote
|
|
57
|
+
// access enforced. Set via setToken() or by the
|
|
58
|
+
// start() call. Server.js middleware reads via
|
|
59
|
+
// getToken().
|
|
60
|
+
|
|
61
|
+
function getToken() { return token; }
|
|
62
|
+
function setToken(t) { token = t ? String(t) : null; return token; }
|
|
63
|
+
|
|
64
|
+
function findBinary(provider) {
|
|
65
|
+
const p = PROVIDERS[provider];
|
|
66
|
+
if (!p) return null;
|
|
67
|
+
// PATH lookup via where.exe — works regardless of how the CLI got
|
|
68
|
+
// installed (winget, choco, manual, in-tree).
|
|
69
|
+
try {
|
|
70
|
+
const out = execFileSync('where.exe', [p.binary], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
71
|
+
.toString().trim().split(/\r?\n/)[0];
|
|
72
|
+
if (out && fs.existsSync(out)) return out;
|
|
73
|
+
} catch { /* not on PATH */ }
|
|
74
|
+
// Fall back to known install locations (winget's PATH update doesn't
|
|
75
|
+
// reach the already-running Node process).
|
|
76
|
+
for (const candidate of p.knownPaths) {
|
|
77
|
+
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
78
|
+
}
|
|
79
|
+
// For devtunnel: winget's package dir has a version suffix that
|
|
80
|
+
// changes between releases. Glob it.
|
|
81
|
+
if (provider === 'devtunnel') {
|
|
82
|
+
const base = path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'WinGet', 'Packages');
|
|
83
|
+
try {
|
|
84
|
+
for (const entry of fs.readdirSync(base)) {
|
|
85
|
+
if (entry.startsWith('Microsoft.devtunnel_')) {
|
|
86
|
+
const candidate = path.join(base, entry, 'devtunnel.exe');
|
|
87
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getVersion(provider, exe) {
|
|
96
|
+
try {
|
|
97
|
+
const out = execFileSync(exe, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
98
|
+
.toString().trim().split(/\r?\n/)[0];
|
|
99
|
+
return out || null;
|
|
100
|
+
} catch { return null; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function checkDevtunnelLogin(exe) {
|
|
104
|
+
try {
|
|
105
|
+
const out = execFileSync(exe, ['user', 'show'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 })
|
|
106
|
+
.toString().trim();
|
|
107
|
+
// "Logged in as <email> using <provider>." vs "Not logged in"
|
|
108
|
+
const m = out.match(/Logged in as (\S+)/);
|
|
109
|
+
if (m) return { loggedIn: true, user: m[1] };
|
|
110
|
+
return { loggedIn: false, user: null };
|
|
111
|
+
} catch {
|
|
112
|
+
return { loggedIn: false, user: null };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Probe is cached. Each cold call shells out 4-6 sync execs (where.exe,
|
|
117
|
+
// --version per provider, `devtunnel user show`) — cumulatively ~1s of
|
|
118
|
+
// blocked event loop on Windows. The Remote page polls /api/tunnel/status
|
|
119
|
+
// every 2.5s, and tunnel.start() returns a fresh status() — without this
|
|
120
|
+
// cache, the loop is frozen ~40% of the time during normal operation,
|
|
121
|
+
// and /api/health probes from other clients time out.
|
|
122
|
+
const PROBE_TTL_MS = 30_000;
|
|
123
|
+
let probeCache = null;
|
|
124
|
+
let probeCacheAt = 0;
|
|
125
|
+
|
|
126
|
+
function probe(force = false) {
|
|
127
|
+
if (!force && probeCache && Date.now() - probeCacheAt < PROBE_TTL_MS) {
|
|
128
|
+
return probeCache;
|
|
129
|
+
}
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const id of Object.keys(PROVIDERS)) {
|
|
132
|
+
const exe = findBinary(id);
|
|
133
|
+
const p = { installed: !!exe, exe, version: exe ? getVersion(id, exe) : null };
|
|
134
|
+
if (id === 'devtunnel' && exe) {
|
|
135
|
+
Object.assign(p, checkDevtunnelLogin(exe));
|
|
136
|
+
}
|
|
137
|
+
out[id] = p;
|
|
138
|
+
}
|
|
139
|
+
probeCache = out;
|
|
140
|
+
probeCacheAt = Date.now();
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Invalidate the cache when callers know the on-disk state likely changed
|
|
145
|
+
// (post-install, post-login, etc.). Next probe() re-shells.
|
|
146
|
+
function invalidateProbe() { probeCache = null; probeCacheAt = 0; }
|
|
147
|
+
|
|
148
|
+
function status() {
|
|
149
|
+
return {
|
|
150
|
+
providers: probe(),
|
|
151
|
+
running: !!current,
|
|
152
|
+
provider: current?.provider || null,
|
|
153
|
+
url: current?.url || null,
|
|
154
|
+
startedAt: current?.startedAt || null,
|
|
155
|
+
pid: current?.child?.pid || null,
|
|
156
|
+
log: current?.log?.slice(-50) || [],
|
|
157
|
+
token,
|
|
158
|
+
// Token is echoed back so the Remote page can render the
|
|
159
|
+
// pre-built share URL. The route itself is token-protected
|
|
160
|
+
// (the middleware blocks non-loopback callers without it), so
|
|
161
|
+
// anyone reaching this endpoint already knows the token.
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Spawn the tunnel CLI. Resolves once we've parsed the public URL out
|
|
166
|
+
// of stdout (with a timeout safety net). Throws if the CLI isn't
|
|
167
|
+
// installed, the provider is unknown, or another tunnel is running.
|
|
168
|
+
async function start({ provider, port }) {
|
|
169
|
+
if (current) throw new Error('tunnel already running');
|
|
170
|
+
const p = PROVIDERS[provider];
|
|
171
|
+
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
172
|
+
const exe = findBinary(provider);
|
|
173
|
+
if (!exe) throw new Error(`${p.label} is not installed`);
|
|
174
|
+
if (provider === 'devtunnel') {
|
|
175
|
+
const { loggedIn } = checkDevtunnelLogin(exe);
|
|
176
|
+
if (!loggedIn) throw new Error('devtunnel requires login — run `devtunnel user login` first');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const args = p.args(port);
|
|
180
|
+
const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
181
|
+
const entry = { provider, child, url: null, startedAt: Date.now(), log: [] };
|
|
182
|
+
current = entry;
|
|
183
|
+
|
|
184
|
+
const pushLog = (line) => {
|
|
185
|
+
entry.log.push(line);
|
|
186
|
+
if (entry.log.length > 200) entry.log.shift();
|
|
187
|
+
if (!entry.url) {
|
|
188
|
+
const m = line.match(p.urlRegex);
|
|
189
|
+
if (m) entry.url = m[0];
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
child.stdout.setEncoding('utf8');
|
|
193
|
+
child.stderr.setEncoding('utf8');
|
|
194
|
+
child.stdout.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
|
|
195
|
+
child.stderr.on('data', (chunk) => chunk.split(/\r?\n/).forEach((l) => l && pushLog(l)));
|
|
196
|
+
|
|
197
|
+
child.on('exit', (code, signal) => {
|
|
198
|
+
if (current === entry) current = null;
|
|
199
|
+
console.log(`[tunnel] ${provider} exited · code=${code} signal=${signal || ''}`);
|
|
200
|
+
});
|
|
201
|
+
child.on('error', (err) => {
|
|
202
|
+
if (current === entry) current = null;
|
|
203
|
+
console.error(`[tunnel] ${provider} spawn error`, err);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Wait up to 25s for the URL to show up in stdout.
|
|
207
|
+
const deadline = Date.now() + 25_000;
|
|
208
|
+
while (Date.now() < deadline) {
|
|
209
|
+
if (entry.url) return status();
|
|
210
|
+
if (!current || current !== entry) {
|
|
211
|
+
throw new Error('tunnel exited before reporting a URL · ' + entry.log.slice(-3).join(' / '));
|
|
212
|
+
}
|
|
213
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
214
|
+
}
|
|
215
|
+
// Timed out — keep the child alive (the URL might appear later) but
|
|
216
|
+
// tell the caller we don't have one yet.
|
|
217
|
+
return status();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stop() {
|
|
221
|
+
if (!current) return false;
|
|
222
|
+
try { current.child.kill(); } catch {}
|
|
223
|
+
current = null;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Background install via winget. Returns immediately with the spawned
|
|
228
|
+
// pid; the actual install completes asynchronously. Caller polls
|
|
229
|
+
// probe() to learn when the binary appears on disk.
|
|
230
|
+
function installViaWinget(provider) {
|
|
231
|
+
const p = PROVIDERS[provider];
|
|
232
|
+
if (!p) throw new Error(`unknown provider: ${provider}`);
|
|
233
|
+
if (process.platform !== 'win32') throw new Error('winget install only supported on Windows');
|
|
234
|
+
const child = spawn('winget', [
|
|
235
|
+
'install', p.wingetId,
|
|
236
|
+
'--accept-source-agreements',
|
|
237
|
+
'--accept-package-agreements',
|
|
238
|
+
'--silent',
|
|
239
|
+
], { stdio: 'ignore', detached: true, windowsHide: true });
|
|
240
|
+
child.unref();
|
|
241
|
+
return { provider, pid: child.pid };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
PROVIDERS,
|
|
246
|
+
probe,
|
|
247
|
+
status,
|
|
248
|
+
start,
|
|
249
|
+
stop,
|
|
250
|
+
installViaWinget,
|
|
251
|
+
getToken,
|
|
252
|
+
setToken,
|
|
253
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/layout.css
CHANGED
|
@@ -32,6 +32,13 @@ body.is-resizing-sidebar .app {
|
|
|
32
32
|
min-height: 0;
|
|
33
33
|
padding: 0 var(--s-4);
|
|
34
34
|
gap: var(--s-2);
|
|
35
|
+
/* .settings-scroll inside uses `margin-right: -var(--s-4)` to bleed
|
|
36
|
+
past .main's padding so its scrollbar sits flush with the window
|
|
37
|
+
edge. Without this clip that 16-px bleed propagates up to <body>
|
|
38
|
+
and shows a permanent horizontal scrollbar on every page. Vertical
|
|
39
|
+
scrolling stays inside .settings-scroll itself (overflow-y:auto)
|
|
40
|
+
so this clip doesn't suppress anything legitimate. */
|
|
41
|
+
overflow-x: hidden;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
.page-head {
|
|
@@ -1,10 +1,130 @@
|
|
|
1
1
|
/* Narrow viewports: force-collapse sidebar, single-col config grid */
|
|
2
2
|
|
|
3
|
+
/* Narrow desktop / tablet viewports: stack the page-head, single-column
|
|
4
|
+
config grids. Sidebar stays at full width — user can still toggle it
|
|
5
|
+
manually via the collapse button. Phone-sized (≤ 640px) gets the FAB
|
|
6
|
+
drawer treatment below. */
|
|
3
7
|
@media (max-width: 900px) {
|
|
4
|
-
.app { grid-template-columns: var(--sidebar-w-collapsed) 1fr !important; }
|
|
5
|
-
.sidebar { padding: 0 var(--s-2); }
|
|
6
|
-
.brand-name, .nav-label, .nav-badge { opacity: 0; pointer-events: none; }
|
|
7
8
|
.main { padding: 0 var(--s-4); }
|
|
8
9
|
.page-head { flex-direction: column; gap: var(--s-3); }
|
|
9
10
|
.config-grid { grid-template-columns: 1fr; }
|
|
10
11
|
}
|
|
12
|
+
|
|
13
|
+
/* Phone viewports (≤ 640px): sidebar disappears entirely from the grid
|
|
14
|
+
layout; a circular floating button bottom-left toggles a full-screen
|
|
15
|
+
drawer that re-mounts the sidebar over everything else. */
|
|
16
|
+
@media (max-width: 640px) {
|
|
17
|
+
.app.is-mobile { grid-template-columns: 1fr !important; }
|
|
18
|
+
.app.is-mobile .sidebar {
|
|
19
|
+
/* Collapsed (drawer closed): out of the flow + invisible. */
|
|
20
|
+
position: fixed;
|
|
21
|
+
inset: 0;
|
|
22
|
+
z-index: 200;
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
transform: translateX(-100%);
|
|
26
|
+
transition: transform .22s cubic-bezier(.4, 0, .2, 1);
|
|
27
|
+
padding: var(--s-3);
|
|
28
|
+
background: var(--bg);
|
|
29
|
+
border-right: 0;
|
|
30
|
+
overflow-y: auto;
|
|
31
|
+
}
|
|
32
|
+
.app.is-mobile.drawer-open .sidebar { transform: translateX(0); }
|
|
33
|
+
/* Brand + nav labels come BACK on mobile — they were hidden by the
|
|
34
|
+
900px collapse rule above. */
|
|
35
|
+
.app.is-mobile .brand-name,
|
|
36
|
+
.app.is-mobile .nav-label,
|
|
37
|
+
.app.is-mobile .nav-badge { opacity: 1; pointer-events: auto; }
|
|
38
|
+
/* Sidebar drag handle isn't useful at full-screen; close button isn't
|
|
39
|
+
either since the FAB doubles as a toggle. */
|
|
40
|
+
.app.is-mobile .sidebar [aria-label="resize sidebar"] { display: none; }
|
|
41
|
+
.app.is-mobile .collapse-toggle { display: none; }
|
|
42
|
+
|
|
43
|
+
/* Main pane occupies full width — no sidebar gutter. */
|
|
44
|
+
.app.is-mobile .main { padding: 0 var(--s-3); }
|
|
45
|
+
/* Session pane edge-to-edge margin needs to match the new padding. */
|
|
46
|
+
.app.is-mobile .session-pane { margin: 0 calc(-1 * var(--s-3)); }
|
|
47
|
+
|
|
48
|
+
/* The bottom-left FAB (52px circle + 16px margin) overlaps content
|
|
49
|
+
near the bottom-left corner. Reserve room inside scroll containers
|
|
50
|
+
so the last button / row isn't trapped under it. */
|
|
51
|
+
.app.is-mobile .settings-scroll { padding-bottom: 80px; }
|
|
52
|
+
.app.is-mobile .remote-page { padding-bottom: 80px; }
|
|
53
|
+
/* Launch page is its own block (no scroll wrapper) — push it up too
|
|
54
|
+
so the Launch button doesn't end up under the FAB. */
|
|
55
|
+
.app.is-mobile [data-panel="launch"] { padding-bottom: 80px; }
|
|
56
|
+
.app.is-mobile [data-panel="about"] { padding-bottom: 80px; }
|
|
57
|
+
/* Sessions terminal: the .session-actions footer / tabs at top means
|
|
58
|
+
the FAB sits over the terminal corner — leave it; the user can
|
|
59
|
+
scroll the terminal independently. */
|
|
60
|
+
|
|
61
|
+
/* Long inline code (URLs, paths) in the About / Remote bodies break
|
|
62
|
+
out of card edges on a narrow viewport. Let them wrap on any
|
|
63
|
+
character instead of stretching the line. */
|
|
64
|
+
.settings-section code,
|
|
65
|
+
.card code,
|
|
66
|
+
.remote-fact code {
|
|
67
|
+
word-break: break-all;
|
|
68
|
+
white-space: normal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* iOS Safari + Edge auto-zoom the viewport when the user taps an
|
|
72
|
+
<input>/<textarea> whose font-size is < 16px — "terminal suddenly
|
|
73
|
+
goes huge" was almost certainly this. xterm.js's hidden
|
|
74
|
+
.xterm-helper-textarea sits at 12px CSS by default; bumping it to
|
|
75
|
+
16px stops the auto-zoom without affecting visible text (the
|
|
76
|
+
textarea is positioned at the cursor and overlapped by canvas
|
|
77
|
+
glyphs). Same defensive bump on every actual form input. */
|
|
78
|
+
.xterm-helper-textarea,
|
|
79
|
+
.input,
|
|
80
|
+
input[type="text"],
|
|
81
|
+
input[type="search"],
|
|
82
|
+
input[type="email"],
|
|
83
|
+
input[type="number"],
|
|
84
|
+
input[type="password"],
|
|
85
|
+
input[type="url"],
|
|
86
|
+
textarea,
|
|
87
|
+
select { font-size: 16px !important; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* FAB + backdrop · sit above page content but BELOW dialogs. */
|
|
91
|
+
.mobile-nav-fab {
|
|
92
|
+
position: fixed;
|
|
93
|
+
bottom: max(16px, env(safe-area-inset-bottom));
|
|
94
|
+
left: max(16px, env(safe-area-inset-left));
|
|
95
|
+
z-index: 210;
|
|
96
|
+
width: 52px;
|
|
97
|
+
height: 52px;
|
|
98
|
+
border-radius: 50%;
|
|
99
|
+
border: 1px solid var(--border-strong);
|
|
100
|
+
background: var(--bg-elev);
|
|
101
|
+
color: var(--ink);
|
|
102
|
+
display: inline-flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
justify-content: center;
|
|
105
|
+
box-shadow:
|
|
106
|
+
0 10px 24px -6px rgba(0,0,0,.28),
|
|
107
|
+
0 2px 4px rgba(0,0,0,.10);
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
transition: transform .15s, box-shadow .15s, background .15s;
|
|
110
|
+
}
|
|
111
|
+
.mobile-nav-fab:hover { transform: translateY(-1px); background: var(--bg); }
|
|
112
|
+
.mobile-nav-fab:active { transform: translateY(0); }
|
|
113
|
+
.mobile-nav-fab svg { width: 22px; height: 22px; stroke-width: 2; }
|
|
114
|
+
/* Open state stays the same paper white — only the icon swaps to X.
|
|
115
|
+
Keeping the colour consistent reads as "same button, different
|
|
116
|
+
mode" instead of two different controls. */
|
|
117
|
+
.mobile-nav-fab.is-open {
|
|
118
|
+
background: var(--bg-elev);
|
|
119
|
+
color: var(--ink);
|
|
120
|
+
border-color: var(--ink);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.mobile-nav-backdrop {
|
|
124
|
+
position: fixed;
|
|
125
|
+
inset: 0;
|
|
126
|
+
z-index: 199; /* below sidebar (200) + fab (210), above content */
|
|
127
|
+
background: rgba(26, 24, 21, 0.45);
|
|
128
|
+
backdrop-filter: blur(2px);
|
|
129
|
+
animation: panel-in .15s ease-out;
|
|
130
|
+
}
|
package/public/css/terminals.css
CHANGED
|
@@ -418,11 +418,25 @@
|
|
|
418
418
|
height: 100%;
|
|
419
419
|
}
|
|
420
420
|
.session-pane-body .terminal-empty {
|
|
421
|
-
background:
|
|
421
|
+
background: #1a1815;
|
|
422
|
+
color: #e8e3d5;
|
|
422
423
|
display: flex;
|
|
423
424
|
flex-direction: column;
|
|
424
425
|
align-items: center;
|
|
425
426
|
justify-content: center;
|
|
426
427
|
gap: var(--s-3);
|
|
427
428
|
height: 100%;
|
|
429
|
+
font-size: 13px;
|
|
430
|
+
}
|
|
431
|
+
.session-pane-body .terminal-empty .mono {
|
|
432
|
+
color: #e07b6e;
|
|
433
|
+
}
|
|
434
|
+
.session-pane-body .terminal-empty .action.primary {
|
|
435
|
+
background: #e8e3d5;
|
|
436
|
+
color: #1a1815;
|
|
437
|
+
border-color: #e8e3d5;
|
|
438
|
+
}
|
|
439
|
+
.session-pane-body .terminal-empty .action.primary:hover {
|
|
440
|
+
background: #faf9f5;
|
|
441
|
+
border-color: #faf9f5;
|
|
428
442
|
}
|