k0-board 0.1.1 → 0.2.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/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +163 -1
- package/README.md +223 -20
- package/bin/k0-board.js +37 -3
- package/menubar/K0MenuBar.swift +105 -22
- package/menubar/build.sh +7 -1
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/linux/tray.py +42 -2
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/platform/win32/tray.ps1 +11 -0
- package/server/changelog.js +242 -0
- package/server/db.js +58 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/index.js +122 -35
- package/server/machine.js +9 -0
- package/server/servers.js +481 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +162 -12
- package/web/board.js +278 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +9 -7
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
package/bin/k0-board.js
CHANGED
|
@@ -126,6 +126,31 @@ async function waitForServer(seconds = 10) {
|
|
|
126
126
|
return false
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// ── Opening the board ────────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Opens the board in a browser and answers with the address it used.
|
|
132
|
+
*
|
|
133
|
+
* `k0.localhost` is the name the board deserves, and inside a browser it is guaranteed to mean
|
|
134
|
+
* this machine — RFC 6761 reserves the whole of `.localhost` for loopback and every browser
|
|
135
|
+
* honours it. A resolver is under no such obligation, and on Windows usually declines. So the
|
|
136
|
+
* name is tried first, from here, and the number is what we fall back to: a board at a duller
|
|
137
|
+
* address beats a blank page at a prettier one.
|
|
138
|
+
*
|
|
139
|
+
* Not being able to open anything at all is not a failure. Installing over SSH, or on a machine
|
|
140
|
+
* with no session to put a window in, is an ordinary thing to do, and the install has already
|
|
141
|
+
* succeeded by the time we get here.
|
|
142
|
+
*/
|
|
143
|
+
async function openBoard() {
|
|
144
|
+
let url = `http://k0.localhost:${PORT}`
|
|
145
|
+
try {
|
|
146
|
+
await fetch(`${url}/api/status`, { signal: AbortSignal.timeout(1500) })
|
|
147
|
+
} catch {
|
|
148
|
+
url = `http://127.0.0.1:${PORT}`
|
|
149
|
+
}
|
|
150
|
+
await shell.openBrowser(url).catch(() => {})
|
|
151
|
+
return url
|
|
152
|
+
}
|
|
153
|
+
|
|
129
154
|
// ── install ──────────────────────────────────────────────────────────────────
|
|
130
155
|
async function install() {
|
|
131
156
|
say(`\n${bold('k0')} — the board that drives your Claude Code sessions`)
|
|
@@ -218,7 +243,8 @@ async function install() {
|
|
|
218
243
|
appDir: APP_DIR,
|
|
219
244
|
tray,
|
|
220
245
|
})
|
|
221
|
-
|
|
246
|
+
const up = await waitForServer()
|
|
247
|
+
if (up) ok(`k0 is up: ${bold(`http://k0.localhost:${PORT}`)}`)
|
|
222
248
|
else warn(`the server is not answering yet — look in ${paths.LOG_DIR}`)
|
|
223
249
|
|
|
224
250
|
// ── The two invitations ────────────────────────────────────────────────────
|
|
@@ -231,6 +257,12 @@ async function install() {
|
|
|
231
257
|
note(`Where: ${extra.target}`)
|
|
232
258
|
}
|
|
233
259
|
|
|
260
|
+
// Last of all, on purpose: by now the imported cards are on the board, so what opens is
|
|
261
|
+
// somebody's own work rather than an empty grid they would have to reload.
|
|
262
|
+
if (up && !flag('no-open')) {
|
|
263
|
+
say(`\n${bold('Done.')} The board is open at ${await openBoard()}`)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
234
266
|
say(`\n${bold('Done.')} The board is at http://k0.localhost:${PORT}`)
|
|
235
267
|
if (process.platform === 'win32') note(`If that name does not resolve, use http://127.0.0.1:${PORT}`)
|
|
236
268
|
}
|
|
@@ -334,16 +366,17 @@ async function uninstall() {
|
|
|
334
366
|
// ── start ────────────────────────────────────────────────────────────────────
|
|
335
367
|
/** Runs the server in the foreground and opens the board. No service, nothing installed. */
|
|
336
368
|
async function start() {
|
|
369
|
+
const open = !flag('no-open')
|
|
337
370
|
if (await alive()) {
|
|
338
371
|
ok(`k0 is already running on http://k0.localhost:${PORT}`)
|
|
339
|
-
|
|
372
|
+
if (open) await openBoard()
|
|
340
373
|
return
|
|
341
374
|
}
|
|
342
375
|
const child = spawn(process.execPath, [path.join(SOURCE, 'server', 'index.js')], {
|
|
343
376
|
stdio: 'inherit',
|
|
344
377
|
env: { ...process.env, K0_PORT: String(PORT) },
|
|
345
378
|
})
|
|
346
|
-
if (await waitForServer())
|
|
379
|
+
if ((await waitForServer()) && open) await openBoard()
|
|
347
380
|
child.on('exit', (code) => process.exit(code ?? 0))
|
|
348
381
|
}
|
|
349
382
|
|
|
@@ -434,6 +467,7 @@ ${bold('k0-board')} — a board of sticky notes that drives your Claude Code ses
|
|
|
434
467
|
Options
|
|
435
468
|
${bold('--yes')} do not ask, assume yes
|
|
436
469
|
${bold('--from-source')} point the service at this directory instead of copying it
|
|
470
|
+
${bold('--no-open')} do not open the board in a browser at the end
|
|
437
471
|
${bold('--no-<name>')} skip one optional change, e.g. --no-lid-sleep
|
|
438
472
|
`)
|
|
439
473
|
}
|
package/menubar/K0MenuBar.swift
CHANGED
|
@@ -173,27 +173,93 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
|
|
|
173
173
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
174
174
|
var seen: [Int: String] = [:] // the last known status of every card
|
|
175
175
|
var firstPass = true // no notifications on the first pass: that would be noise at startup
|
|
176
|
-
|
|
176
|
+
/// What macOS currently thinks of this app's notifications. **Asked again and again, never
|
|
177
|
+
/// remembered from the one answer at launch**: `requestAuthorization` replies asynchronously,
|
|
178
|
+
/// this app starts at login — before the notification service is necessarily up — and a
|
|
179
|
+
/// boolean captured in that callback would then say "no" for the rest of the day, with
|
|
180
|
+
/// nothing anywhere to say why.
|
|
181
|
+
var notificationStatus: UNAuthorizationStatus = .notDetermined
|
|
182
|
+
private var loggedStatus: UNAuthorizationStatus?
|
|
183
|
+
private var ticksBeforeAsking = 0
|
|
177
184
|
let images = ImagePaste()
|
|
178
185
|
|
|
179
186
|
func applicationDidFinishLaunching(_ n: Notification) {
|
|
180
187
|
item.button?.image = icon(nil)
|
|
181
188
|
item.menu = NSMenu()
|
|
182
189
|
|
|
183
|
-
|
|
184
|
-
centre.delegate = self
|
|
185
|
-
centre.requestAuthorization(options: [.alert, .sound]) { ok, _ in
|
|
186
|
-
self.notificationsAllowed = ok
|
|
187
|
-
}
|
|
190
|
+
UNUserNotificationCenter.current().delegate = self
|
|
188
191
|
|
|
189
192
|
images.start()
|
|
190
193
|
watchScreens()
|
|
191
194
|
watchTerminal()
|
|
192
195
|
|
|
193
|
-
Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
|
196
|
+
Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
|
197
|
+
self.refresh()
|
|
198
|
+
self.readNotificationPermission()
|
|
199
|
+
}
|
|
194
200
|
refresh()
|
|
201
|
+
readNotificationPermission()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Reads the permission on **every** pass, two seconds apart, along with everything else.
|
|
205
|
+
///
|
|
206
|
+
/// It rides the same beat as the rest on purpose. Somebody who has just switched k0 on in
|
|
207
|
+
/// System Settings comes straight back to this menu to see it change, and a menu that still
|
|
208
|
+
/// says "blocked" reads as broken — they switch it off again, or give up. Half a minute is
|
|
209
|
+
/// long enough for that; two seconds is not. The read is one call to a service already
|
|
210
|
+
/// listening, next to an HTTP request that happens anyway.
|
|
211
|
+
///
|
|
212
|
+
/// Asking is the part that is kept slow: the request is fired only where the permission has
|
|
213
|
+
/// never been asked for, and at most once every half minute, because a request that fails
|
|
214
|
+
/// leaves the status exactly as it was and would otherwise be retried on every pass.
|
|
215
|
+
///
|
|
216
|
+
/// The log line is the point of the rest. A permission that was refused and one that was
|
|
217
|
+
/// never asked for are both perfectly silent, and until this was written down the only visible
|
|
218
|
+
/// symptom was a notification that did nothing. `launchd` sends it to
|
|
219
|
+
/// `~/.k0/logs/com.k0.menubar.err.log`.
|
|
220
|
+
func readNotificationPermission() {
|
|
221
|
+
let centre = UNUserNotificationCenter.current()
|
|
222
|
+
centre.getNotificationSettings { settings in
|
|
223
|
+
DispatchQueue.main.async {
|
|
224
|
+
self.record(settings.authorizationStatus)
|
|
225
|
+
guard settings.authorizationStatus == .notDetermined else { return }
|
|
226
|
+
guard self.ticksBeforeAsking <= 0 else {
|
|
227
|
+
self.ticksBeforeAsking -= 1
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
self.ticksBeforeAsking = 15 // half a minute, at one read every two seconds
|
|
231
|
+
centre.requestAuthorization(options: [.alert, .sound]) { granted, error in
|
|
232
|
+
if let error {
|
|
233
|
+
NSLog("k0: macOS refused the notification permission: \(error.localizedDescription)")
|
|
234
|
+
} else if !granted {
|
|
235
|
+
NSLog("k0: the notification permission was declined")
|
|
236
|
+
}
|
|
237
|
+
// Whatever the answer, the next read is two seconds away and will report it.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
195
241
|
}
|
|
196
242
|
|
|
243
|
+
private func record(_ status: UNAuthorizationStatus) {
|
|
244
|
+
notificationStatus = status
|
|
245
|
+
guard loggedStatus != status else { return }
|
|
246
|
+
loggedStatus = status
|
|
247
|
+
NSLog("k0: notifications are \(Self.name(status))")
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private static func name(_ status: UNAuthorizationStatus) -> String {
|
|
251
|
+
switch status {
|
|
252
|
+
case .authorized: return "allowed"
|
|
253
|
+
case .provisional: return "allowed quietly"
|
|
254
|
+
case .denied: return "denied"
|
|
255
|
+
case .notDetermined: return "not granted — macOS has not registered this app"
|
|
256
|
+
default: return "in an unknown state (\(status.rawValue))"
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// True only when a notification posted now would actually be delivered.
|
|
261
|
+
var canNotify: Bool { notificationStatus == .authorized || notificationStatus == .provisional }
|
|
262
|
+
|
|
197
263
|
// MARK: The screen changing under the windows
|
|
198
264
|
|
|
199
265
|
/// You plug a monitor in or out, or change resolution, and the session windows stay where
|
|
@@ -381,10 +447,21 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
|
|
|
381
447
|
let restart = NSMenuItem(title: "Restart", action: #selector(restartServer), keyEquivalent: "")
|
|
382
448
|
restart.target = self
|
|
383
449
|
menu.addItem(restart)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
450
|
+
// macOS has the last word here, and when it is saying no the switch below would be a lie:
|
|
451
|
+
// ticked, and nothing ever arrives. So the item stops pretending and takes you to the one
|
|
452
|
+
// place where that can be undone.
|
|
453
|
+
if canNotify {
|
|
454
|
+
let notifications = NSMenuItem(title: "Notifications", action: #selector(toggleNotifications), keyEquivalent: "")
|
|
455
|
+
notifications.target = self
|
|
456
|
+
notifications.state = Notifications.enabled ? .on : .off
|
|
457
|
+
menu.addItem(notifications)
|
|
458
|
+
} else {
|
|
459
|
+
let blocked = NSMenuItem(title: "Notifications blocked by macOS…",
|
|
460
|
+
action: #selector(openNotificationSettings),
|
|
461
|
+
keyEquivalent: "")
|
|
462
|
+
blocked.target = self
|
|
463
|
+
menu.addItem(blocked)
|
|
464
|
+
}
|
|
388
465
|
// The four modes sit next to these because they are the same kind of gesture — something
|
|
389
466
|
// you leave as it is — but the tick comes from the server rather than from here: over
|
|
390
467
|
// there the board changes them too, and the menu is rebuilt every two seconds anyway.
|
|
@@ -442,6 +519,14 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
|
|
|
442
519
|
sender.state = Notifications.enabled ? .on : .off
|
|
443
520
|
}
|
|
444
521
|
|
|
522
|
+
/// Straight to the Notifications pane. The colour of the dot in the menu bar still tells you
|
|
523
|
+
/// what is waiting — what is missing without this permission is only being told without
|
|
524
|
+
/// looking.
|
|
525
|
+
@objc func openNotificationSettings() {
|
|
526
|
+
guard let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") else { return }
|
|
527
|
+
NSWorkspace.shared.open(url)
|
|
528
|
+
}
|
|
529
|
+
|
|
445
530
|
/// The server holds the mode, so here we only ask. The tick moves straight away so the menu
|
|
446
531
|
/// is not left mute under your finger; the real one arrives with the next round, which is at
|
|
447
532
|
/// most two seconds later.
|
|
@@ -516,7 +601,15 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
|
|
|
516
601
|
return Int(text.trimmingCharacters(in: .whitespacesAndNewlines)) == expected
|
|
517
602
|
}
|
|
518
603
|
|
|
604
|
+
/// There is one way to notify and no other. There used to be a second one — `osascript -e
|
|
605
|
+
/// 'display notification'` — for when the Mac would not deliver this bundle's own. It is gone:
|
|
606
|
+
/// a banner posted by `osascript` **belongs to Script Editor**, so it carries no card, has no
|
|
607
|
+
/// delegate, and clicking it opened Script Editor's file dialog instead of the terminal. A
|
|
608
|
+
/// notification you cannot click is worse than none, because you go and click it.
|
|
609
|
+
///
|
|
610
|
+
/// When macOS will not deliver ours, the menu says so — see `buildMenu`.
|
|
519
611
|
func notify(_ w: Waiting) {
|
|
612
|
+
guard canNotify else { return }
|
|
520
613
|
let c = UNMutableNotificationContent()
|
|
521
614
|
c.title = "k0"
|
|
522
615
|
c.body = "\(w.title) · \(label[w.status] ?? w.status)"
|
|
@@ -524,20 +617,10 @@ final class K0: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegat
|
|
|
524
617
|
c.sound = .default
|
|
525
618
|
let r = UNNotificationRequest(identifier: "k0-\(w.id)-\(w.status)", content: c, trigger: nil)
|
|
526
619
|
UNUserNotificationCenter.current().add(r) { error in
|
|
527
|
-
|
|
528
|
-
// banner: it looks the same, but it is not clickable.
|
|
529
|
-
if error != nil || !self.notificationsAllowed { self.fallbackNotify(w) }
|
|
620
|
+
if let error { NSLog("k0: the notification was not delivered: \(error.localizedDescription)") }
|
|
530
621
|
}
|
|
531
622
|
}
|
|
532
623
|
|
|
533
|
-
func fallbackNotify(_ w: Waiting) {
|
|
534
|
-
let text = "\(w.title) · \(label[w.status] ?? w.status)"
|
|
535
|
-
let p = Process()
|
|
536
|
-
p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
537
|
-
p.arguments = ["-e", "display notification \"\(text)\" with title \"k0\""]
|
|
538
|
-
try? p.run()
|
|
539
|
-
}
|
|
540
|
-
|
|
541
624
|
// Clicking the notification goes straight to that terminal.
|
|
542
625
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
543
626
|
didReceive response: UNNotificationResponse,
|
package/menubar/build.sh
CHANGED
|
@@ -22,7 +22,13 @@ swiftc -O -swift-version 5 -o "$BIN" K0MenuBar.swift
|
|
|
22
22
|
|
|
23
23
|
# Ad-hoc signature: without one, macOS does not deliver the bundle's notifications.
|
|
24
24
|
codesign --force --sign - --identifier com.k0.menubar "$APP" >/dev/null 2>&1 || {
|
|
25
|
-
echo "⚠️ signing failed:
|
|
25
|
+
echo "⚠️ signing failed: macOS will not deliver this app's notifications"
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
# And a signature is not enough on its own: macOS only grants the notification permission to an
|
|
29
|
+
# app it knows about. The bundle never passes through Finder — launchd starts it — so nothing
|
|
30
|
+
# else would ever register it, and the permission would be refused with no explanation.
|
|
31
|
+
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
|
|
32
|
+
if [ -x "$LSREGISTER" ]; then "$LSREGISTER" -f "$APP" >/dev/null 2>&1 || true; fi
|
|
33
|
+
|
|
28
34
|
echo "✅ $(pwd)/$APP"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "k0-board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A board of sticky notes that drives your Claude Code sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -32,14 +32,17 @@
|
|
|
32
32
|
"web",
|
|
33
33
|
"menubar",
|
|
34
34
|
"!menubar/k0.app",
|
|
35
|
-
".claude/skills",
|
|
35
|
+
".claude/skills/k0-import",
|
|
36
|
+
".claude/skills/k0-changelog",
|
|
36
37
|
"README.md",
|
|
37
38
|
"CHANGELOG.md",
|
|
38
39
|
"LICENSE"
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
42
|
"start": "node server/index.js",
|
|
42
|
-
"test": "node test
|
|
43
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
44
|
+
"test:watch": "node --test --watch \"test/*.test.mjs\"",
|
|
45
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude=\"platform/**\" --test-coverage-exclude=\"test/**\" \"test/*.test.mjs\"",
|
|
43
46
|
"doctor": "node bin/k0-board.js doctor"
|
|
44
47
|
}
|
|
45
48
|
}
|
package/platform/contract.js
CHANGED
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
* @property {object} metrics
|
|
34
34
|
* @property {boolean} metrics.pressure the kernel publishes a memory-pressure verdict
|
|
35
35
|
* @property {boolean} metrics.swap swap usage is readable
|
|
36
|
+
* @property {object} servers
|
|
37
|
+
* @property {boolean} servers.run can start and stop a project's dev server
|
|
38
|
+
* @property {boolean} servers.ports can tell which port a process is listening on
|
|
39
|
+
* @property {boolean} servers.adopt can recognise a server started outside k0
|
|
36
40
|
* @property {object} service
|
|
37
41
|
* @property {boolean} service.autostart can register a service that starts at login
|
|
38
42
|
* @property {boolean} tray a menu bar / system tray icon is available
|
|
@@ -47,6 +51,7 @@ export const NO_CAPABILITIES = {
|
|
|
47
51
|
terminal: { windows: false, font: false, readScreen: false, pasteWithoutSending: false, title: false },
|
|
48
52
|
power: { keepAwake: false, keepDisplayAwake: false, lidSleep: false, battery: false },
|
|
49
53
|
metrics: { pressure: false, swap: false },
|
|
54
|
+
servers: { run: false, ports: false, adopt: false },
|
|
50
55
|
service: { autostart: false },
|
|
51
56
|
tray: false,
|
|
52
57
|
revealInFileManager: false,
|
|
@@ -101,6 +106,30 @@ export const NO_CAPABILITIES = {
|
|
|
101
106
|
* @property {() => Promise<number>} pressureLevel 1 normal, 2 warning, 4 critical
|
|
102
107
|
*/
|
|
103
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The dev server a project runs while you are working on it — the one the globe in the column
|
|
111
|
+
* heading switches on and off. k0 starts it detached, so it outlives both the session that
|
|
112
|
+
* asked for it and k0 itself; what this adapter adds is the two things only the operating
|
|
113
|
+
* system knows, and the one gesture Node cannot make portably.
|
|
114
|
+
*
|
|
115
|
+
* @typedef {object} ServersAdapter
|
|
116
|
+
* @property {() => {run: boolean, ports: boolean, adopt: boolean}} capabilities
|
|
117
|
+
* @property {() => Promise<Map<number, Set<number>>>} listeners
|
|
118
|
+
* Every process holding a TCP port open, and which ports. This is how k0 knows a server is
|
|
119
|
+
* up: not by asking it, which would be a network request, but by seeing it hold the socket.
|
|
120
|
+
* @property {(pids: number[]) => Promise<Map<number, string>>} cwds
|
|
121
|
+
* Where each of those processes is working from, which is what ties one to a repository.
|
|
122
|
+
* An adapter that cannot say returns an empty map and reports `adopt: false`.
|
|
123
|
+
* @property {(command: string) => {file: string, args: string[]}} shell
|
|
124
|
+
* How to run `command` inside a project. It goes through a LOGIN shell on Unix, and that is
|
|
125
|
+
* not a flourish: the server runs from a launch agent whose PATH is barely more than
|
|
126
|
+
* /usr/bin:/bin, while npm and node usually arrive from a version manager whose PATH only
|
|
127
|
+
* exists inside a shell. It is the same reason `findClaude` ends up in a shell.
|
|
128
|
+
* @property {(root: number, pids: number[], signal?: string) => Promise<boolean>} stop
|
|
129
|
+
* Stops the server and everything it started. `pids` is the whole subtree, `root` included:
|
|
130
|
+
* killing the top process alone leaves the real server holding the port.
|
|
131
|
+
*/
|
|
132
|
+
|
|
104
133
|
/**
|
|
105
134
|
* @typedef {object} ShellAdapter
|
|
106
135
|
* @property {(absPath: string) => Promise<unknown>} revealInFileManager
|
|
@@ -129,6 +158,7 @@ export const NO_CAPABILITIES = {
|
|
|
129
158
|
* @property {TerminalAdapter} terminal
|
|
130
159
|
* @property {PowerAdapter} power
|
|
131
160
|
* @property {MetricsAdapter} metrics
|
|
161
|
+
* @property {ServersAdapter} servers
|
|
132
162
|
* @property {ShellAdapter} shell
|
|
133
163
|
* @property {ServiceAdapter} service
|
|
134
164
|
*/
|
package/platform/darwin/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as terminal from './terminal.js'
|
|
2
2
|
import * as power from './power.js'
|
|
3
3
|
import * as metrics from './metrics.js'
|
|
4
|
+
import * as servers from './servers.js'
|
|
4
5
|
import * as shell from './shell.js'
|
|
5
6
|
import * as service from './service.js'
|
|
6
7
|
import { extras } from './extras.js'
|
|
@@ -13,12 +14,26 @@ export const capabilities = {
|
|
|
13
14
|
terminal: terminal.capabilities,
|
|
14
15
|
power: power.capabilities,
|
|
15
16
|
metrics: metrics.capabilities,
|
|
17
|
+
servers: servers.capabilities(),
|
|
16
18
|
service: service.capabilities,
|
|
17
19
|
tray: true,
|
|
18
20
|
revealInFileManager: true,
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Nothing is missing here, so there is nothing to explain — with one exception that is not
|
|
25
|
+
* about macOS but about a particular Mac: lsof ships with the system, and if it has been taken
|
|
26
|
+
* away k0 says what it can no longer see rather than showing every dev server as off.
|
|
27
|
+
*/
|
|
28
|
+
export const notes = {
|
|
29
|
+
...(capabilities.servers.ports
|
|
30
|
+
? {}
|
|
31
|
+
: {
|
|
32
|
+
'servers.ports':
|
|
33
|
+
'lsof was not found on this Mac. It ships with macOS, so something has removed or ' +
|
|
34
|
+
'blocked it — without it k0 cannot see which port a dev server is listening on, or ' +
|
|
35
|
+
'recognise one you started yourself.',
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
23
38
|
|
|
24
|
-
export { terminal, power, metrics, shell, service, extras }
|
|
39
|
+
export { terminal, power, metrics, servers, shell, service, extras }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { lsofListeners, lsofCwds, LSOF, stop, shell } from '../shared/servers.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev servers on macOS. Everything comes out of one `lsof`, which ships with the system, so
|
|
5
|
+
* there is nothing to install and nothing that can be missing — but the capability is still
|
|
6
|
+
* probed rather than declared, because a machine with a locked-down /usr/sbin is a machine
|
|
7
|
+
* where the globe has to say so instead of showing every server as off.
|
|
8
|
+
*/
|
|
9
|
+
export const capabilities = () => {
|
|
10
|
+
const has = !!LSOF()
|
|
11
|
+
return { run: true, ports: has, adopt: has }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const listeners = lsofListeners
|
|
15
|
+
export const cwds = lsofCwds
|
|
16
|
+
export { stop, shell }
|
package/platform/index.js
CHANGED
|
@@ -56,6 +56,13 @@ const unsupported = {
|
|
|
56
56
|
swap: async () => ({ total: 0, used: 0 }),
|
|
57
57
|
pressureLevel: async () => 1,
|
|
58
58
|
},
|
|
59
|
+
servers: {
|
|
60
|
+
capabilities: () => ({ run: false, ports: false, adopt: false }),
|
|
61
|
+
listeners: async () => new Map(),
|
|
62
|
+
cwds: async () => new Map(),
|
|
63
|
+
shell: (command) => ({ file: process.env.SHELL || '/bin/sh', args: ['-lc', command] }),
|
|
64
|
+
stop: async () => false,
|
|
65
|
+
},
|
|
59
66
|
shell: {
|
|
60
67
|
revealInFileManager: async () => {},
|
|
61
68
|
openBrowser: async () => {},
|
|
@@ -78,7 +85,8 @@ const unsupported = {
|
|
|
78
85
|
const platform = load ? { ...unsupported, ...(await load()) } : unsupported
|
|
79
86
|
|
|
80
87
|
export default platform
|
|
81
|
-
export const { name, capabilities, notes, terminal, power, metrics, shell, service, extras } =
|
|
88
|
+
export const { name, capabilities, notes, terminal, power, metrics, servers, shell, service, extras } =
|
|
89
|
+
platform
|
|
82
90
|
|
|
83
91
|
/**
|
|
84
92
|
* Is this capability available here? Takes a dotted path, so callers read as a question:
|
package/platform/linux/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
3
3
|
import * as tmux from '../shared/tmux.js'
|
|
4
4
|
import * as power from './power.js'
|
|
5
5
|
import * as metrics from './metrics.js'
|
|
6
|
+
import * as servers from './servers.js'
|
|
6
7
|
import * as shell from './shell.js'
|
|
7
8
|
import * as service from './service.js'
|
|
8
9
|
import { NO_CAPABILITIES } from '../contract.js'
|
|
@@ -19,6 +20,7 @@ export const capabilities = {
|
|
|
19
20
|
terminal: tmux.capabilities(),
|
|
20
21
|
power: power.capabilities(),
|
|
21
22
|
metrics: metrics.capabilities(),
|
|
23
|
+
servers: servers.capabilities(),
|
|
22
24
|
service: service.capabilities(),
|
|
23
25
|
tray: !!which('python3'),
|
|
24
26
|
revealInFileManager: shell.capabilities().revealInFileManager,
|
|
@@ -52,9 +54,24 @@ export const notes = {
|
|
|
52
54
|
...(capabilities.tray
|
|
53
55
|
? {}
|
|
54
56
|
: { tray: 'python3 with PyGObject is needed for a tray icon. The board works without it.' }),
|
|
57
|
+
...(capabilities.servers.ports
|
|
58
|
+
? {}
|
|
59
|
+
: {
|
|
60
|
+
'servers.ports':
|
|
61
|
+
'Neither ss nor lsof was found, so k0 cannot see which port a dev server is listening ' +
|
|
62
|
+
'on. Install iproute2 (for ss) and the globe will know where to send you.',
|
|
63
|
+
}),
|
|
64
|
+
...(capabilities.servers.adopt
|
|
65
|
+
? {}
|
|
66
|
+
: {
|
|
67
|
+
'servers.adopt':
|
|
68
|
+
'This system has no /proc, so k0 cannot tell which repository a dev server you ' +
|
|
69
|
+
'started yourself belongs to. The globe switches on and off the servers k0 started, ' +
|
|
70
|
+
'and says nothing about the others rather than showing them as off.',
|
|
71
|
+
}),
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
export { power, metrics, shell, service }
|
|
74
|
+
export { power, metrics, servers, shell, service }
|
|
58
75
|
|
|
59
76
|
/** Nothing on Linux reaches outside k0's own files, so there is nothing to consent to. */
|
|
60
77
|
export const extras = []
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { lsofListeners, ssListeners, LSOF, stop, shell } from '../shared/servers.js'
|
|
3
|
+
import { which } from '../shared/run.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dev servers on Linux.
|
|
7
|
+
*
|
|
8
|
+
* Two differences from macOS. Listening sockets can come from `ss` as well as from `lsof`, and
|
|
9
|
+
* `ss` is the one a minimal system is likelier to have — it arrives with iproute2, where lsof
|
|
10
|
+
* arrives with nothing. And the working directory of a process is a symlink in /proc, which is
|
|
11
|
+
* both cheaper than lsof and always there, so adoption works even where neither tool is.
|
|
12
|
+
*/
|
|
13
|
+
const hasSs = () => !!which('ss', ['/usr/sbin/ss', '/usr/bin/ss', '/sbin/ss'])
|
|
14
|
+
|
|
15
|
+
export const capabilities = () => ({
|
|
16
|
+
run: true,
|
|
17
|
+
ports: !!LSOF() || hasSs(),
|
|
18
|
+
// /proc is what makes this true, and it is what a container without it makes false.
|
|
19
|
+
adopt: fs.existsSync('/proc/self/cwd'),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** lsof first when it is there — it answers both questions in the same shape as macOS. */
|
|
23
|
+
export async function listeners() {
|
|
24
|
+
if (LSOF()) return lsofListeners()
|
|
25
|
+
return ssListeners()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One readlink each. A process that ends mid-loop is skipped, not an error. */
|
|
29
|
+
export async function cwds(pids) {
|
|
30
|
+
const out = new Map()
|
|
31
|
+
for (const pid of pids) {
|
|
32
|
+
try {
|
|
33
|
+
out.set(pid, fs.readlinkSync(`/proc/${pid}/cwd`))
|
|
34
|
+
} catch {
|
|
35
|
+
/* gone, or not ours to look at */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { stop, shell }
|
package/platform/linux/tray.py
CHANGED
|
@@ -41,6 +41,18 @@ except (ImportError, ValueError) as err: # pragma: no cover - depends on the de
|
|
|
41
41
|
print(f"k0: no tray support on this desktop ({err}); the board still works", file=sys.stderr)
|
|
42
42
|
raise SystemExit(0)
|
|
43
43
|
|
|
44
|
+
# libnotify is what makes a notification clickable: `notify-send` can only shout, and a
|
|
45
|
+
# notification you cannot click is one that tells you a session is waiting and then leaves you to
|
|
46
|
+
# find its terminal yourself. Where it is missing we still shout — see `notify`.
|
|
47
|
+
try: # pragma: no cover - depends on the desktop
|
|
48
|
+
gi.require_version("Notify", "0.7")
|
|
49
|
+
from gi.repository import Notify
|
|
50
|
+
|
|
51
|
+
if not Notify.init("k0"):
|
|
52
|
+
Notify = None
|
|
53
|
+
except (ImportError, ValueError, GLib.Error):
|
|
54
|
+
Notify = None
|
|
55
|
+
|
|
44
56
|
|
|
45
57
|
PORT = os.environ.get("K0_PORT", "4319")
|
|
46
58
|
BASE = f"http://127.0.0.1:{PORT}"
|
|
@@ -130,7 +142,35 @@ def api(path, payload=None):
|
|
|
130
142
|
return None
|
|
131
143
|
|
|
132
144
|
|
|
133
|
-
|
|
145
|
+
# Notifications still waiting to be clicked. They are held here for one reason: a Notify object
|
|
146
|
+
# that Python garbage-collects takes its action callback with it, and the click then does nothing.
|
|
147
|
+
_shown = []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _forget(notification):
|
|
151
|
+
if notification in _shown:
|
|
152
|
+
_shown.remove(notification)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def notify(card_id, title, body):
|
|
156
|
+
"""Say that a session is waiting, and let the click take you to it.
|
|
157
|
+
|
|
158
|
+
The action is named `default`, which is what a notification daemon calls the click on the
|
|
159
|
+
banner itself rather than on a button. Not every daemon honours actions; where it does not,
|
|
160
|
+
or where libnotify is missing altogether, this falls back to `notify-send`, which still says
|
|
161
|
+
what happened — the terminal is then one click away in the menu instead of none.
|
|
162
|
+
"""
|
|
163
|
+
if Notify is not None:
|
|
164
|
+
try:
|
|
165
|
+
note = Notify.Notification.new(title, body, None)
|
|
166
|
+
note.add_action("default", "Open", lambda *_: api(f"/api/card/{card_id}/focus", {}))
|
|
167
|
+
note.connect("closed", _forget)
|
|
168
|
+
_shown.append(note)
|
|
169
|
+
if note.show():
|
|
170
|
+
return
|
|
171
|
+
_forget(note)
|
|
172
|
+
except (GLib.Error, TypeError):
|
|
173
|
+
pass
|
|
134
174
|
try:
|
|
135
175
|
subprocess.Popen(["notify-send", "-a", "k0", title, body])
|
|
136
176
|
except OSError:
|
|
@@ -177,7 +217,7 @@ class Tray:
|
|
|
177
217
|
if not self.first_pass:
|
|
178
218
|
for card in waiting:
|
|
179
219
|
if (card["id"], card["status"]) not in self.seen:
|
|
180
|
-
notify(LABELS.get(card["status"], "k0"), card.get("title", ""))
|
|
220
|
+
notify(card["id"], LABELS.get(card["status"], "k0"), card.get("title", ""))
|
|
181
221
|
self.seen = now
|
|
182
222
|
self.first_pass = False
|
|
183
223
|
|