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.
Files changed (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { runQuiet, which } from '../shared/run.js'
4
+ import { ps, psq } from './powershell.js'
5
+
6
+ /**
7
+ * Starting k0 at login on Windows.
8
+ *
9
+ * A scheduled task, not a Windows service. A service runs in session 0, isolated from every
10
+ * desktop — which would leave k0 unable to open a terminal window or see one, the two things
11
+ * it exists to do. A logon-triggered task runs as you, in your session, which is what is
12
+ * wanted. It also needs no elevation to create.
13
+ */
14
+
15
+ const SCHTASKS = () => which('schtasks')
16
+
17
+ export const SERVER_TASK = 'k0 server'
18
+ export const TRAY_TASK = 'k0 tray'
19
+
20
+ function taskXml({ command, args, port, workingDirectory }) {
21
+ const escape = (s) =>
22
+ String(s).replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c])
23
+ return `<?xml version="1.0" encoding="UTF-16"?>
24
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
25
+ <RegistrationInfo><Description>k0 — the board that drives your Claude Code sessions</Description></RegistrationInfo>
26
+ <Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>
27
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
28
+ <Settings>
29
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
30
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
31
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
32
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
33
+ <RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>
34
+ <Enabled>true</Enabled>
35
+ <Hidden>true</Hidden>
36
+ </Settings>
37
+ <Actions Context="Author">
38
+ <Exec>
39
+ <Command>${escape(command)}</Command>
40
+ <Arguments>${escape(args)}</Arguments>
41
+ <WorkingDirectory>${escape(workingDirectory)}</WorkingDirectory>
42
+ </Exec>
43
+ </Actions>
44
+ </Task>
45
+ `.replace('<Actions', `<!-- K0_PORT=${escape(String(port))} --><Actions`)
46
+ }
47
+
48
+ async function register(name, xml, tmpDir) {
49
+ const file = path.join(tmpDir, `${name.replace(/\s+/g, '-')}.xml`)
50
+ // Task Scheduler insists on UTF-16 for imported task files.
51
+ fs.writeFileSync(file, xml, 'utf16le')
52
+ await runQuiet(SCHTASKS(), ['/Delete', '/TN', name, '/F'])
53
+ await runQuiet(SCHTASKS(), ['/Create', '/TN', name, '/XML', file])
54
+ fs.unlinkSync(file)
55
+ }
56
+
57
+ export async function install({ node, entry, port, logDir, tray, appDir }) {
58
+ if (!SCHTASKS()) throw new Error('k0 could not find schtasks.exe, which it needs to start at login.')
59
+ fs.mkdirSync(logDir, { recursive: true })
60
+
61
+ // K0_PORT reaches the task through the user environment rather than the task XML, which has
62
+ // no way to carry environment variables of its own.
63
+ await ps(`[Environment]::SetEnvironmentVariable('K0_PORT', ${psq(String(port))}, 'User')`)
64
+
65
+ await register(
66
+ SERVER_TASK,
67
+ taskXml({ command: node, args: `"${entry}"`, port, workingDirectory: appDir }),
68
+ logDir
69
+ )
70
+ await runQuiet(SCHTASKS(), ['/Run', '/TN', SERVER_TASK])
71
+
72
+ if (tray && fs.existsSync(tray)) {
73
+ const shell = which('pwsh') || which('powershell')
74
+ await register(
75
+ TRAY_TASK,
76
+ taskXml({
77
+ command: shell,
78
+ args: `-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${tray}"`,
79
+ port,
80
+ workingDirectory: appDir,
81
+ }),
82
+ logDir
83
+ )
84
+ await runQuiet(SCHTASKS(), ['/Run', '/TN', TRAY_TASK])
85
+ }
86
+ }
87
+
88
+ export async function uninstall() {
89
+ for (const name of [SERVER_TASK, TRAY_TASK]) {
90
+ await runQuiet(SCHTASKS(), ['/End', '/TN', name])
91
+ await runQuiet(SCHTASKS(), ['/Delete', '/TN', name, '/F'])
92
+ }
93
+ }
94
+
95
+ export async function restart() {
96
+ await runQuiet(SCHTASKS(), ['/End', '/TN', SERVER_TASK])
97
+ await runQuiet(SCHTASKS(), ['/Run', '/TN', SERVER_TASK])
98
+ }
99
+
100
+ export async function status() {
101
+ const out = await runQuiet(SCHTASKS(), ['/Query', '/TN', SERVER_TASK, '/FO', 'LIST'])
102
+ return { running: /Running/i.test(out) }
103
+ }
104
+
105
+ export const describe = () => [
106
+ { path: `Task Scheduler → ${SERVER_TASK}`, what: 'a scheduled task that starts k0 when you log in' },
107
+ { path: `Task Scheduler → ${TRAY_TASK}`, what: 'a scheduled task for the tray icon' },
108
+ { path: 'User environment variable K0_PORT', what: 'the port k0 listens on' },
109
+ ]
110
+
111
+ export const capabilities = () => ({ autostart: !!SCHTASKS() })
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { runQuiet, runSyncQuiet, which } from '../shared/run.js'
5
+
6
+ const EXPLORER = () => which('explorer') || 'explorer.exe'
7
+
8
+ /** `explorer /select,` opens the folder with the file already highlighted. */
9
+ export const revealInFileManager = (abs) => runQuiet(EXPLORER(), [`/select,${path.normalize(abs)}`])
10
+
11
+ export const openBrowser = (url) => runQuiet('cmd', ['/c', 'start', '', url])
12
+
13
+ const programFiles = [
14
+ process.env['PROGRAMFILES'],
15
+ process.env['PROGRAMFILES(X86)'],
16
+ process.env.LOCALAPPDATA,
17
+ ].filter(Boolean)
18
+
19
+ const CHROME_SUFFIXES = [
20
+ ['Google', 'Chrome', 'Application', 'chrome.exe'],
21
+ ['Chromium', 'Application', 'chrome.exe'],
22
+ ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
23
+ ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
24
+ ]
25
+
26
+ export function findChrome() {
27
+ for (const base of programFiles) {
28
+ for (const suffix of CHROME_SUFFIXES) {
29
+ const candidate = path.join(base, ...suffix)
30
+ if (fs.existsSync(candidate)) return candidate
31
+ }
32
+ }
33
+ return which('chrome') || which('msedge') || null
34
+ }
35
+
36
+ /**
37
+ * Directories under the user's profile that are never projects. Windows keeps its own state
38
+ * in the first three and the shell folders in the rest; unlike macOS these are not localised
39
+ * on disk, whatever File Explorer chooses to display.
40
+ */
41
+ export const homeSkipList = () =>
42
+ new Set([
43
+ 'AppData', 'Application Data', 'Local Settings',
44
+ 'Desktop', 'Documents', 'Downloads', 'Music', 'Pictures', 'Videos',
45
+ 'Favorites', 'Links', 'Contacts', 'Searches', 'Saved Games', 'OneDrive',
46
+ ])
47
+
48
+ let claudeBin = null
49
+
50
+ export function findClaude() {
51
+ if (claudeBin) return claudeBin
52
+ const candidates = [
53
+ path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
54
+ path.join(os.homedir(), '.local', 'bin', 'claude.cmd'),
55
+ path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
56
+ ]
57
+ for (const c of candidates) if (c && fs.existsSync(c)) return (claudeBin = c)
58
+ const fromPath = which('claude')
59
+ if (fromPath) return (claudeBin = fromPath)
60
+ claudeBin = runSyncQuiet('cmd', ['/c', 'where claude']).trim().split('\n')[0] || 'claude'
61
+ return claudeBin
62
+ }
63
+
64
+ export const capabilities = () => ({ revealInFileManager: true })
@@ -0,0 +1,209 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { which } from '../shared/run.js'
4
+ import { ps, psJson, psq } from './powershell.js'
5
+
6
+ /**
7
+ * Terminals on Windows.
8
+ *
9
+ * This is the adapter that gives something up, and it is worth being plain about what.
10
+ *
11
+ * Windows has no way for one process to read what is printed inside another's console. On
12
+ * macOS `readScreen` is how k0 knows Claude Code's interface has finished drawing and is
13
+ * ready to receive a prompt; here there is nothing to ask, so k0 waits a fixed moment
14
+ * instead. That is a guess, and a slow machine can beat it — which is why the capability is
15
+ * reported as missing rather than quietly approximated.
16
+ *
17
+ * Everything else survives. A prompt can be placed without being sent, because Windows lets
18
+ * any process put text on the clipboard and send a Ctrl+V to the foreground window without
19
+ * asking for a special permission — the thing macOS needs Accessibility for. Windows can be
20
+ * moved, resized and raised through user32, found by the unique title k0 gives each one.
21
+ *
22
+ * If you run Claude Code inside WSL, run k0 inside WSL too: there it is simply the Linux
23
+ * build, tmux and all, with nothing given up.
24
+ */
25
+
26
+ const WT = () => which('wt')
27
+ const POWERSHELL_BIN = () => which('pwsh') || which('powershell')
28
+
29
+ /** The user32 declarations every window operation here needs, added once per PowerShell run. */
30
+ const USER32 = `
31
+ Add-Type -Namespace K0 -Name Win -MemberDefinition @'
32
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
33
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
34
+ [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int t, bool r);
35
+ '@ -ErrorAction SilentlyContinue
36
+ `
37
+
38
+ /**
39
+ * The handle k0 stores is the window title, because it is the only identifier that survives
40
+ * the launch: `wt.exe` hands its arguments to an already-running Windows Terminal and exits,
41
+ * so its own process id says nothing about the window that appeared.
42
+ */
43
+ const handleFor = (title) => `k0-${randomUUID().slice(0, 8)} ${title}`.trim()
44
+
45
+ const findWindow = (handle) =>
46
+ `Get-Process | Where-Object { $_.MainWindowTitle -like ${psq('*' + handle + '*')} } | Select-Object -First 1`
47
+
48
+ export async function open({ command, title }) {
49
+ const handle = handleFor(title)
50
+ const shell = POWERSHELL_BIN()
51
+ if (!shell) throw new Error('k0 could not find PowerShell, which it needs to open terminals on Windows.')
52
+
53
+ // The title is set from inside the session rather than passed to the terminal, because it
54
+ // has to be there whichever terminal actually opens — Windows Terminal, the old console
55
+ // host, or whatever the user has set as default.
56
+ const inner = `$host.UI.RawUI.WindowTitle = ${psq(handle)}; ${command}`
57
+ const wt = WT()
58
+ const [file, args] = wt
59
+ ? [wt, ['new-tab', '--title', handle, shell, '-NoProfile', '-NoLogo', '-NoExit', '-Command', inner]]
60
+ : [shell, ['-NoProfile', '-NoLogo', '-NoExit', '-Command', inner]]
61
+
62
+ const child = spawn(file, args, { stdio: 'ignore', detached: true, windowsHide: false })
63
+ child.unref()
64
+ return handle
65
+ }
66
+
67
+ /**
68
+ * Windows Terminal owns its tab titles and does not take instructions about them from
69
+ * outside, so a card renamed after its session started keeps the old name in the title bar
70
+ * until the session is restarted. Reported as unavailable rather than failing silently.
71
+ */
72
+ export async function setTitle() {
73
+ return false
74
+ }
75
+
76
+ export async function focus(handle) {
77
+ if (!handle) return { ok: false, error: 'This card has no terminal window of its own' }
78
+ const found = await psJson(
79
+ `${USER32}
80
+ $p = ${findWindow(handle)}
81
+ if ($p) { [K0.Win]::ShowWindow($p.MainWindowHandle, 9) | Out-Null
82
+ [K0.Win]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
83
+ @{ ok = $true } } else { @{ ok = $false } }`,
84
+ { ok: false }
85
+ )
86
+ return found?.ok ? { ok: true } : { ok: false, error: 'That window is gone' }
87
+ }
88
+
89
+ export async function relayout(handles) {
90
+ let touched = 0
91
+ for (const handle of handles || []) {
92
+ const ok = await psJson(
93
+ `${USER32}
94
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
95
+ $area = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
96
+ $w = [int]($area.Width * 0.86); $h = [int]($area.Height * 0.86)
97
+ $x = $area.X + [int](($area.Width - $w) / 2); $y = $area.Y + [int](($area.Height - $h) / 2)
98
+ $p = ${findWindow(handle)}
99
+ if ($p) { [K0.Win]::MoveWindow($p.MainWindowHandle, $x, $y, $w, $h, $true) | Out-Null; @{ ok = $true } }
100
+ else { @{ ok = $false } }`,
101
+ { ok: false }
102
+ )
103
+ if (ok?.ok) touched++
104
+ }
105
+ return { touched }
106
+ }
107
+
108
+ /** Font size lives in the terminal's own profile, which k0 will not rewrite. */
109
+ export async function setFont() {
110
+ return { touched: 0 }
111
+ }
112
+
113
+ export async function defaultFontSize() {
114
+ return 12
115
+ }
116
+
117
+ /** Nothing to read: see the note at the top of this file. */
118
+ export async function readScreen() {
119
+ return null
120
+ }
121
+
122
+ /**
123
+ * Put the prompt in front of you without sending it: clipboard, then a Ctrl+V into the
124
+ * window once it is in the foreground. SendKeys needs no elevation and no special permission
125
+ * on Windows, which makes this the one place where Windows has an easier time than macOS.
126
+ */
127
+ export async function paste(text, handle) {
128
+ try {
129
+ await ps(
130
+ `${USER32}
131
+ Add-Type -AssemblyName System.Windows.Forms
132
+ $previous = ''
133
+ try { $previous = Get-Clipboard -Raw } catch {}
134
+ Set-Clipboard -Value ${psq(text)}
135
+ $p = ${findWindow(handle)}
136
+ if (-not $p) { throw 'window not found' }
137
+ [K0.Win]::ShowWindow($p.MainWindowHandle, 9) | Out-Null
138
+ [K0.Win]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
139
+ Start-Sleep -Milliseconds 250
140
+ [System.Windows.Forms.SendKeys]::SendWait('^v')
141
+ Start-Sleep -Milliseconds 250
142
+ if ($previous) { Set-Clipboard -Value $previous }`,
143
+ { timeout: 15000 }
144
+ )
145
+ return { pasted: true }
146
+ } catch (err) {
147
+ return { pasted: false, error: String(err.stderr || err.message).trim() }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * The fallback: type the prompt and send it. Same trade as on macOS — the session starts on
153
+ * its own instead of waiting for you — and used only when pasting failed.
154
+ */
155
+ export async function type(text, handle) {
156
+ try {
157
+ await ps(
158
+ `${USER32}
159
+ Add-Type -AssemblyName System.Windows.Forms
160
+ Set-Clipboard -Value ${psq(text)}
161
+ $p = ${findWindow(handle)}
162
+ if (-not $p) { throw 'window not found' }
163
+ [K0.Win]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
164
+ Start-Sleep -Milliseconds 250
165
+ [System.Windows.Forms.SendKeys]::SendWait('^v')
166
+ Start-Sleep -Milliseconds 250
167
+ [System.Windows.Forms.SendKeys]::SendWait('{ENTER}')`,
168
+ { timeout: 15000 }
169
+ )
170
+ return { written: true }
171
+ } catch (err) {
172
+ return { written: false, error: String(err.stderr || err.message).trim() }
173
+ }
174
+ }
175
+
176
+ export async function close({ handle, pid }) {
177
+ if (pid) {
178
+ try {
179
+ process.kill(pid, 'SIGTERM')
180
+ for (let i = 0; i < 20; i++) {
181
+ await new Promise((r) => setTimeout(r, 100))
182
+ try {
183
+ process.kill(pid, 0)
184
+ } catch {
185
+ break
186
+ }
187
+ }
188
+ } catch {
189
+ /* already gone */
190
+ }
191
+ }
192
+ if (!handle) return { closed: false }
193
+ const res = await psJson(
194
+ `$p = ${findWindow(handle)}
195
+ if ($p) { $p.CloseMainWindow() | Out-Null; @{ ok = $true } } else { @{ ok = $false } }`,
196
+ { ok: false }
197
+ )
198
+ return { closed: !!res?.ok }
199
+ }
200
+
201
+ export const capabilities = () => ({
202
+ windows: !!POWERSHELL_BIN(),
203
+ font: false,
204
+ readScreen: false,
205
+ pasteWithoutSending: !!POWERSHELL_BIN(),
206
+ title: false,
207
+ })
208
+
209
+ export { powershellCommand as buildCommand } from '../shared/command.js'
@@ -0,0 +1,159 @@
1
+ # k0's tray icon for Windows.
2
+ #
3
+ # The counterpart of menubar/K0MenuBar.swift and platform/linux/tray.py, and deliberately the
4
+ # same shape: poll /api/status every couple of seconds, tint a small square with the colour of
5
+ # the most urgent session, and put the sessions that are waiting for you at the top of the
6
+ # menu so one click brings the right terminal back.
7
+ #
8
+ # WinForms' NotifyIcon is used because it is part of .NET Framework, which is on every Windows
9
+ # install: nothing to download, nothing to compile, no dependency added to a project that has
10
+ # none. The icon is drawn at runtime rather than shipped as a file, for the same reason.
11
+
12
+ param([int]$Port = $(if ($env:K0_PORT) { [int]$env:K0_PORT } else { 4319 }))
13
+
14
+ $ErrorActionPreference = 'Stop'
15
+ Add-Type -AssemblyName System.Windows.Forms
16
+ Add-Type -AssemblyName System.Drawing
17
+
18
+ $Base = "http://127.0.0.1:$Port"
19
+
20
+ # The same colours and the same order of urgency as the board. ASK first because a question
21
+ # blocks everything behind it; COMPLETED never gets here.
22
+ $Urgency = @('ASK', 'PLANNED', 'IDLE', 'WORKING', 'PLANNING')
23
+ $Colours = @{
24
+ ASK = @(232, 106, 51)
25
+ PLANNED = @(108, 142, 191)
26
+ IDLE = @(224, 192, 74)
27
+ WORKING = @(91, 168, 106)
28
+ PLANNING = @(142, 124, 195)
29
+ quiet = @(154, 154, 154)
30
+ down = @(176, 80, 80)
31
+ }
32
+ $Labels = @{
33
+ ASK = 'Needs answer'; PLANNED = 'Needs approval'; IDLE = 'Your turn'
34
+ WORKING = 'Working'; PLANNING = 'Planning'
35
+ }
36
+ $Modes = [ordered]@{ sleep = 'Sleep'; away = 'Away'; nerd = 'Nerd'; driving = 'Driving' }
37
+
38
+ $script:IconCache = @{}
39
+ $script:Seen = @{}
40
+ $script:FirstPass = $true
41
+ $script:Mode = $null
42
+
43
+ function Get-K0Icon([string]$State) {
44
+ if ($script:IconCache.ContainsKey($State)) { return $script:IconCache[$State] }
45
+ $rgb = $Colours[$State]
46
+ if (-not $rgb) { $rgb = $Colours['quiet'] }
47
+ $bitmap = New-Object System.Drawing.Bitmap 16, 16
48
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
49
+ $brush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb($rgb[0], $rgb[1], $rgb[2]))
50
+ $graphics.FillRectangle($brush, 1, 1, 14, 14)
51
+ $graphics.Dispose()
52
+ $icon = [System.Drawing.Icon]::FromHandle($bitmap.GetHicon())
53
+ $script:IconCache[$State] = $icon
54
+ return $icon
55
+ }
56
+
57
+ function Invoke-K0([string]$Path, $Body) {
58
+ try {
59
+ if ($null -ne $Body) {
60
+ $json = $Body | ConvertTo-Json -Compress
61
+ return Invoke-RestMethod -Uri ($Base + $Path) -Method Post -Body $json `
62
+ -ContentType 'application/json' -Headers @{ Origin = $Base } -TimeoutSec 3
63
+ }
64
+ return Invoke-RestMethod -Uri ($Base + $Path) -Method Get -Headers @{ Origin = $Base } -TimeoutSec 3
65
+ } catch {
66
+ return $null
67
+ }
68
+ }
69
+
70
+ $notify = New-Object System.Windows.Forms.NotifyIcon
71
+ $notify.Icon = Get-K0Icon 'quiet'
72
+ $notify.Text = 'k0'
73
+ $notify.Visible = $true
74
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
75
+ $notify.ContextMenuStrip = $menu
76
+
77
+ function Add-K0Item($Text, $Action, [bool]$Enabled = $true) {
78
+ $item = New-Object System.Windows.Forms.ToolStripMenuItem
79
+ $item.Text = $Text
80
+ $item.Enabled = $Enabled -and ($null -ne $Action)
81
+ if ($null -ne $Action) { $item.Add_Click($Action) | Out-Null }
82
+ $menu.Items.Add($item) | Out-Null
83
+ return $item
84
+ }
85
+
86
+ function Update-K0Menu($Waiting, [bool]$Down) {
87
+ $menu.Items.Clear()
88
+ if ($Down) {
89
+ Add-K0Item 'Server not responding' $null $false | Out-Null
90
+ } elseif (-not $Waiting -or $Waiting.Count -eq 0) {
91
+ Add-K0Item 'Nothing waiting for you' $null $false | Out-Null
92
+ } else {
93
+ foreach ($card in $Waiting) {
94
+ $label = "$($Labels[$card.status]) - $($card.title)"
95
+ $id = $card.id
96
+ Add-K0Item $label ([System.EventHandler]{ Invoke-K0 "/api/card/$id/focus" @{} | Out-Null }.GetNewClosure()) | Out-Null
97
+ }
98
+ }
99
+
100
+ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
101
+ foreach ($key in $Modes.Keys) {
102
+ $value = $key
103
+ $item = Add-K0Item $Modes[$key] ([System.EventHandler]{
104
+ if ($script:Mode -ne $value) { Invoke-K0 '/api/mode' @{ mode = $value } | Out-Null }
105
+ }.GetNewClosure())
106
+ $item.Checked = ($script:Mode -eq $value)
107
+ }
108
+
109
+ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
110
+ Add-K0Item 'Dashboard' ([System.EventHandler]{ Start-Process $Base }) | Out-Null
111
+ Add-K0Item 'Restart server' ([System.EventHandler]{
112
+ schtasks /End /TN 'k0 server' | Out-Null; schtasks /Run /TN 'k0 server' | Out-Null
113
+ }) | Out-Null
114
+ Add-K0Item 'Quit' ([System.EventHandler]{
115
+ $notify.Visible = $false; [System.Windows.Forms.Application]::Exit()
116
+ }) | Out-Null
117
+ }
118
+
119
+ # Notify about cards that have just started waiting, not about every one on every pass.
120
+ function Show-K0Notifications($Waiting) {
121
+ $now = @{}
122
+ foreach ($card in $Waiting) { $now["$($card.id):$($card.status)"] = $true }
123
+ if (-not $script:FirstPass) {
124
+ foreach ($card in $Waiting) {
125
+ $key = "$($card.id):$($card.status)"
126
+ if (-not $script:Seen.ContainsKey($key)) {
127
+ $notify.BalloonTipTitle = $Labels[$card.status]
128
+ $notify.BalloonTipText = $card.title
129
+ $notify.ShowBalloonTip(4000)
130
+ }
131
+ }
132
+ }
133
+ $script:Seen = $now
134
+ $script:FirstPass = $false
135
+ }
136
+
137
+ $timer = New-Object System.Windows.Forms.Timer
138
+ $timer.Interval = 2000
139
+ $timer.Add_Tick({
140
+ $status = Invoke-K0 '/api/status'
141
+ if ($null -eq $status) {
142
+ $notify.Icon = Get-K0Icon 'down'
143
+ $notify.Text = 'k0: server not responding'
144
+ Update-K0Menu @() $true
145
+ return
146
+ }
147
+ $waiting = @($status.waiting)
148
+ $script:Mode = $status.mode
149
+ $state = if ($status.urgent) { $status.urgent } else { 'quiet' }
150
+ $notify.Icon = Get-K0Icon $state
151
+ $notify.Text = if ($Labels[$state]) { $Labels[$state] } else { 'k0 - nothing waiting for you' }
152
+ Update-K0Menu $waiting $false
153
+ Show-K0Notifications $waiting
154
+ })
155
+ $timer.Start()
156
+
157
+ Update-K0Menu @() $true
158
+ [System.Windows.Forms.Application]::Run()
159
+ $notify.Visible = $false