k0-board 0.1.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-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- package/web/view.js +248 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import { runQuiet, which } from '../shared/run.js'
|
|
4
|
+
import { parsePs } from '../shared/ps.js'
|
|
5
|
+
|
|
6
|
+
const PS = () => which('ps', ['/bin/ps', '/usr/bin/ps'])
|
|
7
|
+
|
|
8
|
+
export async function processes() {
|
|
9
|
+
// Same five fields in the same order as macOS, so one parser covers both. `args` is
|
|
10
|
+
// Linux's name for what BSD calls `command`.
|
|
11
|
+
return parsePs(await runQuiet(PS(), ['-eo', 'pid=,ppid=,rss=,time=,args='], { maxBuffer: 1 << 24 }))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const read = (file) => {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readFileSync(file, 'utf8')
|
|
17
|
+
} catch {
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* /proc/meminfo, in kilobytes.
|
|
24
|
+
*
|
|
25
|
+
* "Used" is total minus MemAvailable, which is the kernel's own estimate of what a new
|
|
26
|
+
* process could get hold of without swapping — a far better answer than total minus free,
|
|
27
|
+
* which counts every cached file as memory somebody is short of.
|
|
28
|
+
*
|
|
29
|
+
* The macOS shape has fields Linux has no equivalent for. `wired` maps to the kernel's
|
|
30
|
+
* unreclaimable slab, `compressed` to zswap where it is on, and both are simply zero when
|
|
31
|
+
* the machine has nothing to put there.
|
|
32
|
+
*/
|
|
33
|
+
export function parseMeminfo(text) {
|
|
34
|
+
const kb = (label) => Number(new RegExp(`^${label}:\\s+(\\d+) kB`, 'm').exec(String(text ?? ''))?.[1] || 0) * 1024
|
|
35
|
+
const total = kb('MemTotal') || os.totalmem()
|
|
36
|
+
const available = kb('MemAvailable')
|
|
37
|
+
const cached = kb('Cached') + kb('Buffers')
|
|
38
|
+
const wired = kb('SUnreclaim')
|
|
39
|
+
const compressed = kb('Zswapped')
|
|
40
|
+
const used = available ? Math.max(0, total - available) : Math.max(0, total - kb('MemFree') - cached)
|
|
41
|
+
return { total, used, app: Math.max(0, used - wired - compressed), wired, compressed, cached }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function memory() {
|
|
45
|
+
return parseMeminfo(read('/proc/meminfo'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseSwapFromMeminfo(text) {
|
|
49
|
+
const kb = (label) => Number(new RegExp(`^${label}:\\s+(\\d+) kB`, 'm').exec(String(text ?? ''))?.[1] || 0) * 1024
|
|
50
|
+
const total = kb('SwapTotal')
|
|
51
|
+
return { total, used: Math.max(0, total - kb('SwapFree')) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function swap() {
|
|
55
|
+
return parseSwapFromMeminfo(read('/proc/meminfo'))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pressure Stall Information: the fraction of the last ten seconds in which at least one task
|
|
60
|
+
* was stalled waiting for memory. It is the closest thing Linux has to the macOS kernel's
|
|
61
|
+
* pressure verdict, and like it, it is a measurement rather than a guess — so the thresholds
|
|
62
|
+
* are the only part k0 chooses.
|
|
63
|
+
*
|
|
64
|
+
* Not every kernel is built with PSI, and cgroup-less containers do not expose it either;
|
|
65
|
+
* where the file is missing, `capabilities.pressure` says so and the board falls back to
|
|
66
|
+
* judging by how full memory is.
|
|
67
|
+
*/
|
|
68
|
+
export function parsePressure(text) {
|
|
69
|
+
const avg10 = Number(/some\s+avg10=([\d.]+)/.exec(String(text ?? ''))?.[1] || 0)
|
|
70
|
+
if (avg10 >= 20) return 4
|
|
71
|
+
if (avg10 >= 5) return 2
|
|
72
|
+
return 1
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function pressureLevel() {
|
|
76
|
+
return parsePressure(read('/proc/pressure/memory'))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const capabilities = () => ({
|
|
80
|
+
pressure: fs.existsSync('/proc/pressure/memory'),
|
|
81
|
+
swap: true,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export const notes = {
|
|
85
|
+
'metrics.pressure':
|
|
86
|
+
'This kernel does not expose /proc/pressure/memory, so k0 judges memory pressure by how ' +
|
|
87
|
+
'full memory is rather than by how much it is actually stalling.',
|
|
88
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { which } from '../shared/run.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Keeping a Linux machine awake.
|
|
8
|
+
*
|
|
9
|
+
* The equivalent of `caffeinate` is `systemd-inhibit`, which holds a lock for as long as the
|
|
10
|
+
* command it runs is alive — so k0 runs a `sleep infinity` under it and kills that when the
|
|
11
|
+
* mode changes. Same shape as macOS, different lever.
|
|
12
|
+
*
|
|
13
|
+
* What is NOT here is the lid. On macOS a single system flag covers it; on Linux the lid is
|
|
14
|
+
* handled by logind, and changing it means editing /etc/systemd/logind.conf as root and
|
|
15
|
+
* reloading the daemon — a machine-wide setting that outlives k0 and that k0 has no business
|
|
16
|
+
* rewriting behind your back. So `lidSleep` is reported as unavailable, with a note that says
|
|
17
|
+
* what to change if you want it. Better a switch that is visibly off than one that lies.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SYSTEMD_INHIBIT = () => which('systemd-inhibit')
|
|
21
|
+
const GNOME_INHIBIT = () => which('gnome-session-inhibit')
|
|
22
|
+
|
|
23
|
+
const SUPPLY = '/sys/class/power_supply'
|
|
24
|
+
|
|
25
|
+
let children = []
|
|
26
|
+
let activeLevel = null
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Power source and charge, straight from sysfs. `AC`/`ADP*` entries report whether the mains
|
|
30
|
+
* are connected; `BAT*` entries report capacity. No battery at all means a desktop, and a
|
|
31
|
+
* desktop should never trip a battery threshold — hence `charge: null` rather than 100.
|
|
32
|
+
*/
|
|
33
|
+
export function readPowerSupply(dir = SUPPLY) {
|
|
34
|
+
let onAcPower = null
|
|
35
|
+
let charge = null
|
|
36
|
+
let entries = []
|
|
37
|
+
try {
|
|
38
|
+
entries = fs.readdirSync(dir)
|
|
39
|
+
} catch {
|
|
40
|
+
return { onAcPower: true, charge: null }
|
|
41
|
+
}
|
|
42
|
+
for (const name of entries) {
|
|
43
|
+
const read = (file) => {
|
|
44
|
+
try {
|
|
45
|
+
return fs.readFileSync(path.join(dir, name, file), 'utf8').trim()
|
|
46
|
+
} catch {
|
|
47
|
+
return ''
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const type = read('type')
|
|
51
|
+
if (type === 'Mains') {
|
|
52
|
+
if (read('online') === '1') onAcPower = true
|
|
53
|
+
else if (onAcPower === null) onAcPower = false
|
|
54
|
+
} else if (type === 'Battery') {
|
|
55
|
+
const capacity = Number(read('capacity'))
|
|
56
|
+
if (Number.isFinite(capacity) && read('capacity') !== '') charge = capacity
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { onAcPower: onAcPower === null ? true : onAcPower, charge }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function state() {
|
|
63
|
+
return readPowerSupply()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function startInhibitor(bin, args) {
|
|
67
|
+
const child = spawn(bin, args, { stdio: 'ignore' })
|
|
68
|
+
child.on('error', () => {})
|
|
69
|
+
child.unref()
|
|
70
|
+
children.push(child)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function inhibit(level) {
|
|
74
|
+
if (activeLevel === level) return
|
|
75
|
+
release()
|
|
76
|
+
if (!level) return
|
|
77
|
+
activeLevel = level
|
|
78
|
+
|
|
79
|
+
const systemd = SYSTEMD_INHIBIT()
|
|
80
|
+
if (systemd) {
|
|
81
|
+
startInhibitor(systemd, [
|
|
82
|
+
'--what=idle:sleep',
|
|
83
|
+
'--who=k0',
|
|
84
|
+
'--why=A Claude Code session is running',
|
|
85
|
+
'--mode=block',
|
|
86
|
+
'sleep',
|
|
87
|
+
'infinity',
|
|
88
|
+
])
|
|
89
|
+
}
|
|
90
|
+
// Stopping the *screen* from going dark is a desktop-environment matter, not a logind one.
|
|
91
|
+
// gnome-session-inhibit covers GNOME, which is the common case; elsewhere the display may
|
|
92
|
+
// still blank, and `capabilities` says so.
|
|
93
|
+
if (level === 'display') {
|
|
94
|
+
const gnome = GNOME_INHIBIT()
|
|
95
|
+
if (gnome) startInhibitor(gnome, ['--inhibit', 'idle', '--reason', 'k0 driving mode', 'sleep', 'infinity'])
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function release() {
|
|
100
|
+
activeLevel = null
|
|
101
|
+
for (const child of children) {
|
|
102
|
+
try {
|
|
103
|
+
child.kill()
|
|
104
|
+
} catch {
|
|
105
|
+
/* already gone */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
children = []
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Not ours to change: see the note at the top of this file. */
|
|
112
|
+
export async function setLidSleepBlocked() {}
|
|
113
|
+
|
|
114
|
+
export async function lidSleepBlocked() {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function releaseSync() {
|
|
119
|
+
release()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const capabilities = () => ({
|
|
123
|
+
keepAwake: !!SYSTEMD_INHIBIT(),
|
|
124
|
+
keepDisplayAwake: !!GNOME_INHIBIT(),
|
|
125
|
+
lidSleep: false,
|
|
126
|
+
battery: fs.existsSync(SUPPLY),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
export const notes = {
|
|
130
|
+
'power.lidSleep':
|
|
131
|
+
'On Linux the lid is handled by logind. To keep working with the lid closed, set ' +
|
|
132
|
+
'HandleLidSwitch=ignore in /etc/systemd/logind.conf and reload logind. k0 will not ' +
|
|
133
|
+
'change a machine-wide setting for you.',
|
|
134
|
+
'power.keepDisplayAwake':
|
|
135
|
+
'Keeping the screen on needs a desktop-level inhibitor. k0 uses gnome-session-inhibit ' +
|
|
136
|
+
'where it exists; on other desktops the display may still blank.',
|
|
137
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { run, runQuiet, which } from '../shared/run.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Background services, systemd user-unit edition.
|
|
8
|
+
*
|
|
9
|
+
* User units, not system ones: k0 runs as you, needs your session's environment to find a
|
|
10
|
+
* display and a terminal, and has no business existing when you are not logged in. The one
|
|
11
|
+
* wrinkle is that a user unit normally stops when your last session ends — `loginctl
|
|
12
|
+
* enable-linger` is what keeps it alive on a headless box, and the installer only mentions
|
|
13
|
+
* it rather than turning it on, because it is a policy decision about your account.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SYSTEMCTL = () => which('systemctl')
|
|
17
|
+
const UNIT_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user')
|
|
18
|
+
|
|
19
|
+
export const SERVER_UNIT = 'k0.service'
|
|
20
|
+
export const TRAY_UNIT = 'k0-tray.service'
|
|
21
|
+
|
|
22
|
+
const unitPath = (unit) => path.join(UNIT_DIR, unit)
|
|
23
|
+
|
|
24
|
+
function unitFile({ description, exec, port, logDir, workingDirectory }) {
|
|
25
|
+
return `[Unit]
|
|
26
|
+
Description=${description}
|
|
27
|
+
After=graphical-session.target
|
|
28
|
+
|
|
29
|
+
[Service]
|
|
30
|
+
Type=simple
|
|
31
|
+
ExecStart=${exec}
|
|
32
|
+
Environment=K0_PORT=${port}
|
|
33
|
+
${workingDirectory ? `WorkingDirectory=${workingDirectory}\n` : ''}Restart=always
|
|
34
|
+
RestartSec=2
|
|
35
|
+
StandardOutput=append:${path.join(logDir, description.replace(/\s+/g, '-').toLowerCase())}.out.log
|
|
36
|
+
StandardError=append:${path.join(logDir, description.replace(/\s+/g, '-').toLowerCase())}.err.log
|
|
37
|
+
|
|
38
|
+
[Install]
|
|
39
|
+
WantedBy=default.target
|
|
40
|
+
`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const quote = (s) => (/[\s"']/.test(s) ? JSON.stringify(s) : s)
|
|
44
|
+
|
|
45
|
+
export async function install({ node, entry, port, logDir, tray, appDir }) {
|
|
46
|
+
const systemctl = SYSTEMCTL()
|
|
47
|
+
if (!systemctl) throw new Error('k0 needs systemd to install a background service on Linux.')
|
|
48
|
+
fs.mkdirSync(UNIT_DIR, { recursive: true })
|
|
49
|
+
fs.mkdirSync(logDir, { recursive: true })
|
|
50
|
+
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
unitPath(SERVER_UNIT),
|
|
53
|
+
unitFile({
|
|
54
|
+
description: 'k0 server',
|
|
55
|
+
exec: `${quote(node)} ${quote(entry)}`,
|
|
56
|
+
port,
|
|
57
|
+
logDir,
|
|
58
|
+
workingDirectory: appDir,
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
if (tray && fs.existsSync(tray)) {
|
|
62
|
+
const python = which('python3')
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
unitPath(TRAY_UNIT),
|
|
65
|
+
unitFile({ description: 'k0 tray', exec: `${quote(python || 'python3')} ${quote(tray)}`, port, logDir })
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await run(systemctl, ['--user', 'daemon-reload'])
|
|
70
|
+
await run(systemctl, ['--user', 'enable', '--now', SERVER_UNIT])
|
|
71
|
+
if (tray && fs.existsSync(unitPath(TRAY_UNIT))) {
|
|
72
|
+
await runQuiet(systemctl, ['--user', 'enable', '--now', TRAY_UNIT])
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function uninstall() {
|
|
77
|
+
const systemctl = SYSTEMCTL()
|
|
78
|
+
for (const unit of [SERVER_UNIT, TRAY_UNIT]) {
|
|
79
|
+
if (systemctl) {
|
|
80
|
+
await runQuiet(systemctl, ['--user', 'disable', '--now', unit])
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
fs.unlinkSync(unitPath(unit))
|
|
84
|
+
} catch {
|
|
85
|
+
/* never installed */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (systemctl) await runQuiet(systemctl, ['--user', 'daemon-reload'])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function restart() {
|
|
92
|
+
await runQuiet(SYSTEMCTL(), ['--user', 'restart', SERVER_UNIT])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function status() {
|
|
96
|
+
const out = await runQuiet(SYSTEMCTL(), ['--user', 'is-active', SERVER_UNIT])
|
|
97
|
+
return { running: String(out).trim() === 'active' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const describe = () => [
|
|
101
|
+
{ path: unitPath(SERVER_UNIT), what: 'a user service that starts k0 when you log in' },
|
|
102
|
+
{ path: unitPath(TRAY_UNIT), what: 'a user service for the tray icon' },
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
export const capabilities = () => ({ autostart: !!SYSTEMCTL() })
|
|
106
|
+
|
|
107
|
+
export const notes = {
|
|
108
|
+
'service.autostart':
|
|
109
|
+
'systemd was not found, so k0 cannot register itself to start at login. Run it by hand ' +
|
|
110
|
+
'with `k0-board start` or from your desktop’s own autostart configuration.',
|
|
111
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { run, runQuiet, runSyncQuiet, which } from '../shared/run.js'
|
|
5
|
+
|
|
6
|
+
const XDG_OPEN = () => which('xdg-open')
|
|
7
|
+
const DBUS_SEND = () => which('dbus-send')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* "Show me this file in the file manager" has a proper freedesktop answer — the FileManager1
|
|
11
|
+
* interface, which selects the file rather than just opening its folder — and a fallback for
|
|
12
|
+
* desktops that do not implement it, which is to open the containing directory and let you
|
|
13
|
+
* find it yourself.
|
|
14
|
+
*/
|
|
15
|
+
export async function revealInFileManager(abs) {
|
|
16
|
+
const dbus = DBUS_SEND()
|
|
17
|
+
if (dbus) {
|
|
18
|
+
const uri = `file://${encodeURI(abs)}`
|
|
19
|
+
const out = await runQuiet(dbus, [
|
|
20
|
+
'--session',
|
|
21
|
+
'--dest=org.freedesktop.FileManager1',
|
|
22
|
+
'--type=method_call',
|
|
23
|
+
'/org/freedesktop/FileManager1',
|
|
24
|
+
'org.freedesktop.FileManager1.ShowItems',
|
|
25
|
+
`array:string:${uri}`,
|
|
26
|
+
'string:',
|
|
27
|
+
])
|
|
28
|
+
if (out !== '') return
|
|
29
|
+
}
|
|
30
|
+
const open = XDG_OPEN()
|
|
31
|
+
if (open) await runQuiet(open, [path.dirname(abs)])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const openBrowser = (url) => run(XDG_OPEN(), [url], { timeout: 4000 })
|
|
35
|
+
|
|
36
|
+
const CHROME_CANDIDATES = [
|
|
37
|
+
'google-chrome',
|
|
38
|
+
'google-chrome-stable',
|
|
39
|
+
'chromium',
|
|
40
|
+
'chromium-browser',
|
|
41
|
+
'brave-browser',
|
|
42
|
+
'microsoft-edge',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export function findChrome() {
|
|
46
|
+
for (const name of CHROME_CANDIDATES) {
|
|
47
|
+
const bin = which(name)
|
|
48
|
+
if (bin) return bin
|
|
49
|
+
}
|
|
50
|
+
for (const p of ['/snap/bin/chromium', '/usr/lib/chromium/chromium']) {
|
|
51
|
+
if (fs.existsSync(p)) return p
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Directories under $HOME that are never projects.
|
|
58
|
+
*
|
|
59
|
+
* The XDG names are the ones the desktop creates, and unlike macOS they can be localised —
|
|
60
|
+
* a French install has `Bureau` and `Téléchargements`. So the real list is read from
|
|
61
|
+
* user-dirs.dirs when it is there, with the English names as a floor for machines that have
|
|
62
|
+
* no desktop at all.
|
|
63
|
+
*/
|
|
64
|
+
export function homeSkipList() {
|
|
65
|
+
const skip = new Set(['Desktop', 'Documents', 'Downloads', 'Music', 'Pictures', 'Public', 'Templates', 'Videos', 'snap'])
|
|
66
|
+
const config = path.join(
|
|
67
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
|
|
68
|
+
'user-dirs.dirs'
|
|
69
|
+
)
|
|
70
|
+
try {
|
|
71
|
+
for (const line of fs.readFileSync(config, 'utf8').split('\n')) {
|
|
72
|
+
const m = /^XDG_\w+_DIR="\$HOME\/(.+)"/.exec(line.trim())
|
|
73
|
+
if (m) skip.add(m[1].replace(/\/$/, ''))
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
/* no desktop configuration: the defaults above are enough */
|
|
77
|
+
}
|
|
78
|
+
return skip
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let claudeBin = null
|
|
82
|
+
|
|
83
|
+
export function findClaude() {
|
|
84
|
+
if (claudeBin) return claudeBin
|
|
85
|
+
const candidates = [
|
|
86
|
+
path.join(os.homedir(), '.local/bin/claude'),
|
|
87
|
+
'/usr/local/bin/claude',
|
|
88
|
+
'/usr/bin/claude',
|
|
89
|
+
]
|
|
90
|
+
for (const c of candidates) if (fs.existsSync(c)) return (claudeBin = c)
|
|
91
|
+
const shell = process.env.SHELL || '/bin/bash'
|
|
92
|
+
const fromShell = runSyncQuiet(shell, ['-lc', 'command -v claude']).trim()
|
|
93
|
+
claudeBin = fromShell || which('claude') || 'claude'
|
|
94
|
+
return claudeBin
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const capabilities = () => ({ revealInFileManager: !!XDG_OPEN() })
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""k0's tray icon for Linux.
|
|
3
|
+
|
|
4
|
+
The counterpart of menubar/K0MenuBar.swift, and deliberately the same shape: poll
|
|
5
|
+
/api/status every couple of seconds, tint a small square with the colour of the most urgent
|
|
6
|
+
session, and put the sessions that are waiting for you at the top of the menu so one click
|
|
7
|
+
brings the right terminal back.
|
|
8
|
+
|
|
9
|
+
Written in Python because it is the only language with a tray binding that is already
|
|
10
|
+
installed on a normal desktop: GNOME, KDE and the rest ship python3 with PyGObject. Anything
|
|
11
|
+
else would mean asking people to install a toolkit before they can see an icon. There are no
|
|
12
|
+
third-party imports here — the PNG the icon is made of is written by hand with zlib, which is
|
|
13
|
+
in the standard library, precisely so that this file never needs a pip install.
|
|
14
|
+
|
|
15
|
+
If PyGObject or an AppIndicator implementation is missing, this exits quietly. The board and
|
|
16
|
+
the server work perfectly without a tray icon; refusing to start would be worse than not
|
|
17
|
+
having one.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import struct
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
import zlib
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import gi
|
|
31
|
+
|
|
32
|
+
gi.require_version("Gtk", "3.0")
|
|
33
|
+
try:
|
|
34
|
+
gi.require_version("AyatanaAppIndicator3", "0.1")
|
|
35
|
+
from gi.repository import AyatanaAppIndicator3 as AppIndicator
|
|
36
|
+
except (ValueError, ImportError):
|
|
37
|
+
gi.require_version("AppIndicator3", "0.1")
|
|
38
|
+
from gi.repository import AppIndicator3 as AppIndicator
|
|
39
|
+
from gi.repository import GLib, Gtk
|
|
40
|
+
except (ImportError, ValueError) as err: # pragma: no cover - depends on the desktop
|
|
41
|
+
print(f"k0: no tray support on this desktop ({err}); the board still works", file=sys.stderr)
|
|
42
|
+
raise SystemExit(0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
PORT = os.environ.get("K0_PORT", "4319")
|
|
46
|
+
BASE = f"http://127.0.0.1:{PORT}"
|
|
47
|
+
POLL_SECONDS = 2
|
|
48
|
+
|
|
49
|
+
CACHE = os.path.join(
|
|
50
|
+
os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "k0", "tray"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# The same colours and the same order of urgency as the board and the macOS menu bar icon.
|
|
54
|
+
# ASK first because a question blocks everything behind it; COMPLETED never gets here.
|
|
55
|
+
URGENCY = ["ASK", "PLANNED", "IDLE", "WORKING", "PLANNING"]
|
|
56
|
+
COLOURS = {
|
|
57
|
+
"ASK": (0xE8, 0x6A, 0x33),
|
|
58
|
+
"PLANNED": (0x6C, 0x8E, 0xBF),
|
|
59
|
+
"IDLE": (0xE0, 0xC0, 0x4A),
|
|
60
|
+
"WORKING": (0x5B, 0xA8, 0x6A),
|
|
61
|
+
"PLANNING": (0x8E, 0x7C, 0xC3),
|
|
62
|
+
"quiet": (0x9A, 0x9A, 0x9A),
|
|
63
|
+
"down": (0xB0, 0x50, 0x50),
|
|
64
|
+
}
|
|
65
|
+
LABELS = {
|
|
66
|
+
"ASK": "Needs answer",
|
|
67
|
+
"PLANNED": "Needs approval",
|
|
68
|
+
"IDLE": "Your turn",
|
|
69
|
+
"WORKING": "Working",
|
|
70
|
+
"PLANNING": "Planning",
|
|
71
|
+
}
|
|
72
|
+
MODES = [("sleep", "Sleep"), ("away", "Away"), ("nerd", "Nerd"), ("driving", "Driving")]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def png(rgb, size=22):
|
|
76
|
+
"""A solid rounded square, as PNG bytes.
|
|
77
|
+
|
|
78
|
+
Hand-rolled so the tray needs nothing but the standard library. The corners are cut by
|
|
79
|
+
simply not drawing the four corner pixels, which at this size reads as rounded.
|
|
80
|
+
"""
|
|
81
|
+
r, g, b = rgb
|
|
82
|
+
rows = bytearray()
|
|
83
|
+
for y in range(size):
|
|
84
|
+
rows.append(0) # PNG filter type for this scanline: none
|
|
85
|
+
for x in range(size):
|
|
86
|
+
edge = (x < 2 or x >= size - 2) and (y < 2 or y >= size - 2)
|
|
87
|
+
rows += bytes((r, g, b, 0 if edge else 255))
|
|
88
|
+
|
|
89
|
+
def chunk(tag, data):
|
|
90
|
+
return (
|
|
91
|
+
struct.pack(">I", len(data))
|
|
92
|
+
+ tag
|
|
93
|
+
+ data
|
|
94
|
+
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
header = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
|
|
98
|
+
return (
|
|
99
|
+
b"\x89PNG\r\n\x1a\n"
|
|
100
|
+
+ chunk(b"IHDR", header)
|
|
101
|
+
+ chunk(b"IDAT", zlib.compress(bytes(rows), 9))
|
|
102
|
+
+ chunk(b"IEND", b"")
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def icon_for(state):
|
|
107
|
+
"""Write the icon for `state` once and hand back its path."""
|
|
108
|
+
os.makedirs(CACHE, exist_ok=True)
|
|
109
|
+
path = os.path.join(CACHE, f"{state}.png")
|
|
110
|
+
if not os.path.exists(path):
|
|
111
|
+
with open(path, "wb") as fh:
|
|
112
|
+
fh.write(png(COLOURS.get(state, COLOURS["quiet"])))
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def api(path, payload=None):
|
|
117
|
+
"""One request to the server. Returns None when the server is not answering."""
|
|
118
|
+
data = json.dumps(payload).encode() if payload is not None else None
|
|
119
|
+
request = urllib.request.Request(
|
|
120
|
+
BASE + path,
|
|
121
|
+
data=data,
|
|
122
|
+
headers={"Content-Type": "application/json", "Origin": BASE},
|
|
123
|
+
method="POST" if data else "GET",
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
with urllib.request.urlopen(request, timeout=3) as response:
|
|
127
|
+
body = response.read()
|
|
128
|
+
return json.loads(body) if body else {}
|
|
129
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def notify(title, body):
|
|
134
|
+
try:
|
|
135
|
+
subprocess.Popen(["notify-send", "-a", "k0", title, body])
|
|
136
|
+
except OSError:
|
|
137
|
+
pass # no notification daemon: not worth complaining about
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Tray:
|
|
141
|
+
def __init__(self):
|
|
142
|
+
self.indicator = AppIndicator.Indicator.new(
|
|
143
|
+
"k0", icon_for("quiet"), AppIndicator.IndicatorCategory.APPLICATION_STATUS
|
|
144
|
+
)
|
|
145
|
+
self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
|
|
146
|
+
self.menu = Gtk.Menu()
|
|
147
|
+
self.indicator.set_menu(self.menu)
|
|
148
|
+
self.seen = set()
|
|
149
|
+
self.first_pass = True
|
|
150
|
+
self.mode = None
|
|
151
|
+
self.build_menu([], down=True)
|
|
152
|
+
GLib.timeout_add_seconds(POLL_SECONDS, self.poll)
|
|
153
|
+
self.poll()
|
|
154
|
+
|
|
155
|
+
# ── the loop ────────────────────────────────────────────────────────────
|
|
156
|
+
def poll(self):
|
|
157
|
+
status = api("/api/status")
|
|
158
|
+
if status is None:
|
|
159
|
+
self.indicator.set_icon_full(icon_for("down"), "k0: server not responding")
|
|
160
|
+
self.build_menu([], down=True)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
waiting = status.get("waiting") or []
|
|
164
|
+
urgent = status.get("urgent")
|
|
165
|
+
self.mode = status.get("mode")
|
|
166
|
+
self.indicator.set_icon_full(
|
|
167
|
+
icon_for(urgent or "quiet"),
|
|
168
|
+
LABELS.get(urgent, "k0 — nothing waiting for you"),
|
|
169
|
+
)
|
|
170
|
+
self.build_menu(waiting, down=False)
|
|
171
|
+
self.announce(waiting)
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
def announce(self, waiting):
|
|
175
|
+
"""Notify about cards that have just started waiting, not about every one every time."""
|
|
176
|
+
now = {(card["id"], card["status"]) for card in waiting}
|
|
177
|
+
if not self.first_pass:
|
|
178
|
+
for card in waiting:
|
|
179
|
+
if (card["id"], card["status"]) not in self.seen:
|
|
180
|
+
notify(LABELS.get(card["status"], "k0"), card.get("title", ""))
|
|
181
|
+
self.seen = now
|
|
182
|
+
self.first_pass = False
|
|
183
|
+
|
|
184
|
+
# ── the menu ────────────────────────────────────────────────────────────
|
|
185
|
+
def build_menu(self, waiting, down):
|
|
186
|
+
for child in self.menu.get_children():
|
|
187
|
+
self.menu.remove(child)
|
|
188
|
+
|
|
189
|
+
if down:
|
|
190
|
+
self.add_item("Server not responding", None, enabled=False)
|
|
191
|
+
elif not waiting:
|
|
192
|
+
self.add_item("Nothing waiting for you", None, enabled=False)
|
|
193
|
+
else:
|
|
194
|
+
for card in waiting:
|
|
195
|
+
label = f"{LABELS.get(card['status'], card['status'])} — {card.get('title', '')}"
|
|
196
|
+
self.add_item(label, lambda _w, c=card: api(f"/api/card/{c['id']}/focus", {}))
|
|
197
|
+
|
|
198
|
+
self.menu.append(Gtk.SeparatorMenuItem())
|
|
199
|
+
for value, label in MODES:
|
|
200
|
+
item = Gtk.CheckMenuItem(label=label)
|
|
201
|
+
item.set_active(self.mode == value)
|
|
202
|
+
item.connect("activate", self.on_mode, value)
|
|
203
|
+
item.show()
|
|
204
|
+
self.menu.append(item)
|
|
205
|
+
|
|
206
|
+
self.menu.append(Gtk.SeparatorMenuItem())
|
|
207
|
+
self.add_item("Dashboard", lambda _w: subprocess.Popen(["xdg-open", BASE]))
|
|
208
|
+
self.add_item("Restart server", lambda _w: subprocess.Popen(["systemctl", "--user", "restart", "k0.service"]))
|
|
209
|
+
self.add_item("Quit", lambda _w: Gtk.main_quit())
|
|
210
|
+
self.menu.show_all()
|
|
211
|
+
|
|
212
|
+
def add_item(self, label, handler, enabled=True):
|
|
213
|
+
item = Gtk.MenuItem(label=label)
|
|
214
|
+
item.set_sensitive(enabled and handler is not None)
|
|
215
|
+
if handler:
|
|
216
|
+
item.connect("activate", handler)
|
|
217
|
+
item.show()
|
|
218
|
+
self.menu.append(item)
|
|
219
|
+
|
|
220
|
+
def on_mode(self, widget, value):
|
|
221
|
+
# A CheckMenuItem fires `activate` when it is rebuilt too, which would post the mode
|
|
222
|
+
# back to the server in an endless loop. Only a real change is worth a request.
|
|
223
|
+
if widget.get_active() and self.mode != value:
|
|
224
|
+
api("/api/mode", {"mode": value})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
Tray()
|
|
229
|
+
Gtk.main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning "run this program, in this directory, with these arguments" into one line a shell
|
|
3
|
+
* will accept.
|
|
4
|
+
*
|
|
5
|
+
* It has to be one line because that is all a terminal can be handed: there is no argv on the
|
|
6
|
+
* far side, only text typed into a shell. Which makes quoting the whole of the problem — a
|
|
7
|
+
* project directory called `My Projects` or `it's-mine` has to survive the trip intact, and
|
|
8
|
+
* a prompt passed as an argument can contain anything at all.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** POSIX single quotes: everything is literal inside them, and a quote ends and reopens them. */
|
|
12
|
+
export const posixQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
13
|
+
|
|
14
|
+
export const posixCommand = ({ cwd, bin, args = [] }) =>
|
|
15
|
+
['cd', posixQuote(cwd), '&&', posixQuote(bin), ...args.map(posixQuote)].join(' ')
|
|
16
|
+
|
|
17
|
+
/** PowerShell single quotes: same idea, but a quote is escaped by doubling it. */
|
|
18
|
+
export const powershellQuote = (s) => `'${String(s).replace(/'/g, "''")}'`
|
|
19
|
+
|
|
20
|
+
export const powershellCommand = ({ cwd, bin, args = [] }) =>
|
|
21
|
+
[
|
|
22
|
+
`Set-Location ${powershellQuote(cwd)};`,
|
|
23
|
+
'&',
|
|
24
|
+
powershellQuote(bin),
|
|
25
|
+
...args.map(powershellQuote),
|
|
26
|
+
].join(' ')
|