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,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) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[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
|