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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line between k0 and the operating system.
|
|
3
|
+
*
|
|
4
|
+
* Everything above this file — the board, the watcher, the API — is the same on every
|
|
5
|
+
* machine. Everything below it is not: opening a terminal window is AppleScript on macOS,
|
|
6
|
+
* tmux on Linux and Windows Terminal on Windows, and none of those three agree on what a
|
|
7
|
+
* "window" even is. Rather than sprinkle `process.platform` checks through the server, all
|
|
8
|
+
* of it goes behind the small surface described here, and each platform fills it in.
|
|
9
|
+
*
|
|
10
|
+
* Two rules keep this honest.
|
|
11
|
+
*
|
|
12
|
+
* **An adapter never pretends.** If a platform cannot do something — read back what is on a
|
|
13
|
+
* terminal's screen, stop the machine sleeping when the lid closes — it says so in
|
|
14
|
+
* `capabilities` and the feature is visibly switched off in the interface. A button that
|
|
15
|
+
* quietly does nothing is worse than a button that is greyed out with a reason next to it.
|
|
16
|
+
*
|
|
17
|
+
* **Capabilities are read at runtime, not guessed from the platform name.** Whether window
|
|
18
|
+
* geometry works on Linux depends on X11 versus Wayland, not on "linux"; whether Windows can
|
|
19
|
+
* read a terminal's contents depends on whether WSL is installed. Adapters probe.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {object} Capabilities
|
|
22
|
+
* @property {object} terminal
|
|
23
|
+
* @property {boolean} terminal.windows can place, size and focus windows
|
|
24
|
+
* @property {boolean} terminal.font can change the font size of a live terminal
|
|
25
|
+
* @property {boolean} terminal.readScreen can read back what is on screen
|
|
26
|
+
* @property {boolean} terminal.pasteWithoutSending can leave a prompt sitting unsent
|
|
27
|
+
* @property {boolean} terminal.title can rename a window from outside
|
|
28
|
+
* @property {object} power
|
|
29
|
+
* @property {boolean} power.keepAwake can stop idle sleep
|
|
30
|
+
* @property {boolean} power.keepDisplayAwake can stop the display sleeping
|
|
31
|
+
* @property {boolean} power.lidSleep can stop sleep-on-lid-close
|
|
32
|
+
* @property {boolean} power.battery can read charge and power source
|
|
33
|
+
* @property {object} metrics
|
|
34
|
+
* @property {boolean} metrics.pressure the kernel publishes a memory-pressure verdict
|
|
35
|
+
* @property {boolean} metrics.swap swap usage is readable
|
|
36
|
+
* @property {object} service
|
|
37
|
+
* @property {boolean} service.autostart can register a service that starts at login
|
|
38
|
+
* @property {boolean} tray a menu bar / system tray icon is available
|
|
39
|
+
* @property {boolean} revealInFileManager
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What an adapter that can do nothing would look like. Adapters spread this and override,
|
|
44
|
+
* so a capability added here later defaults to "no" everywhere instead of crashing.
|
|
45
|
+
*/
|
|
46
|
+
export const NO_CAPABILITIES = {
|
|
47
|
+
terminal: { windows: false, font: false, readScreen: false, pasteWithoutSending: false, title: false },
|
|
48
|
+
power: { keepAwake: false, keepDisplayAwake: false, lidSleep: false, battery: false },
|
|
49
|
+
metrics: { pressure: false, swap: false },
|
|
50
|
+
service: { autostart: false },
|
|
51
|
+
tray: false,
|
|
52
|
+
revealInFileManager: false,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The reason a capability is missing, in a sentence the board can show to the user.
|
|
57
|
+
* Keys are dotted capability paths, e.g. `terminal.readScreen`.
|
|
58
|
+
* @typedef {Record<string, string>} CapabilityNotes
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} TerminalAdapter
|
|
63
|
+
* @property {(opts: {command: string, title: string}) => Promise<string|null>} open
|
|
64
|
+
* Opens a terminal running `command` and returns an opaque handle, or null if the
|
|
65
|
+
* platform has no notion of a handle it can address later.
|
|
66
|
+
* @property {(handle: string, title: string) => Promise<boolean>} setTitle
|
|
67
|
+
* @property {(handles: string[]) => Promise<{touched: number}>} setFont
|
|
68
|
+
* Applies the font size the current mode asks for, leaving each window where it is.
|
|
69
|
+
* @property {(handles: string[]) => Promise<{touched: number}>} relayout
|
|
70
|
+
* @property {(handle: string) => Promise<{ok: boolean, error?: string}>} focus
|
|
71
|
+
* @property {(opts: {handle: string, pid?: number}) => Promise<{closed: boolean}>} close
|
|
72
|
+
* @property {(handle: string) => Promise<string|null>} readScreen
|
|
73
|
+
* What is on screen right now, or null when this platform cannot look.
|
|
74
|
+
* @property {(text: string, handle: string) => Promise<{pasted: boolean, error?: string}>} paste
|
|
75
|
+
* Puts `text` in front of the user WITHOUT submitting it.
|
|
76
|
+
* @property {(text: string, handle: string) => Promise<{written: boolean, error?: string}>} type
|
|
77
|
+
* Writes `text` and submits it. The fallback when `paste` is unavailable.
|
|
78
|
+
* @property {() => Promise<number>} defaultFontSize
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {object} PowerAdapter
|
|
83
|
+
* @property {() => Promise<{onAcPower: boolean, charge: number|null}>} state
|
|
84
|
+
* @property {(level: null|'system'|'display') => void} inhibit
|
|
85
|
+
* Keeps the machine awake. 'system' stops idle sleep, 'display' also keeps the screen on,
|
|
86
|
+
* null lets everything sleep normally. Idempotent: calling it with the level already in
|
|
87
|
+
* force does nothing.
|
|
88
|
+
* @property {() => void} release
|
|
89
|
+
* @property {(blocked: boolean) => Promise<void>} setLidSleepBlocked
|
|
90
|
+
* @property {() => Promise<boolean>} lidSleepBlocked
|
|
91
|
+
* @property {() => void} releaseSync
|
|
92
|
+
* The last gesture on the way out, from `process.on('exit')`, where nothing async can
|
|
93
|
+
* still start. This is what stops a machine being left awake forever.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @typedef {object} MetricsAdapter
|
|
98
|
+
* @property {() => Promise<Map<number, {ppid: number, rss: number, cpu: number, cmd: string}>>} processes
|
|
99
|
+
* @property {() => Promise<{total: number, used: number, app: number, wired: number, compressed: number, cached: number}>} memory
|
|
100
|
+
* @property {() => Promise<{total: number, used: number}>} swap
|
|
101
|
+
* @property {() => Promise<number>} pressureLevel 1 normal, 2 warning, 4 critical
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {object} ShellAdapter
|
|
106
|
+
* @property {(absPath: string) => Promise<unknown>} revealInFileManager
|
|
107
|
+
* @property {(url: string) => Promise<unknown>} openBrowser
|
|
108
|
+
* @property {() => string|null} findChrome a Chromium-family browser able to print
|
|
109
|
+
* @property {() => Set<string>} homeSkipList directories under $HOME that are never projects
|
|
110
|
+
* @property {() => string} findClaude the Claude Code binary
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @typedef {object} ServiceAdapter
|
|
115
|
+
* @property {(opts: {node: string, entry: string, port: number, logDir: string, tray?: string}) => Promise<void>} install
|
|
116
|
+
* @property {() => Promise<void>} uninstall
|
|
117
|
+
* @property {() => Promise<void>} restart
|
|
118
|
+
* @property {() => Promise<{running: boolean}>} status
|
|
119
|
+
* @property {() => {path: string, what: string}[]} describe
|
|
120
|
+
* Everything this adapter will write, for the consent screen the installer shows before
|
|
121
|
+
* it touches anything. If it is not in this list, the installer must not create it.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {object} PlatformAdapter
|
|
126
|
+
* @property {string} name
|
|
127
|
+
* @property {Capabilities} capabilities
|
|
128
|
+
* @property {CapabilityNotes} notes
|
|
129
|
+
* @property {TerminalAdapter} terminal
|
|
130
|
+
* @property {PowerAdapter} power
|
|
131
|
+
* @property {MetricsAdapter} metrics
|
|
132
|
+
* @property {ShellAdapter} shell
|
|
133
|
+
* @property {ServiceAdapter} service
|
|
134
|
+
*/
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { run, runQuiet, runSyncQuiet, which } from '../shared/run.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The optional system tweaks, each one opt-in.
|
|
9
|
+
*
|
|
10
|
+
* These are the parts of installing k0 that reach outside its own files and change something
|
|
11
|
+
* about the machine. They are listed separately from the service because the installer shows
|
|
12
|
+
* them one by one and asks before touching anything: a tool that quietly writes to
|
|
13
|
+
* /etc/sudoers.d and rewrites your Terminal preferences has no business being installed with
|
|
14
|
+
* a single unexplained "y".
|
|
15
|
+
*
|
|
16
|
+
* Every extra can say whether it applies here, whether it is already in place, how to put it
|
|
17
|
+
* there, and how to take it away again.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SUDO = () => which('sudo', ['/usr/bin/sudo'])
|
|
21
|
+
const PMSET = () => which('pmset', ['/usr/bin/pmset'])
|
|
22
|
+
const VISUDO = () => which('visudo', ['/usr/sbin/visudo'])
|
|
23
|
+
const PLUTIL = () => which('plutil', ['/usr/bin/plutil'])
|
|
24
|
+
const DEFAULTS = () => which('defaults', ['/usr/bin/defaults'])
|
|
25
|
+
const KILLALL = () => which('killall', ['/usr/bin/killall'])
|
|
26
|
+
const SW_VERS = () => which('sw_vers', ['/usr/bin/sw_vers'])
|
|
27
|
+
const PS = () => which('ps', ['/bin/ps'])
|
|
28
|
+
|
|
29
|
+
// ── Staying awake with the lid closed ────────────────────────────────────────
|
|
30
|
+
// `caffeinate`, the thing you run by hand in a terminal, only sets *idle* assertions: they
|
|
31
|
+
// stop sleep from inactivity, not the sleep that fires when the lid closes, which is a forced
|
|
32
|
+
// sleep and goes straight past them. The only thing that stops that one too is the system
|
|
33
|
+
// flag `SleepDisabled`, and turning it on takes root.
|
|
34
|
+
//
|
|
35
|
+
// The server runs under launchd with no terminal in front of it, so nobody could type a
|
|
36
|
+
// password. Which is why permission is granted here, once, for that command and nothing
|
|
37
|
+
// else — no wildcards, the arguments spelled out, so the blast radius is exactly two command
|
|
38
|
+
// lines.
|
|
39
|
+
|
|
40
|
+
const SUDOERS = '/etc/sudoers.d/k0-pmset'
|
|
41
|
+
|
|
42
|
+
const sudoersRule = () =>
|
|
43
|
+
`${os.userInfo().username} ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0\n`
|
|
44
|
+
|
|
45
|
+
/** Runs a command with the terminal attached, so sudo can ask for a password. */
|
|
46
|
+
function interactive(file, args) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const child = spawn(file, args, { stdio: 'inherit' })
|
|
49
|
+
child.on('exit', (code) => resolve(code === 0))
|
|
50
|
+
child.on('error', () => resolve(false))
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lidSleep = {
|
|
55
|
+
id: 'lid-sleep',
|
|
56
|
+
title: 'Keep this Mac awake with the lid closed',
|
|
57
|
+
detail:
|
|
58
|
+
'Grants one administrator rule so k0 can toggle the system sleep flag. That is all it ' +
|
|
59
|
+
'can ever do as root: that command, with those arguments. Asks for your password once.',
|
|
60
|
+
target: SUDOERS,
|
|
61
|
+
supported: () => true,
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Already fine means the rule exists AND really does grant that command without a password.
|
|
65
|
+
* The file itself cannot be read back (it is 0440 root, as it should be), but `sudo -l`
|
|
66
|
+
* answers the question that actually matters — "will you let me do this without asking?" —
|
|
67
|
+
* without running anything.
|
|
68
|
+
*/
|
|
69
|
+
async installed() {
|
|
70
|
+
if (!fs.existsSync(SUDOERS)) return false
|
|
71
|
+
const out = await runQuiet(SUDO(), ['-n', '-l', PMSET(), '-a', 'disablesleep', '1'])
|
|
72
|
+
return !!out
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async install() {
|
|
76
|
+
const tmp = path.join(os.tmpdir(), `k0-pmset-${process.pid}`)
|
|
77
|
+
fs.writeFileSync(tmp, sudoersRule(), { mode: 0o600 })
|
|
78
|
+
try {
|
|
79
|
+
// A malformed file in /etc/sudoers.d makes `sudo` unusable on the whole machine: it is
|
|
80
|
+
// validated BEFORE being put in place, and if it does not pass, nothing is installed.
|
|
81
|
+
await run(VISUDO(), ['-c', '-f', tmp])
|
|
82
|
+
} catch {
|
|
83
|
+
fs.unlinkSync(tmp)
|
|
84
|
+
return { ok: false, message: 'the rule did not pass visudo, so it was not installed' }
|
|
85
|
+
}
|
|
86
|
+
const ok = await interactive(SUDO(), ['install', '-m', '0440', '-o', 'root', '-g', 'wheel', tmp, SUDOERS])
|
|
87
|
+
fs.unlinkSync(tmp)
|
|
88
|
+
return ok
|
|
89
|
+
? { ok: true }
|
|
90
|
+
: { ok: false, message: 'permission not granted — k0 will keep the Mac awake only with the lid open' }
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async uninstall() {
|
|
94
|
+
// Lift the ban on sleeping first, then remove the permission to lift it: the other order
|
|
95
|
+
// would leave the Mac awake forever with no way left to fix itself.
|
|
96
|
+
await runQuiet(SUDO(), ['-n', PMSET(), '-a', 'disablesleep', '0'])
|
|
97
|
+
if (!fs.existsSync(SUDOERS)) return { ok: true }
|
|
98
|
+
const ok = await interactive(SUDO(), ['rm', '-f', SUDOERS])
|
|
99
|
+
return { ok }
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Shift+Enter inserts a newline ────────────────────────────────────────────
|
|
104
|
+
// Terminal.app, up to macOS 26, does not tell Shift+Enter from Enter: the program receives
|
|
105
|
+
// the same single byte, so Claude Code sends the message instead of starting a new line. We
|
|
106
|
+
// teach the Terminal profile to send Esc+Enter when you press Shift+Enter: that is the
|
|
107
|
+
// sequence Claude Code reads as "new line" (the same one its own /terminal-setup installs for
|
|
108
|
+
// VS Code, Zed and Alacritty). From macOS 27 the Terminal does it by itself and nothing here
|
|
109
|
+
// is touched.
|
|
110
|
+
//
|
|
111
|
+
// The when matters as much as the what: Terminal reads key bindings ONLY at startup, and on
|
|
112
|
+
// quitting it rewrites its preferences from what it had in memory — erasing our entry. Writing
|
|
113
|
+
// it while Terminal is running is therefore useless at best and counterproductive at worst:
|
|
114
|
+
// the only moment the write survives is right after Terminal has died. The menu bar icon
|
|
115
|
+
// catches that moment and calls us back (see menubar/K0MenuBar.swift).
|
|
116
|
+
|
|
117
|
+
const PREFS = path.join(os.homedir(), 'Library', 'Preferences', 'com.apple.Terminal.plist')
|
|
118
|
+
const BACKUP = `${PREFS}.k0.bak`
|
|
119
|
+
|
|
120
|
+
/** How Terminal writes a key in a profile: modifiers first ($ = Shift), then the key code. */
|
|
121
|
+
const KEY = '$000D'
|
|
122
|
+
/** Esc followed by carriage return: the sequence Claude Code reads as "new line". */
|
|
123
|
+
const ESC_ENTER = '\x1b\r'
|
|
124
|
+
|
|
125
|
+
// plutil uses the dot as a path separator: dots inside a profile name have to be protected,
|
|
126
|
+
// or a profile called "My.Profile" would split the path in two.
|
|
127
|
+
const entryPath = (profile) => `Window Settings.${profile.replace(/\./g, '\\.')}.keyMapBoundKeys.${KEY}`
|
|
128
|
+
const dictPath = (profile) => `Window Settings.${profile.replace(/\./g, '\\.')}.keyMapBoundKeys`
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The profiles to touch: the default one and, if different, the one Terminal opens new
|
|
132
|
+
* windows with. These are the same two reads Claude Code's /terminal-setup makes.
|
|
133
|
+
*/
|
|
134
|
+
async function profiles() {
|
|
135
|
+
const fallback = (await runQuiet(DEFAULTS(), ['read', 'com.apple.Terminal', 'Default Window Settings'])).trim()
|
|
136
|
+
const startup = (await runQuiet(DEFAULTS(), ['read', 'com.apple.Terminal', 'Startup Window Settings'])).trim()
|
|
137
|
+
return [...new Set([fallback, startup].filter(Boolean))]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const hasBinding = (profile) => !!runSyncQuiet(PLUTIL(), ['-extract', entryPath(profile), 'raw', '-o', '-', PREFS])
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* pgrep is no good here: it cannot see the Terminal process's command line, and
|
|
144
|
+
* `pgrep -x Terminal` misses it because the name is the full path.
|
|
145
|
+
*/
|
|
146
|
+
async function terminalRunning() {
|
|
147
|
+
const out = await runQuiet(PS(), ['-Ao', 'comm='])
|
|
148
|
+
return /Terminal\.app\/Contents\/MacOS\/Terminal/.test(out)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function majorVersion() {
|
|
152
|
+
const out = await runQuiet(SW_VERS(), ['-productVersion'])
|
|
153
|
+
return Number(String(out).trim().split('.')[0]) || 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shiftEnter = {
|
|
157
|
+
id: 'shift-enter',
|
|
158
|
+
title: 'Shift+Enter starts a new line in Terminal',
|
|
159
|
+
detail:
|
|
160
|
+
'Adds one key binding to your Terminal profiles, after backing the preferences up. ' +
|
|
161
|
+
'Without it, Shift+Enter sends the message instead of starting a new line.',
|
|
162
|
+
target: PREFS,
|
|
163
|
+
async supported() {
|
|
164
|
+
return (await majorVersion()) < 27
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async installed() {
|
|
168
|
+
if (!fs.existsSync(PREFS)) return false
|
|
169
|
+
const list = await profiles()
|
|
170
|
+
return list.length > 0 && list.every(hasBinding)
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async install() {
|
|
174
|
+
if ((await majorVersion()) >= 27) return { ok: true, message: 'macOS does this by itself now' }
|
|
175
|
+
// Nothing is written while Terminal is running: it would not read the entry anyway, and on
|
|
176
|
+
// quitting it would write its in-memory preferences over the top. The menu bar icon calls
|
|
177
|
+
// us back the moment Terminal closes.
|
|
178
|
+
if (await terminalRunning()) {
|
|
179
|
+
return { ok: false, message: 'Terminal is open — k0 will add it as soon as you quit Terminal' }
|
|
180
|
+
}
|
|
181
|
+
if (!fs.existsSync(PREFS)) return { ok: false, message: 'Terminal preferences not found' }
|
|
182
|
+
|
|
183
|
+
// Before writing, wake up cfprefsd, which caches preferences: if it is still holding
|
|
184
|
+
// anything from a Terminal that just quit, it flushes it to disk NOW. Without this it
|
|
185
|
+
// would flush after us, over the entry we just wrote — which is exactly the trap this
|
|
186
|
+
// whole story started from. Terminal is already dead here, so nothing is taken from anybody.
|
|
187
|
+
await runQuiet(KILLALL(), ['cfprefsd'])
|
|
188
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
189
|
+
|
|
190
|
+
const list = await profiles()
|
|
191
|
+
let wrote = false
|
|
192
|
+
const failed = []
|
|
193
|
+
for (const profile of list) {
|
|
194
|
+
if (hasBinding(profile)) continue
|
|
195
|
+
// A backup before writing, and only the first time: making it now would save the
|
|
196
|
+
// already-modified preferences, which is to say nothing.
|
|
197
|
+
if (!fs.existsSync(BACKUP)) await runQuiet(DEFAULTS(), ['export', 'com.apple.Terminal', BACKUP])
|
|
198
|
+
// The bindings dictionary has to be created if it is not there yet; if it is, this
|
|
199
|
+
// -insert fails, and that is correct.
|
|
200
|
+
await runQuiet(PLUTIL(), ['-insert', dictPath(profile), '-dictionary', PREFS])
|
|
201
|
+
await runQuiet(PLUTIL(), ['-insert', entryPath(profile), '-string', ESC_ENTER, PREFS])
|
|
202
|
+
if (hasBinding(profile)) wrote = true
|
|
203
|
+
else failed.push(profile)
|
|
204
|
+
}
|
|
205
|
+
// cfprefsd caches preferences: without this, nobody sees what we wrote to the file.
|
|
206
|
+
if (wrote) await runQuiet(KILLALL(), ['cfprefsd'])
|
|
207
|
+
if (failed.length) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
message:
|
|
211
|
+
`could not write it to ${failed.join(', ')}. By hand: Terminal → Settings → Profiles → ` +
|
|
212
|
+
'Keyboard → "+", key Return, modifier Shift, action "Send Text" with Esc and Enter.',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { ok: true }
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async uninstall() {
|
|
219
|
+
if (!fs.existsSync(PREFS)) return { ok: true }
|
|
220
|
+
let removed = false
|
|
221
|
+
for (const profile of await profiles()) {
|
|
222
|
+
if (!hasBinding(profile)) continue
|
|
223
|
+
// ONLY this entry is deleted: the dictionary and any bindings added by hand stay.
|
|
224
|
+
await runQuiet(PLUTIL(), ['-remove', entryPath(profile), PREFS])
|
|
225
|
+
removed = true
|
|
226
|
+
}
|
|
227
|
+
if (removed) await runQuiet(KILLALL(), ['cfprefsd'])
|
|
228
|
+
return { ok: true, message: removed ? 'reopen Terminal for it to take effect' : undefined }
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Accessibility ────────────────────────────────────────────────────────────
|
|
233
|
+
// Not something an installer can grant: macOS only lets the user do it, by hand, in System
|
|
234
|
+
// Settings. It is listed anyway so the consent screen can say what is being asked for and,
|
|
235
|
+
// more to the point, what it means.
|
|
236
|
+
|
|
237
|
+
const accessibility = {
|
|
238
|
+
id: 'accessibility',
|
|
239
|
+
title: 'Accessibility permission, so k0 can leave a prompt unsent',
|
|
240
|
+
detail:
|
|
241
|
+
'k0 simulates a Cmd+V to place your prompt in the terminal without sending it. macOS ' +
|
|
242
|
+
'only allows that for apps you approve in System Settings → Privacy & Security → ' +
|
|
243
|
+
'Accessibility. Note that a process with this permission can send keystrokes to any ' +
|
|
244
|
+
'app. Skipping it breaks nothing: the prompt gets typed and the session starts by itself.',
|
|
245
|
+
target: 'System Settings → Privacy & Security → Accessibility',
|
|
246
|
+
supported: () => true,
|
|
247
|
+
manual: true,
|
|
248
|
+
installed: async () => false,
|
|
249
|
+
install: async () => ({ ok: false, message: 'grant this by hand in System Settings' }),
|
|
250
|
+
uninstall: async () => ({ ok: true, message: 'revoke this by hand in System Settings' }),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const extras = [lidSleep, shiftEnter, accessibility]
|
|
254
|
+
export { shiftEnter }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as terminal from './terminal.js'
|
|
2
|
+
import * as power from './power.js'
|
|
3
|
+
import * as metrics from './metrics.js'
|
|
4
|
+
import * as shell from './shell.js'
|
|
5
|
+
import * as service from './service.js'
|
|
6
|
+
import { extras } from './extras.js'
|
|
7
|
+
import { NO_CAPABILITIES } from '../contract.js'
|
|
8
|
+
|
|
9
|
+
export const name = 'macOS'
|
|
10
|
+
|
|
11
|
+
export const capabilities = {
|
|
12
|
+
...NO_CAPABILITIES,
|
|
13
|
+
terminal: terminal.capabilities,
|
|
14
|
+
power: power.capabilities,
|
|
15
|
+
metrics: metrics.capabilities,
|
|
16
|
+
service: service.capabilities,
|
|
17
|
+
tray: true,
|
|
18
|
+
revealInFileManager: true,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Nothing is missing here, so there is nothing to explain. */
|
|
22
|
+
export const notes = {}
|
|
23
|
+
|
|
24
|
+
export { terminal, power, metrics, shell, service, extras }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import { runQuiet, which } from '../shared/run.js'
|
|
3
|
+
import { parsePs } from '../shared/ps.js'
|
|
4
|
+
|
|
5
|
+
const OPTS = { maxBuffer: 1 << 24 }
|
|
6
|
+
|
|
7
|
+
const PS = () => which('ps', ['/bin/ps'])
|
|
8
|
+
const VM_STAT = () => which('vm_stat', ['/usr/bin/vm_stat'])
|
|
9
|
+
const SYSCTL = () => which('sysctl', ['/usr/sbin/sysctl'])
|
|
10
|
+
|
|
11
|
+
export async function processes() {
|
|
12
|
+
return parsePs(await runQuiet(PS(), ['-axo', 'pid=,ppid=,rss=,time=,command='], OPTS))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `vm_stat`, in pages. The entries that matter are the ones Activity Monitor uses to build
|
|
17
|
+
* its "memory used": anonymous pages minus purgeable ones are the applications' memory, and
|
|
18
|
+
* to those you add wired and compressed. The rest — files held in cache — is not memory
|
|
19
|
+
* anybody is short of.
|
|
20
|
+
*/
|
|
21
|
+
export function parseVmStat(out) {
|
|
22
|
+
const text = String(out ?? '')
|
|
23
|
+
const page = Number(/page size of (\d+)/.exec(text)?.[1]) || 4096
|
|
24
|
+
const of = (label) => Number(new RegExp(`^${label}:\\s+(\\d+)`, 'm').exec(text)?.[1] || 0) * page
|
|
25
|
+
|
|
26
|
+
const wired = of('Pages wired down')
|
|
27
|
+
const compressed = of('Pages occupied by compressor')
|
|
28
|
+
const app = Math.max(0, of('Anonymous pages') - of('Pages purgeable'))
|
|
29
|
+
return { used: app + wired + compressed, app, wired, compressed, cached: of('File-backed pages') }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function memory() {
|
|
33
|
+
return { total: os.totalmem(), ...parseVmStat(await runQuiet(VM_STAT(), [], OPTS)) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `vm.swapusage: total = 13312.00M used = 12660.19M free = 651.81M` */
|
|
37
|
+
export function parseSwap(out) {
|
|
38
|
+
const grab = (k) => {
|
|
39
|
+
const m = new RegExp(`${k} = ([\\d.]+)([KMG])`).exec(String(out ?? ''))
|
|
40
|
+
if (!m) return 0
|
|
41
|
+
return Number(m[1]) * { K: 1024, M: 1024 ** 2, G: 1024 ** 3 }[m[2]]
|
|
42
|
+
}
|
|
43
|
+
return { total: grab('total'), used: grab('used') }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function swap() {
|
|
47
|
+
return parseSwap(await runQuiet(SYSCTL(), ['vm.swapusage'], OPTS))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Not our verdict to invent: `kern.memorystatus_vm_pressure_level` is the kernel's own
|
|
52
|
+
* judgement — 1 normal, 2 warning, 4 critical.
|
|
53
|
+
*/
|
|
54
|
+
export async function pressureLevel() {
|
|
55
|
+
const out = await runQuiet(SYSCTL(), ['-n', 'kern.memorystatus_vm_pressure_level'], OPTS)
|
|
56
|
+
return Number(String(out).trim()) || 1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const capabilities = { pressure: true, swap: true }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { run, runSyncQuiet, which } from '../shared/run.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keeping a Mac awake.
|
|
6
|
+
*
|
|
7
|
+
* There are two levers here, and `caffeinate` alone is not enough: its assertions are all
|
|
8
|
+
* *idle* assertions. They stop sleep from inactivity but not the sleep that fires when the
|
|
9
|
+
* lid closes, which is forced and goes straight past them. Only the system flag
|
|
10
|
+
* `SleepDisabled` stops that one, and it wants root — see the installer, which grants that
|
|
11
|
+
* one command and nothing else.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SUDO = () => which('sudo', ['/usr/bin/sudo'])
|
|
15
|
+
const PMSET = () => which('pmset', ['/usr/bin/pmset'])
|
|
16
|
+
const CAFFEINATE = () => which('caffeinate', ['/usr/bin/caffeinate'])
|
|
17
|
+
|
|
18
|
+
/** `-i` is idle sleep, `-m` the disk, `-d` the display. The `-d` is the whole difference. */
|
|
19
|
+
const FLAGS = { system: '-im', display: '-dim' }
|
|
20
|
+
|
|
21
|
+
let child = null // the `caffeinate` child, when there is one
|
|
22
|
+
let activeLevel = null // which level it was started with: changing mode means redoing it
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `SleepDisabled` inside the output of `pmset -g`. When it is zero the line can be missing
|
|
26
|
+
* altogether, so "I did not find it" and "it is 0" are the same answer.
|
|
27
|
+
*/
|
|
28
|
+
export function parseSleepDisabled(out) {
|
|
29
|
+
const m = /^\s*SleepDisabled\s+(\d+)/m.exec(String(out ?? ''))
|
|
30
|
+
return !!m && m[1] !== '0'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where the power comes from and how much charge is left, from `pmset -g batt`. A desktop
|
|
35
|
+
* has no battery: charge comes back `null`, and no threshold should fire for it.
|
|
36
|
+
*/
|
|
37
|
+
export function parseBattery(out) {
|
|
38
|
+
const text = String(out ?? '')
|
|
39
|
+
const charge = /(\d+)%/.exec(text)
|
|
40
|
+
return {
|
|
41
|
+
onAcPower: !/battery power/i.test(text),
|
|
42
|
+
charge: charge ? Number(charge[1]) : null,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function state() {
|
|
47
|
+
try {
|
|
48
|
+
const stdout = await run(PMSET(), ['-g', 'batt'])
|
|
49
|
+
return parseBattery(stdout)
|
|
50
|
+
} catch {
|
|
51
|
+
// If we cannot ask, assume the case that does no harm: plugged in, no threshold to trip.
|
|
52
|
+
return { onAcPower: true, charge: null }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `caffeinate` for this level. Changing mode changes the flags, and a running process
|
|
58
|
+
* cannot have its flags changed: it gets killed and remade. The `-w <our pid>` is the first
|
|
59
|
+
* safety net — it stops by itself when the server dies, even if the server is killed
|
|
60
|
+
* outright and never gets to switch anything off.
|
|
61
|
+
*/
|
|
62
|
+
export function inhibit(level) {
|
|
63
|
+
if (child && activeLevel === level) return
|
|
64
|
+
release()
|
|
65
|
+
if (!level) return
|
|
66
|
+
const flag = FLAGS[level]
|
|
67
|
+
if (!flag) return
|
|
68
|
+
const spawned = spawn(CAFFEINATE(), [flag, '-w', String(process.pid)], { stdio: 'ignore' })
|
|
69
|
+
child = spawned
|
|
70
|
+
activeLevel = level
|
|
71
|
+
// Comparing against `spawned` is not pedantry: the `exit` of the KILLED process arrives
|
|
72
|
+
// AFTER the new one has already started, and a handler that blindly cleared `child` would
|
|
73
|
+
// wipe out the reference to the new one. On the next pass k0 would believe it had none and
|
|
74
|
+
// start a second — measured: two live `caffeinate` processes, one of them with nobody left
|
|
75
|
+
// who knows about it.
|
|
76
|
+
const forget = () => {
|
|
77
|
+
if (child !== spawned) return
|
|
78
|
+
child = null
|
|
79
|
+
activeLevel = null
|
|
80
|
+
}
|
|
81
|
+
spawned.on('error', forget)
|
|
82
|
+
spawned.on('exit', forget)
|
|
83
|
+
spawned.unref() // it is not its job to keep the server alive
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function release() {
|
|
87
|
+
activeLevel = null
|
|
88
|
+
if (!child) return
|
|
89
|
+
try {
|
|
90
|
+
child.kill()
|
|
91
|
+
} catch {
|
|
92
|
+
/* already gone on its own */
|
|
93
|
+
}
|
|
94
|
+
child = null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `sudo -n`: the `-n` is the part that matters. Without the rule in /etc/sudoers.d the
|
|
99
|
+
* command fails IMMEDIATELY instead of hanging waiting for a password nobody here can type —
|
|
100
|
+
* the server runs under launchd, with no terminal in front of it.
|
|
101
|
+
*/
|
|
102
|
+
export async function setLidSleepBlocked(blocked) {
|
|
103
|
+
try {
|
|
104
|
+
await run(SUDO(), ['-n', PMSET(), '-a', 'disablesleep', blocked ? '1' : '0'])
|
|
105
|
+
} catch {
|
|
106
|
+
/* no sudo rule: carry on with caffeinate alone */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function lidSleepBlocked() {
|
|
111
|
+
try {
|
|
112
|
+
return parseSleepDisabled(await run(PMSET(), ['-g']))
|
|
113
|
+
} catch {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The last gesture on the way out. Synchronous on purpose: from `process.on('exit')` nothing
|
|
120
|
+
* asynchronous gets to start any more, and this is the net that stops a Mac being left awake
|
|
121
|
+
* forever because k0 went away while it was on.
|
|
122
|
+
*/
|
|
123
|
+
export function releaseSync() {
|
|
124
|
+
release()
|
|
125
|
+
runSyncQuiet(SUDO(), ['-n', PMSET(), '-a', 'disablesleep', '0'], { stdio: 'ignore', timeout: 4000 })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const capabilities = {
|
|
129
|
+
keepAwake: true,
|
|
130
|
+
keepDisplayAwake: true,
|
|
131
|
+
lidSleep: true,
|
|
132
|
+
battery: true,
|
|
133
|
+
}
|