pi-caps-blink 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/LICENSE +21 -0
- package/README.md +117 -0
- package/daemon/CapsBlinkDaemon.swift +585 -0
- package/extensions/index.ts +80 -0
- package/package.json +54 -0
- package/src/client.ts +351 -0
- package/src/constants.ts +24 -0
- package/src/platform.ts +39 -0
- package/src/runtime-paths.ts +61 -0
- package/src/spawn.ts +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boston Cartwright
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# pi-caps-blink
|
|
2
|
+
|
|
3
|
+
Pi extension for macOS that blinks the MacBook Caps Lock LED while any Pi agent is actively running on the machine, and restores the system-managed LED when all agents settle.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- macOS 26 or later (Darwin 25+)
|
|
8
|
+
- Node.js 22.19+
|
|
9
|
+
- Xcode Command Line Tools (or Xcode) so `/usr/bin/xcrun swift` can run the packaged daemon script
|
|
10
|
+
- A Mac with a built-in keyboard HID service (`Built-In == true`); external keyboards are never used
|
|
11
|
+
|
|
12
|
+
The npm manifest rejects non-macOS installation. Older macOS versions, or local
|
|
13
|
+
loads on unsupported platforms, disable the extension with a warning.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
From npm:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:pi-caps-blink
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
From a local checkout while developing:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install /absolute/path/to/pi-caps-blink
|
|
27
|
+
# or
|
|
28
|
+
pi install ./relative/path/to/pi-caps-blink
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use `-l` for a project-local install. To try one run without persisting settings: `pi -e ./path/to/pi-caps-blink`.
|
|
32
|
+
|
|
33
|
+
## Busy semantics
|
|
34
|
+
|
|
35
|
+
Busy is agent activity, not idle TUI presence:
|
|
36
|
+
|
|
37
|
+
| Event | Behavior |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `agent_start` | Acquire (desired busy) |
|
|
40
|
+
| `agent_settled` | Release only if `ctx.isIdle()` is true |
|
|
41
|
+
| `session_start` | Acquire if the session is already non-idle |
|
|
42
|
+
| `session_shutdown` | Always release |
|
|
43
|
+
|
|
44
|
+
`agent_settled` is used instead of `agent_end` because retries, compaction retries, and queued follow-ups can remain after `agent_end`.
|
|
45
|
+
|
|
46
|
+
Handlers update desired state synchronously and reconcile in the background. They never await daemon startup or delay agent work. Failures warn at most once per active period and degrade to no blinking.
|
|
47
|
+
|
|
48
|
+
## Machine-wide aggregation
|
|
49
|
+
|
|
50
|
+
Multiple Pi processes share one per-user Swift daemon. An open Unix-domain socket connection is one active lease; there is no heartbeat protocol. The daemon blinks while the lease count is greater than zero and stops only when the last lease closes. Kernel EOF drops a lease if a client crashes or is SIGKILL’d, so one process cannot turn the LED off while another remains busy.
|
|
51
|
+
|
|
52
|
+
## Behavior
|
|
53
|
+
|
|
54
|
+
- Blink cadence: **250 ms On / 750 ms Off**, starting On as soon as the first lease appears
|
|
55
|
+
- Idle restore: on transition to zero leases, the daemon synchronously stops the timer and sets `HIDCapsLockLED` to **Auto**
|
|
56
|
+
- Daemon idle exit: after 5 minutes with no leases, the daemon exits, allowing updates and uninstall without leaving an orphan process while avoiding Swift startup between adjacent runs
|
|
57
|
+
- Startup always restores **Auto** before accepting leases
|
|
58
|
+
|
|
59
|
+
## Caps Lock LED caveat
|
|
60
|
+
|
|
61
|
+
The LED is controlled via the private, unsupported `HIDCapsLockLED` property (`On` / `Off` / `Auto`) on a passive IOHID event-system client. This is not a public API and may break or be rejected by future macOS releases. The extension feature-detects and fails soft.
|
|
62
|
+
|
|
63
|
+
No root, Accessibility, or Input Monitoring permission is expected for this path on currently tested hardware. Logical Caps Lock state (`HIDCapsLockState`) is not written.
|
|
64
|
+
|
|
65
|
+
## Auto restoration
|
|
66
|
+
|
|
67
|
+
`HIDCapsLockLED=Auto` is restored when:
|
|
68
|
+
|
|
69
|
+
- The last lease closes
|
|
70
|
+
- The daemon starts (before accepting clients)
|
|
71
|
+
- Catchable termination (`SIGINT` / `SIGTERM`) or clean idle exit
|
|
72
|
+
|
|
73
|
+
**Limitation:** SIGKILL of the daemon or sudden power loss can leave an On/Off override stranded. Recovery is best-effort: the next daemon start restores Auto before accepting leases. Starting any Pi session that becomes busy (or otherwise spawning the daemon) triggers that recovery.
|
|
74
|
+
|
|
75
|
+
## Architecture
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
Pi extension (TypeScript)
|
|
79
|
+
└─ CapsBlinkClient (desiredBusy / socket / retry / generation)
|
|
80
|
+
└─ Unix socket lease ──► CapsBlinkDaemon.swift (singleton)
|
|
81
|
+
├─ flock on runtime lock file
|
|
82
|
+
├─ count same-UID accepted fds
|
|
83
|
+
└─ HID LED On/Off/Auto (built-in keyboard only)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Runtime directory: Darwin per-user temp (`getconf DARWIN_USER_TEMP_DIR` / `confstr(_CS_DARWIN_USER_TEMP_DIR)`) + `pi-caps-blink-v1/`, with short `l` (lock) and `s` (socket) names. Directory mode `0700`, socket/lock `0600`, no symlinks, UID ownership checks, `getpeereid` same-UID enforcement, and socket path length under 104 bytes. TypeScript passes the canonical path; Swift independently derives it and rejects mismatches. On connect failure, the extension spawns `/usr/bin/xcrun swift <packaged-script>` detached and retries with exponential backoff + jitter; only the flock winner binds the socket.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm install
|
|
92
|
+
npm run check
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The daemon accepts `--fake-led-file <path>` for LED-free local checks of lease/blink lifecycle.
|
|
96
|
+
|
|
97
|
+
## Manual hardware test
|
|
98
|
+
|
|
99
|
+
On a MacBook (or other Mac with a built-in keyboard) running macOS 26+:
|
|
100
|
+
|
|
101
|
+
1. Install this package into Pi and start a session; confirm Caps Lock still reflects real Caps Lock when idle (Auto).
|
|
102
|
+
2. Start an agent turn; the Caps Lock LED should blink ~250 ms on / 750 ms off without changing typing Caps Lock behavior.
|
|
103
|
+
3. Open a second Pi process and run an agent there too; settling one session must not stop the blink while the other remains busy.
|
|
104
|
+
4. Settle all agents; the LED should return to normal Caps Lock indication promptly.
|
|
105
|
+
5. Optional: kill a busy Pi process mid-turn; its lease should drop via EOF and blinking should continue or stop according to remaining leases.
|
|
106
|
+
6. Optional recovery check: if the LED is stuck after a forced daemon kill, start Pi again and become busy once—daemon startup should restore Auto first.
|
|
107
|
+
|
|
108
|
+
## Privacy and security
|
|
109
|
+
|
|
110
|
+
- No network I/O; coordination is a per-user local Unix socket under the Darwin user temp directory
|
|
111
|
+
- No agent transcripts, prompts, or tool output are read or sent
|
|
112
|
+
- Same-UID peer checks and restrictive file modes limit cross-user access to the lease socket
|
|
113
|
+
- The extension and daemon run as your user; like other Pi packages, review source before installing from untrusted origins
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT — Copyright (c) 2026 Boston Cartwright. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
#!/usr/bin/env swift
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// Per-user lease coordinator for the Pi Caps Lock LED indicator.
|
|
5
|
+
// This source is intentionally self-contained so it can be run by `swift`.
|
|
6
|
+
|
|
7
|
+
import CoreFoundation
|
|
8
|
+
import Dispatch
|
|
9
|
+
import Foundation
|
|
10
|
+
import IOKit.hidsystem
|
|
11
|
+
import Darwin
|
|
12
|
+
|
|
13
|
+
// This HID constructor is exported by IOKit but is not in the public SDK
|
|
14
|
+
// module. Client type 2 is the passive event-system client.
|
|
15
|
+
@_silgen_name("IOHIDEventSystemClientCreateWithType")
|
|
16
|
+
private func createPassiveHIDClient(
|
|
17
|
+
_ allocator: CFAllocator?,
|
|
18
|
+
_ clientType: UInt32,
|
|
19
|
+
_ attributes: CFDictionary?
|
|
20
|
+
) -> IOHIDEventSystemClient?
|
|
21
|
+
|
|
22
|
+
private let runtimeLeaf = "pi-caps-blink-v1"
|
|
23
|
+
private let lockLeaf = "l"
|
|
24
|
+
private let socketLeaf = "s"
|
|
25
|
+
private let queue = DispatchQueue(label: "dev.pi.caps-blink.daemon")
|
|
26
|
+
|
|
27
|
+
private enum DaemonError: Error, CustomStringConvertible {
|
|
28
|
+
case usage
|
|
29
|
+
case unsupportedOS
|
|
30
|
+
case invalidRuntimeDirectory
|
|
31
|
+
case unsafeRuntimeDirectory(String)
|
|
32
|
+
case lockUnavailable
|
|
33
|
+
case socketPathTooLong
|
|
34
|
+
case systemCall(String)
|
|
35
|
+
case noBuiltInKeyboard
|
|
36
|
+
case ledWriteRejected
|
|
37
|
+
|
|
38
|
+
var description: String {
|
|
39
|
+
switch self {
|
|
40
|
+
case .usage:
|
|
41
|
+
return "usage: CapsBlinkDaemon.swift --runtime-dir <DARWIN_USER_TEMP_DIR>/pi-caps-blink-v1 [--fake-led-file <path>]"
|
|
42
|
+
case .unsupportedOS:
|
|
43
|
+
return "macOS 26 or later is required"
|
|
44
|
+
case .invalidRuntimeDirectory:
|
|
45
|
+
return "runtime directory does not match DARWIN_USER_TEMP_DIR"
|
|
46
|
+
case let .unsafeRuntimeDirectory(reason):
|
|
47
|
+
return "unsafe runtime directory: \(reason)"
|
|
48
|
+
case .lockUnavailable:
|
|
49
|
+
return "another daemon owns the runtime lock"
|
|
50
|
+
case .socketPathTooLong:
|
|
51
|
+
return "Unix socket path is too long"
|
|
52
|
+
case let .systemCall(name):
|
|
53
|
+
return "\(name): \(String(cString: strerror(errno)))"
|
|
54
|
+
case .noBuiltInKeyboard:
|
|
55
|
+
return "no built-in keyboard HID service is available"
|
|
56
|
+
case .ledWriteRejected:
|
|
57
|
+
return "macOS rejected the Caps Lock LED update"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private enum LEDMode: String {
|
|
63
|
+
case on = "On"
|
|
64
|
+
case off = "Off"
|
|
65
|
+
case auto = "Auto"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private protocol LEDBackend {
|
|
69
|
+
func apply(_ mode: LEDMode) throws
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Uses the HID keyboard filter's private override property. It deliberately
|
|
73
|
+
/// selects only a built-in Generic Desktop Keyboard service.
|
|
74
|
+
private final class HIDLEDBackend: LEDBackend {
|
|
75
|
+
private let client: IOHIDEventSystemClient
|
|
76
|
+
private let ledKey = "HIDCapsLockLED" as CFString
|
|
77
|
+
private let builtInKey = "Built-In" as CFString
|
|
78
|
+
|
|
79
|
+
init() throws {
|
|
80
|
+
guard let client = createPassiveHIDClient(kCFAllocatorDefault, 2, nil) else {
|
|
81
|
+
throw DaemonError.noBuiltInKeyboard
|
|
82
|
+
}
|
|
83
|
+
self.client = client
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func apply(_ mode: LEDMode) throws {
|
|
87
|
+
guard let services = IOHIDEventSystemClientCopyServices(client) else {
|
|
88
|
+
throw DaemonError.noBuiltInKeyboard
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
var foundBuiltInKeyboard = false
|
|
92
|
+
var acceptedWrite = false
|
|
93
|
+
let count = CFArrayGetCount(services)
|
|
94
|
+
for index in 0..<count {
|
|
95
|
+
guard let rawService = CFArrayGetValueAtIndex(services, index) else { continue }
|
|
96
|
+
let service = unsafeBitCast(rawService, to: IOHIDServiceClient.self)
|
|
97
|
+
guard IOHIDServiceClientConformsTo(service, 0x01, 0x06) != 0 else { continue }
|
|
98
|
+
guard isBuiltIn(service) else { continue }
|
|
99
|
+
|
|
100
|
+
foundBuiltInKeyboard = true
|
|
101
|
+
let value = mode.rawValue as NSString
|
|
102
|
+
if IOHIDServiceClientSetProperty(service, ledKey, value) {
|
|
103
|
+
acceptedWrite = true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
guard foundBuiltInKeyboard else { throw DaemonError.noBuiltInKeyboard }
|
|
108
|
+
guard acceptedWrite else { throw DaemonError.ledWriteRejected }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func isBuiltIn(_ service: IOHIDServiceClient) -> Bool {
|
|
112
|
+
guard let value = IOHIDServiceClientCopyProperty(service, builtInKey) else { return false }
|
|
113
|
+
return (value as? NSNumber)?.boolValue == true
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Explicitly opt-in test backend. It writes only the latest requested state.
|
|
118
|
+
private final class FakeLEDBackend: LEDBackend {
|
|
119
|
+
private let path: String
|
|
120
|
+
|
|
121
|
+
init(path: String) {
|
|
122
|
+
self.path = path
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func apply(_ mode: LEDMode) throws {
|
|
126
|
+
let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, S_IRUSR | S_IWUSR)
|
|
127
|
+
guard fd >= 0 else { throw DaemonError.systemCall("open fake LED file") }
|
|
128
|
+
defer { _ = close(fd) }
|
|
129
|
+
|
|
130
|
+
var info = stat()
|
|
131
|
+
guard fstat(fd, &info) == 0 else { throw DaemonError.systemCall("fstat fake LED file") }
|
|
132
|
+
guard (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == getuid() else {
|
|
133
|
+
throw DaemonError.unsafeRuntimeDirectory("fake LED file is not a regular file owned by this user")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let bytes = Array((mode.rawValue + "\n").utf8)
|
|
137
|
+
var written = 0
|
|
138
|
+
while written < bytes.count {
|
|
139
|
+
let result = bytes.withUnsafeBytes { buffer in
|
|
140
|
+
write(fd, buffer.baseAddress!.advanced(by: written), bytes.count - written)
|
|
141
|
+
}
|
|
142
|
+
if result > 0 {
|
|
143
|
+
written += result
|
|
144
|
+
} else if result < 0 && errno == EINTR {
|
|
145
|
+
continue
|
|
146
|
+
} else {
|
|
147
|
+
throw DaemonError.systemCall("write fake LED file")
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private struct Arguments {
|
|
154
|
+
let runtimeDirectory: String
|
|
155
|
+
let fakeLEDFile: String?
|
|
156
|
+
|
|
157
|
+
static func parse(_ arguments: [String]) throws -> Arguments {
|
|
158
|
+
var runtimeDirectory: String?
|
|
159
|
+
var fakeLEDFile: String?
|
|
160
|
+
var index = 1
|
|
161
|
+
while index < arguments.count {
|
|
162
|
+
let argument = arguments[index]
|
|
163
|
+
guard index + 1 < arguments.count else { throw DaemonError.usage }
|
|
164
|
+
let value = arguments[index + 1]
|
|
165
|
+
switch argument {
|
|
166
|
+
case "--runtime-dir":
|
|
167
|
+
guard runtimeDirectory == nil else { throw DaemonError.usage }
|
|
168
|
+
runtimeDirectory = value
|
|
169
|
+
case "--fake-led-file":
|
|
170
|
+
guard fakeLEDFile == nil else { throw DaemonError.usage }
|
|
171
|
+
fakeLEDFile = value
|
|
172
|
+
default:
|
|
173
|
+
throw DaemonError.usage
|
|
174
|
+
}
|
|
175
|
+
index += 2
|
|
176
|
+
}
|
|
177
|
+
guard let runtimeDirectory else { throw DaemonError.usage }
|
|
178
|
+
return Arguments(runtimeDirectory: runtimeDirectory, fakeLEDFile: fakeLEDFile)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private func darwinUserTemporaryDirectory() throws -> String {
|
|
183
|
+
let required = confstr(_CS_DARWIN_USER_TEMP_DIR, nil, 0)
|
|
184
|
+
guard required > 1 else { throw DaemonError.systemCall("confstr(DARWIN_USER_TEMP_DIR)") }
|
|
185
|
+
var buffer = [CChar](repeating: 0, count: Int(required))
|
|
186
|
+
guard confstr(_CS_DARWIN_USER_TEMP_DIR, &buffer, buffer.count) == required else {
|
|
187
|
+
throw DaemonError.systemCall("confstr(DARWIN_USER_TEMP_DIR)")
|
|
188
|
+
}
|
|
189
|
+
let root = String(cString: buffer)
|
|
190
|
+
guard root.hasPrefix("/") else { throw DaemonError.invalidRuntimeDirectory }
|
|
191
|
+
return root
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func canonicalDARWINUserTemporaryDirectory() throws -> String {
|
|
195
|
+
let rawRoot = try darwinUserTemporaryDirectory()
|
|
196
|
+
var resolved = [CChar](repeating: 0, count: Int(PATH_MAX))
|
|
197
|
+
guard rawRoot.withCString({ realpath($0, &resolved) != nil }) else {
|
|
198
|
+
throw DaemonError.systemCall("realpath(DARWIN_USER_TEMP_DIR)")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let canonicalRoot = String(cString: resolved)
|
|
202
|
+
guard canonicalRoot.hasPrefix("/") else { throw DaemonError.invalidRuntimeDirectory }
|
|
203
|
+
if canonicalRoot == "/" { return canonicalRoot }
|
|
204
|
+
return canonicalRoot.hasSuffix("/") ? String(canonicalRoot.dropLast()) : canonicalRoot
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private func checkOwnedDirectory(_ path: String) throws {
|
|
208
|
+
var info = stat()
|
|
209
|
+
guard lstat(path, &info) == 0 else { throw DaemonError.systemCall("lstat runtime directory") }
|
|
210
|
+
guard (info.st_mode & S_IFMT) == S_IFDIR else {
|
|
211
|
+
throw DaemonError.unsafeRuntimeDirectory("not a directory or is a symlink")
|
|
212
|
+
}
|
|
213
|
+
guard info.st_uid == getuid() else { throw DaemonError.unsafeRuntimeDirectory("wrong owner") }
|
|
214
|
+
guard (info.st_mode & 0o777) == 0o700 else { throw DaemonError.unsafeRuntimeDirectory("permissions are not 0700") }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private func prepareRuntimeDirectory(passedPath: String) throws -> String {
|
|
218
|
+
// The TypeScript launcher passes the realpath form. Canonicalize the
|
|
219
|
+
// independently obtained confstr root too, because on this system confstr
|
|
220
|
+
// begins with /var while realpath produces /private/var.
|
|
221
|
+
let canonicalRoot = try canonicalDARWINUserTemporaryDirectory()
|
|
222
|
+
let expected = canonicalRoot == "/" ? "/" + runtimeLeaf : canonicalRoot + "/" + runtimeLeaf
|
|
223
|
+
guard passedPath == expected else { throw DaemonError.invalidRuntimeDirectory }
|
|
224
|
+
|
|
225
|
+
if mkdir(expected, 0o700) != 0 && errno != EEXIST {
|
|
226
|
+
throw DaemonError.systemCall("mkdir runtime directory")
|
|
227
|
+
}
|
|
228
|
+
try checkOwnedDirectory(expected)
|
|
229
|
+
return expected
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private func setNonBlockingAndCloseOnExec(_ fd: Int32) throws {
|
|
233
|
+
let flags = fcntl(fd, F_GETFL)
|
|
234
|
+
guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0 else {
|
|
235
|
+
throw DaemonError.systemCall("fcntl(O_NONBLOCK)")
|
|
236
|
+
}
|
|
237
|
+
let descriptorFlags = fcntl(fd, F_GETFD)
|
|
238
|
+
guard descriptorFlags >= 0, fcntl(fd, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else {
|
|
239
|
+
throw DaemonError.systemCall("fcntl(FD_CLOEXEC)")
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private final class Daemon {
|
|
244
|
+
private let runtimeDirectory: String
|
|
245
|
+
private let lockPath: String
|
|
246
|
+
private let socketPath: String
|
|
247
|
+
private let backend: LEDBackend
|
|
248
|
+
private let lockFD: Int32
|
|
249
|
+
|
|
250
|
+
private var listenerFD: Int32 = -1
|
|
251
|
+
private var listenerSource: DispatchSourceRead?
|
|
252
|
+
private var clients: [Int32: DispatchSourceRead] = [:]
|
|
253
|
+
private var blinkTimer: DispatchSourceTimer?
|
|
254
|
+
private var idleTimer: DispatchSourceTimer?
|
|
255
|
+
private var signalSources: [DispatchSourceSignal] = []
|
|
256
|
+
private var ledIsOn = false
|
|
257
|
+
private var blinkGeneration: UInt64 = 0
|
|
258
|
+
private var shuttingDown = false
|
|
259
|
+
private var pendingFDCloses = Set<Int32>()
|
|
260
|
+
|
|
261
|
+
init(runtimeDirectory: String, backend: LEDBackend) throws {
|
|
262
|
+
self.runtimeDirectory = runtimeDirectory
|
|
263
|
+
self.lockPath = runtimeDirectory + "/" + lockLeaf
|
|
264
|
+
self.socketPath = runtimeDirectory + "/" + socketLeaf
|
|
265
|
+
self.backend = backend
|
|
266
|
+
|
|
267
|
+
let fd = open(lockPath, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, S_IRUSR | S_IWUSR)
|
|
268
|
+
guard fd >= 0 else { throw DaemonError.systemCall("open lock") }
|
|
269
|
+
self.lockFD = fd
|
|
270
|
+
|
|
271
|
+
var info = stat()
|
|
272
|
+
guard fstat(fd, &info) == 0 else {
|
|
273
|
+
_ = close(fd)
|
|
274
|
+
throw DaemonError.systemCall("fstat lock")
|
|
275
|
+
}
|
|
276
|
+
guard (info.st_mode & S_IFMT) == S_IFREG, info.st_uid == getuid(), (info.st_mode & 0o777) == 0o600 else {
|
|
277
|
+
_ = close(fd)
|
|
278
|
+
throw DaemonError.unsafeRuntimeDirectory("lock is not a 0600 regular file owned by this user")
|
|
279
|
+
}
|
|
280
|
+
guard flock(fd, LOCK_EX | LOCK_NB) == 0 else {
|
|
281
|
+
_ = close(fd)
|
|
282
|
+
if errno == EWOULDBLOCK { throw DaemonError.lockUnavailable }
|
|
283
|
+
throw DaemonError.systemCall("flock lock")
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
deinit {
|
|
288
|
+
if lockFD >= 0 { _ = close(lockFD) }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func start() throws {
|
|
292
|
+
// A prior catchable shutdown may not have reached Auto; reset before
|
|
293
|
+
// accepting any lease.
|
|
294
|
+
try backend.apply(.auto)
|
|
295
|
+
try removeStaleSocketAsLockOwner()
|
|
296
|
+
try bindListener()
|
|
297
|
+
installSignalHandlers()
|
|
298
|
+
armIdleExit(startSuspended: true)
|
|
299
|
+
|
|
300
|
+
// All sources are constructed suspended. Resume them together on the
|
|
301
|
+
// serial state queue only after every mutable field is initialized.
|
|
302
|
+
queue.sync { [self] in activateInitialSources() }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private func activateInitialSources() {
|
|
306
|
+
listenerSource?.resume()
|
|
307
|
+
for source in signalSources { source.resume() }
|
|
308
|
+
idleTimer?.resume()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private func removeStaleSocketAsLockOwner() throws {
|
|
312
|
+
var info = stat()
|
|
313
|
+
if lstat(socketPath, &info) != 0 {
|
|
314
|
+
if errno == ENOENT { return }
|
|
315
|
+
throw DaemonError.systemCall("lstat socket")
|
|
316
|
+
}
|
|
317
|
+
guard (info.st_mode & S_IFMT) == S_IFSOCK else {
|
|
318
|
+
throw DaemonError.unsafeRuntimeDirectory("socket path is not a socket")
|
|
319
|
+
}
|
|
320
|
+
let permissions = info.st_mode & 0o777
|
|
321
|
+
guard info.st_uid == getuid(), permissions == 0o600 || permissions == 0o700 else {
|
|
322
|
+
throw DaemonError.unsafeRuntimeDirectory("socket has wrong owner or permissions")
|
|
323
|
+
}
|
|
324
|
+
guard unlink(socketPath) == 0 else { throw DaemonError.systemCall("unlink stale socket") }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private func bindListener() throws {
|
|
328
|
+
let capacity = MemoryLayout.size(ofValue: sockaddr_un().sun_path)
|
|
329
|
+
let encodedPath = Array(socketPath.utf8CString)
|
|
330
|
+
guard encodedPath.count <= capacity else { throw DaemonError.socketPathTooLong }
|
|
331
|
+
|
|
332
|
+
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
333
|
+
guard fd >= 0 else { throw DaemonError.systemCall("socket") }
|
|
334
|
+
// chmod after bind leaves a SIGKILL window. Create the socket with its
|
|
335
|
+
// final mode instead, then immediately restore this process's umask.
|
|
336
|
+
let priorUmask = umask(0o177)
|
|
337
|
+
defer { _ = umask(priorUmask) }
|
|
338
|
+
do {
|
|
339
|
+
try setNonBlockingAndCloseOnExec(fd)
|
|
340
|
+
var address = sockaddr_un()
|
|
341
|
+
address.sun_family = sa_family_t(AF_UNIX)
|
|
342
|
+
let offset = MemoryLayout<sockaddr_un>.offset(of: \sockaddr_un.sun_path)!
|
|
343
|
+
withUnsafeMutableBytes(of: &address) { raw in
|
|
344
|
+
encodedPath.withUnsafeBytes { pathBytes in
|
|
345
|
+
raw.baseAddress!.advanced(by: offset).copyMemory(from: pathBytes.baseAddress!, byteCount: encodedPath.count)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
let addressLength = socklen_t(offset + encodedPath.count)
|
|
349
|
+
let bindResult = withUnsafePointer(to: &address) { pointer in
|
|
350
|
+
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
351
|
+
bind(fd, $0, addressLength)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
guard bindResult == 0 else { throw DaemonError.systemCall("bind socket") }
|
|
355
|
+
guard chmod(socketPath, 0o600) == 0 else { throw DaemonError.systemCall("chmod socket") }
|
|
356
|
+
guard listen(fd, SOMAXCONN) == 0 else { throw DaemonError.systemCall("listen") }
|
|
357
|
+
} catch {
|
|
358
|
+
_ = close(fd)
|
|
359
|
+
_ = unlink(socketPath)
|
|
360
|
+
throw error
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
listenerFD = fd
|
|
364
|
+
let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
|
|
365
|
+
source.setEventHandler { [weak self] in self?.drainAcceptedConnections() }
|
|
366
|
+
source.setCancelHandler { [weak self] in
|
|
367
|
+
_ = close(fd)
|
|
368
|
+
self?.fdDidClose(fd)
|
|
369
|
+
}
|
|
370
|
+
listenerSource = source
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private func installSignalHandlers() {
|
|
374
|
+
for signalNumber in [SIGINT, SIGTERM] {
|
|
375
|
+
signal(signalNumber, SIG_IGN)
|
|
376
|
+
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: queue)
|
|
377
|
+
source.setEventHandler { [weak self] in self?.beginShutdown() }
|
|
378
|
+
signalSources.append(source)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private func drainAcceptedConnections() {
|
|
383
|
+
guard !shuttingDown else { return }
|
|
384
|
+
while true {
|
|
385
|
+
let fd = accept(listenerFD, nil, nil)
|
|
386
|
+
if fd >= 0 {
|
|
387
|
+
do {
|
|
388
|
+
try setNonBlockingAndCloseOnExec(fd)
|
|
389
|
+
var peerUID: uid_t = 0
|
|
390
|
+
var peerGID: gid_t = 0
|
|
391
|
+
guard getpeereid(fd, &peerUID, &peerGID) == 0, peerUID == getuid() else {
|
|
392
|
+
_ = close(fd)
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
addClient(fd)
|
|
396
|
+
} catch {
|
|
397
|
+
_ = close(fd)
|
|
398
|
+
}
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
if errno == EINTR { continue }
|
|
402
|
+
if errno == EAGAIN || errno == EWOULDBLOCK { return }
|
|
403
|
+
beginShutdown()
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private func addClient(_ fd: Int32) {
|
|
409
|
+
let hadNoClients = clients.isEmpty
|
|
410
|
+
let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
|
|
411
|
+
source.setEventHandler { [weak self] in self?.drainClient(fd) }
|
|
412
|
+
source.setCancelHandler { [weak self] in
|
|
413
|
+
_ = close(fd)
|
|
414
|
+
self?.fdDidClose(fd)
|
|
415
|
+
}
|
|
416
|
+
clients[fd] = source
|
|
417
|
+
source.resume()
|
|
418
|
+
if hadNoClients { startBlinking() }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private func drainClient(_ fd: Int32) {
|
|
422
|
+
guard clients[fd] != nil else { return }
|
|
423
|
+
var bytes = [UInt8](repeating: 0, count: 1024)
|
|
424
|
+
let byteCount = bytes.count
|
|
425
|
+
while true {
|
|
426
|
+
let result = bytes.withUnsafeMutableBytes { read(fd, $0.baseAddress!, byteCount) }
|
|
427
|
+
if result > 0 { continue }
|
|
428
|
+
if result == 0 {
|
|
429
|
+
removeClient(fd)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if errno == EINTR { continue }
|
|
433
|
+
if errno == EAGAIN || errno == EWOULDBLOCK { return }
|
|
434
|
+
removeClient(fd)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private func removeClient(_ fd: Int32) {
|
|
440
|
+
guard let source = clients.removeValue(forKey: fd) else { return }
|
|
441
|
+
source.cancel()
|
|
442
|
+
if clients.isEmpty { stopBlinkingAndArmIdleExit() }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private func startBlinking() {
|
|
446
|
+
idleTimer?.cancel()
|
|
447
|
+
idleTimer = nil
|
|
448
|
+
cancelBlinkTimer()
|
|
449
|
+
blinkGeneration &+= 1
|
|
450
|
+
let generation = blinkGeneration
|
|
451
|
+
setLED(.on)
|
|
452
|
+
ledIsOn = true
|
|
453
|
+
|
|
454
|
+
let timer = DispatchSource.makeTimerSource(queue: queue)
|
|
455
|
+
timer.setEventHandler { [weak self] in self?.blinkTick(generation: generation) }
|
|
456
|
+
blinkTimer = timer
|
|
457
|
+
timer.schedule(deadline: .now() + .milliseconds(250), repeating: .never)
|
|
458
|
+
timer.resume()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private func cancelBlinkTimer() {
|
|
462
|
+
// Dispatch may already have queued a canceled timer's handler. Changing
|
|
463
|
+
// this token makes that handler inert before a replacement is installed.
|
|
464
|
+
blinkGeneration &+= 1
|
|
465
|
+
blinkTimer?.cancel()
|
|
466
|
+
blinkTimer = nil
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private func blinkTick(generation: UInt64) {
|
|
470
|
+
guard generation == blinkGeneration,
|
|
471
|
+
!shuttingDown,
|
|
472
|
+
!clients.isEmpty,
|
|
473
|
+
let timer = blinkTimer else { return }
|
|
474
|
+
if ledIsOn {
|
|
475
|
+
setLED(.off)
|
|
476
|
+
ledIsOn = false
|
|
477
|
+
timer.schedule(deadline: .now() + .milliseconds(750), repeating: .never)
|
|
478
|
+
} else {
|
|
479
|
+
setLED(.on)
|
|
480
|
+
ledIsOn = true
|
|
481
|
+
timer.schedule(deadline: .now() + .milliseconds(250), repeating: .never)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private func stopBlinkingAndArmIdleExit() {
|
|
486
|
+
cancelBlinkTimer()
|
|
487
|
+
ledIsOn = false
|
|
488
|
+
setLED(.auto)
|
|
489
|
+
armIdleExit()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private func armIdleExit(startSuspended: Bool = false) {
|
|
493
|
+
guard !shuttingDown, clients.isEmpty else { return }
|
|
494
|
+
idleTimer?.cancel()
|
|
495
|
+
let timer = DispatchSource.makeTimerSource(queue: queue)
|
|
496
|
+
timer.setEventHandler { [weak self] in
|
|
497
|
+
guard let self, self.clients.isEmpty else { return }
|
|
498
|
+
self.beginShutdown()
|
|
499
|
+
}
|
|
500
|
+
idleTimer = timer
|
|
501
|
+
timer.schedule(deadline: .now() + .seconds(300), repeating: .never)
|
|
502
|
+
if !startSuspended { timer.resume() }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private func setLED(_ mode: LEDMode) {
|
|
506
|
+
// A failed HID write must not destabilize IPC. A later edge or daemon
|
|
507
|
+
// restart gets another chance to restore the system-managed value.
|
|
508
|
+
try? backend.apply(mode)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private func beginShutdown() {
|
|
512
|
+
guard !shuttingDown else { return }
|
|
513
|
+
shuttingDown = true
|
|
514
|
+
|
|
515
|
+
cancelBlinkTimer()
|
|
516
|
+
idleTimer?.cancel()
|
|
517
|
+
idleTimer = nil
|
|
518
|
+
ledIsOn = false
|
|
519
|
+
setLED(.auto)
|
|
520
|
+
|
|
521
|
+
for source in signalSources { source.cancel() }
|
|
522
|
+
signalSources.removeAll()
|
|
523
|
+
|
|
524
|
+
if let source = listenerSource {
|
|
525
|
+
pendingFDCloses.insert(listenerFD)
|
|
526
|
+
source.cancel()
|
|
527
|
+
listenerSource = nil
|
|
528
|
+
}
|
|
529
|
+
for (fd, source) in clients {
|
|
530
|
+
pendingFDCloses.insert(fd)
|
|
531
|
+
source.cancel()
|
|
532
|
+
}
|
|
533
|
+
clients.removeAll()
|
|
534
|
+
finishShutdownIfReady()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private func fdDidClose(_ fd: Int32) {
|
|
538
|
+
guard pendingFDCloses.remove(fd) != nil else { return }
|
|
539
|
+
finishShutdownIfReady()
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private func finishShutdownIfReady() {
|
|
543
|
+
guard shuttingDown, pendingFDCloses.isEmpty else { return }
|
|
544
|
+
// This process owns the flock, so it alone removes the active pathname.
|
|
545
|
+
// Re-validate before unlinking rather than deleting an unexpected file.
|
|
546
|
+
var info = stat()
|
|
547
|
+
if lstat(socketPath, &info) == 0,
|
|
548
|
+
(info.st_mode & S_IFMT) == S_IFSOCK,
|
|
549
|
+
info.st_uid == getuid(),
|
|
550
|
+
(info.st_mode & 0o777) == 0o600 {
|
|
551
|
+
_ = unlink(socketPath)
|
|
552
|
+
}
|
|
553
|
+
_ = flock(lockFD, LOCK_UN)
|
|
554
|
+
_ = close(lockFD)
|
|
555
|
+
exit(0)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private func run() throws {
|
|
560
|
+
guard ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 else {
|
|
561
|
+
throw DaemonError.unsupportedOS
|
|
562
|
+
}
|
|
563
|
+
_ = umask(0o077)
|
|
564
|
+
let arguments = try Arguments.parse(CommandLine.arguments)
|
|
565
|
+
let runtimeDirectory = try prepareRuntimeDirectory(passedPath: arguments.runtimeDirectory)
|
|
566
|
+
let backend: LEDBackend
|
|
567
|
+
if let fakeLEDFile = arguments.fakeLEDFile {
|
|
568
|
+
backend = FakeLEDBackend(path: fakeLEDFile)
|
|
569
|
+
} else {
|
|
570
|
+
backend = try HIDLEDBackend()
|
|
571
|
+
}
|
|
572
|
+
let daemon = try Daemon(runtimeDirectory: runtimeDirectory, backend: backend)
|
|
573
|
+
try daemon.start()
|
|
574
|
+
dispatchMain()
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
do {
|
|
578
|
+
try run()
|
|
579
|
+
} catch DaemonError.lockUnavailable {
|
|
580
|
+
// Spawn races are expected. The process holding the lock is authoritative.
|
|
581
|
+
exit(0)
|
|
582
|
+
} catch {
|
|
583
|
+
fputs("CapsBlinkDaemon: \(error)\n", stderr)
|
|
584
|
+
exit(1)
|
|
585
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CapsBlinkClient, defaultConnect } from "../src/client.ts";
|
|
3
|
+
import { checkPlatformSupport } from "../src/platform.ts";
|
|
4
|
+
import { resolveRuntimePaths } from "../src/runtime-paths.ts";
|
|
5
|
+
import {
|
|
6
|
+
packageRootFrom,
|
|
7
|
+
resolveDaemonScriptPath,
|
|
8
|
+
spawnDaemon,
|
|
9
|
+
} from "../src/spawn.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Blink the built-in Caps Lock LED while this Pi session has an active agent.
|
|
13
|
+
* Machine-wide coordination is via a shared Swift daemon lease socket.
|
|
14
|
+
*/
|
|
15
|
+
export default function capsBlinkExtension(pi: ExtensionAPI): void {
|
|
16
|
+
const support = checkPlatformSupport();
|
|
17
|
+
let client: CapsBlinkClient | null = null;
|
|
18
|
+
let unsupportedWarned = false;
|
|
19
|
+
|
|
20
|
+
const warn = (message: string): void => {
|
|
21
|
+
console.warn(message);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
pi.on("session_start", (_event, ctx) => {
|
|
25
|
+
client?.dispose();
|
|
26
|
+
client = null;
|
|
27
|
+
|
|
28
|
+
if (!support.ok) {
|
|
29
|
+
if (!unsupportedWarned) {
|
|
30
|
+
unsupportedWarned = true;
|
|
31
|
+
warn(`pi-caps-blink: disabled — ${support.reason}`);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const paths = resolveRuntimePaths();
|
|
38
|
+
const packageRoot = packageRootFrom(import.meta.url);
|
|
39
|
+
client = new CapsBlinkClient({
|
|
40
|
+
socketPath: paths.socketPath,
|
|
41
|
+
runtimeDir: paths.runtimeDir,
|
|
42
|
+
scriptPath: resolveDaemonScriptPath(packageRoot),
|
|
43
|
+
connect: defaultConnect,
|
|
44
|
+
spawnDaemon,
|
|
45
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
46
|
+
clearTimeout: (id) => clearTimeout(id as NodeJS.Timeout),
|
|
47
|
+
random: Math.random,
|
|
48
|
+
now: () => Date.now(),
|
|
49
|
+
warn,
|
|
50
|
+
});
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (!unsupportedWarned) {
|
|
53
|
+
unsupportedWarned = true;
|
|
54
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
55
|
+
warn(`pi-caps-blink: disabled — ${detail}`);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!ctx.isIdle()) {
|
|
61
|
+
client.setDesiredBusy(true);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.on("agent_start", () => {
|
|
66
|
+
client?.setDesiredBusy(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
70
|
+
if (ctx.isIdle()) {
|
|
71
|
+
client?.setDesiredBusy(false);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
pi.on("session_shutdown", () => {
|
|
76
|
+
client?.setDesiredBusy(false);
|
|
77
|
+
client?.dispose();
|
|
78
|
+
client = null;
|
|
79
|
+
});
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-caps-blink",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Blink the MacBook Caps Lock LED while any Pi agent is actively running.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Boston Cartwright",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/bstncartwright/pi-caps-blink.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/bstncartwright/pi-caps-blink/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/bstncartwright/pi-caps-blink#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"pi",
|
|
20
|
+
"macos",
|
|
21
|
+
"caps-lock"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"daemon",
|
|
25
|
+
"extensions",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./extensions/index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"typecheck": "tsc --noEmit && xcrun swiftc -typecheck daemon/CapsBlinkDaemon.swift",
|
|
38
|
+
"test": "node --test --experimental-strip-types test/*.test.ts",
|
|
39
|
+
"check": "npm run typecheck && npm test",
|
|
40
|
+
"prepublishOnly": "npm run check"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
47
|
+
"@types/node": "^24.10.0",
|
|
48
|
+
"typescript": "^5.9.3"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22.19"
|
|
52
|
+
},
|
|
53
|
+
"os": ["darwin"]
|
|
54
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import {
|
|
3
|
+
INITIAL_RETRY_MS,
|
|
4
|
+
MAX_RETRY_MS,
|
|
5
|
+
SPAWN_COOLDOWN_MS,
|
|
6
|
+
} from "./constants.ts";
|
|
7
|
+
|
|
8
|
+
/** Minimal lease handle: an open connection is the acquire. */
|
|
9
|
+
export type LeaseSocket = {
|
|
10
|
+
destroy(): void;
|
|
11
|
+
on(event: "close", listener: () => void): void;
|
|
12
|
+
on(event: "error", listener: (err: Error) => void): void;
|
|
13
|
+
off?(event: "close", listener: () => void): void;
|
|
14
|
+
off?(event: "error", listener: (err: Error) => void): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SpawnDaemonArgs = {
|
|
18
|
+
runtimeDir: string;
|
|
19
|
+
scriptPath: string;
|
|
20
|
+
onError?: (err: Error) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type CapsBlinkClientDeps = {
|
|
24
|
+
socketPath: string;
|
|
25
|
+
runtimeDir: string;
|
|
26
|
+
scriptPath: string;
|
|
27
|
+
connect: (socketPath: string) => Promise<LeaseSocket>;
|
|
28
|
+
spawnDaemon: (args: SpawnDaemonArgs) => void;
|
|
29
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
30
|
+
clearTimeout: (id: unknown) => void;
|
|
31
|
+
random: () => number;
|
|
32
|
+
now: () => number;
|
|
33
|
+
warn: (message: string) => void;
|
|
34
|
+
initialRetryMs?: number;
|
|
35
|
+
maxRetryMs?: number;
|
|
36
|
+
spawnCooldownMs?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default Unix-domain connect. Connection success is the lease; no protocol.
|
|
41
|
+
*/
|
|
42
|
+
export function defaultConnect(socketPath: string): Promise<LeaseSocket> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const socket = net.createConnection({ path: socketPath });
|
|
45
|
+
const onError = (err: Error) => {
|
|
46
|
+
socket.off("connect", onConnect);
|
|
47
|
+
socket.destroy();
|
|
48
|
+
reject(err);
|
|
49
|
+
};
|
|
50
|
+
const onConnect = () => {
|
|
51
|
+
socket.off("error", onError);
|
|
52
|
+
// Prevent unhandled error events after lease is held; close drives retry.
|
|
53
|
+
socket.on("error", () => {});
|
|
54
|
+
resolve(socket);
|
|
55
|
+
};
|
|
56
|
+
socket.once("error", onError);
|
|
57
|
+
socket.once("connect", onConnect);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Explicit desiredBusy / socket / retry / generation state machine.
|
|
63
|
+
* Handlers should call {@link CapsBlinkClient.setDesiredBusy} without awaiting.
|
|
64
|
+
*/
|
|
65
|
+
export class CapsBlinkClient {
|
|
66
|
+
private desiredBusy = false;
|
|
67
|
+
private socket: LeaseSocket | null = null;
|
|
68
|
+
/** Generation of the in-flight connect, or null if none for the current lease cycle. */
|
|
69
|
+
private connectingGeneration: number | null = null;
|
|
70
|
+
private generation = 0;
|
|
71
|
+
private retryTimer: unknown = null;
|
|
72
|
+
private retryAttempt = 0;
|
|
73
|
+
private lastSpawnAt = Number.NEGATIVE_INFINITY;
|
|
74
|
+
private busySince = 0;
|
|
75
|
+
private warned = false;
|
|
76
|
+
|
|
77
|
+
private readonly socketPath: string;
|
|
78
|
+
private readonly runtimeDir: string;
|
|
79
|
+
private readonly scriptPath: string;
|
|
80
|
+
private readonly connectFn: CapsBlinkClientDeps["connect"];
|
|
81
|
+
private readonly spawnDaemonFn: CapsBlinkClientDeps["spawnDaemon"];
|
|
82
|
+
private readonly setTimeoutFn: CapsBlinkClientDeps["setTimeout"];
|
|
83
|
+
private readonly clearTimeoutFn: CapsBlinkClientDeps["clearTimeout"];
|
|
84
|
+
private readonly randomFn: CapsBlinkClientDeps["random"];
|
|
85
|
+
private readonly nowFn: CapsBlinkClientDeps["now"];
|
|
86
|
+
private readonly warnFn: CapsBlinkClientDeps["warn"];
|
|
87
|
+
private readonly initialRetryMs: number;
|
|
88
|
+
private readonly maxRetryMs: number;
|
|
89
|
+
private readonly spawnCooldownMs: number;
|
|
90
|
+
|
|
91
|
+
constructor(deps: CapsBlinkClientDeps) {
|
|
92
|
+
this.socketPath = deps.socketPath;
|
|
93
|
+
this.runtimeDir = deps.runtimeDir;
|
|
94
|
+
this.scriptPath = deps.scriptPath;
|
|
95
|
+
this.connectFn = deps.connect;
|
|
96
|
+
this.spawnDaemonFn = deps.spawnDaemon;
|
|
97
|
+
this.setTimeoutFn = deps.setTimeout;
|
|
98
|
+
this.clearTimeoutFn = deps.clearTimeout;
|
|
99
|
+
this.randomFn = deps.random;
|
|
100
|
+
this.nowFn = deps.now;
|
|
101
|
+
this.warnFn = deps.warn;
|
|
102
|
+
this.initialRetryMs = deps.initialRetryMs ?? INITIAL_RETRY_MS;
|
|
103
|
+
this.maxRetryMs = deps.maxRetryMs ?? MAX_RETRY_MS;
|
|
104
|
+
this.spawnCooldownMs = deps.spawnCooldownMs ?? SPAWN_COOLDOWN_MS;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Test/observation helpers. */
|
|
108
|
+
getDesiredBusy(): boolean {
|
|
109
|
+
return this.desiredBusy;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getGeneration(): number {
|
|
113
|
+
return this.generation;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
hasSocket(): boolean {
|
|
117
|
+
return this.socket !== null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
isConnecting(): boolean {
|
|
121
|
+
return this.connectingGeneration !== null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
hasRetryTimer(): boolean {
|
|
125
|
+
return this.retryTimer !== null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Synchronously update desired busy state and fire-and-forget reconcile.
|
|
130
|
+
* Never awaits daemon startup.
|
|
131
|
+
*/
|
|
132
|
+
setDesiredBusy(desired: boolean): void {
|
|
133
|
+
if (desired) {
|
|
134
|
+
if (
|
|
135
|
+
this.desiredBusy &&
|
|
136
|
+
(this.socket ||
|
|
137
|
+
this.connectingGeneration === this.generation ||
|
|
138
|
+
this.retryTimer)
|
|
139
|
+
) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!this.desiredBusy) {
|
|
143
|
+
this.busySince = this.nowFn();
|
|
144
|
+
this.warned = false;
|
|
145
|
+
}
|
|
146
|
+
this.desiredBusy = true;
|
|
147
|
+
this.reconcile();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
!this.desiredBusy &&
|
|
153
|
+
!this.socket &&
|
|
154
|
+
this.connectingGeneration === null &&
|
|
155
|
+
!this.retryTimer
|
|
156
|
+
) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.desiredBusy = false;
|
|
161
|
+
this.generation += 1;
|
|
162
|
+
this.clearRetryTimer();
|
|
163
|
+
this.retryAttempt = 0;
|
|
164
|
+
this.destroySocket();
|
|
165
|
+
// In-flight connect completions are generation-gated and will destroy.
|
|
166
|
+
// connectingGeneration stays until that attempt settles (may be stale).
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Idempotent shutdown helper for session_shutdown. */
|
|
170
|
+
dispose(): void {
|
|
171
|
+
this.setDesiredBusy(false);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private reconcile(): void {
|
|
175
|
+
if (!this.desiredBusy) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (this.socket) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Only block on a connect for *this* generation; a stale pending connect
|
|
182
|
+
// must not prevent re-acquire after busy→false→true.
|
|
183
|
+
if (this.connectingGeneration === this.generation) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.clearRetryTimer();
|
|
187
|
+
this.beginConnect(this.generation);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private beginConnect(generation: number): void {
|
|
191
|
+
if (!this.desiredBusy || generation !== this.generation) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (this.socket || this.connectingGeneration === generation) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.connectingGeneration = generation;
|
|
199
|
+
void this.connectFn(this.socketPath).then(
|
|
200
|
+
(socket) => this.onConnectSuccess(generation, socket),
|
|
201
|
+
(err: unknown) => this.onConnectFailure(generation, err),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private clearConnectingIf(generation: number): void {
|
|
206
|
+
if (this.connectingGeneration === generation) {
|
|
207
|
+
this.connectingGeneration = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private onConnectSuccess(generation: number, socket: LeaseSocket): void {
|
|
212
|
+
this.clearConnectingIf(generation);
|
|
213
|
+
|
|
214
|
+
if (!this.desiredBusy || generation !== this.generation) {
|
|
215
|
+
socket.destroy();
|
|
216
|
+
// Stale completion after re-acquire: ensure a current connect is running.
|
|
217
|
+
this.reconcile();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.socket = socket;
|
|
222
|
+
this.retryAttempt = 0;
|
|
223
|
+
this.clearRetryTimer();
|
|
224
|
+
|
|
225
|
+
const onClose = () => {
|
|
226
|
+
socket.off?.("close", onClose);
|
|
227
|
+
this.onSocketClose(generation, socket);
|
|
228
|
+
};
|
|
229
|
+
socket.on("close", onClose);
|
|
230
|
+
// Swallow post-lease errors; close drives reconnection.
|
|
231
|
+
socket.on("error", () => {});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private onConnectFailure(generation: number, err: unknown): void {
|
|
235
|
+
this.clearConnectingIf(generation);
|
|
236
|
+
|
|
237
|
+
if (!this.desiredBusy || generation !== this.generation) {
|
|
238
|
+
this.reconcile();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A missing socket is expected on first use: the daemon is on-demand.
|
|
243
|
+
// Warn only if startup has remained unavailable for a full spawn cooldown.
|
|
244
|
+
if (this.nowFn() - this.busySince >= this.spawnCooldownMs) {
|
|
245
|
+
this.warnOnce(
|
|
246
|
+
`pi-caps-blink: unavailable (${formatError(err)}); still retrying`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
this.maybeSpawnDaemon();
|
|
250
|
+
this.scheduleRetry(generation);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private onSocketClose(generation: number, socket: LeaseSocket): void {
|
|
254
|
+
if (this.socket === socket) {
|
|
255
|
+
this.socket = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Retry daemon death only from close, only while same generation remains desired.
|
|
259
|
+
if (!this.desiredBusy || generation !== this.generation) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.warnOnce("pi-caps-blink: lease socket closed; reconnecting");
|
|
264
|
+
this.maybeSpawnDaemon();
|
|
265
|
+
this.scheduleRetry(generation);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private scheduleRetry(generation: number): void {
|
|
269
|
+
if (!this.desiredBusy || generation !== this.generation) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
this.retryTimer !== null ||
|
|
274
|
+
this.connectingGeneration === generation ||
|
|
275
|
+
this.socket
|
|
276
|
+
) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const base = Math.min(
|
|
281
|
+
this.maxRetryMs,
|
|
282
|
+
this.initialRetryMs * 2 ** this.retryAttempt,
|
|
283
|
+
);
|
|
284
|
+
this.retryAttempt += 1;
|
|
285
|
+
const jitter = this.randomFn() * base;
|
|
286
|
+
const delay = Math.min(this.maxRetryMs, base * 0.5 + jitter * 0.5);
|
|
287
|
+
|
|
288
|
+
this.retryTimer = this.setTimeoutFn(() => {
|
|
289
|
+
this.retryTimer = null;
|
|
290
|
+
if (!this.desiredBusy || generation !== this.generation) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.beginConnect(generation);
|
|
294
|
+
}, delay);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private maybeSpawnDaemon(): void {
|
|
298
|
+
const now = this.nowFn();
|
|
299
|
+
if (now - this.lastSpawnAt < this.spawnCooldownMs) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.lastSpawnAt = now;
|
|
303
|
+
try {
|
|
304
|
+
this.spawnDaemonFn({
|
|
305
|
+
runtimeDir: this.runtimeDir,
|
|
306
|
+
scriptPath: this.scriptPath,
|
|
307
|
+
onError: (err) => {
|
|
308
|
+
this.warnOnce(
|
|
309
|
+
`pi-caps-blink: daemon spawn error (${formatError(err)})`,
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
this.warnOnce(`pi-caps-blink: failed to spawn daemon (${formatError(err)})`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private destroySocket(): void {
|
|
319
|
+
const socket = this.socket;
|
|
320
|
+
this.socket = null;
|
|
321
|
+
if (socket) {
|
|
322
|
+
try {
|
|
323
|
+
socket.destroy();
|
|
324
|
+
} catch {
|
|
325
|
+
// ignore
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private clearRetryTimer(): void {
|
|
331
|
+
if (this.retryTimer !== null) {
|
|
332
|
+
this.clearTimeoutFn(this.retryTimer);
|
|
333
|
+
this.retryTimer = null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private warnOnce(message: string): void {
|
|
338
|
+
if (this.warned) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
this.warned = true;
|
|
342
|
+
this.warnFn(message);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function formatError(err: unknown): string {
|
|
347
|
+
if (err instanceof Error) {
|
|
348
|
+
return err.message;
|
|
349
|
+
}
|
|
350
|
+
return String(err);
|
|
351
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Fixed short runtime subdirectory under the Darwin per-user temp root. */
|
|
2
|
+
export const RUNTIME_DIR_NAME = "pi-caps-blink-v1";
|
|
3
|
+
|
|
4
|
+
/** Singleton lock file name inside the runtime directory (daemon-owned). */
|
|
5
|
+
export const LOCK_NAME = "l";
|
|
6
|
+
|
|
7
|
+
/** Unix-domain socket name inside the runtime directory. */
|
|
8
|
+
export const SOCKET_NAME = "s";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* macOS `sockaddr_un.sun_path` capacity. Socket path UTF-8 byte length must be
|
|
12
|
+
* strictly less than this.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_SOCKET_PATH_BYTES = 104;
|
|
15
|
+
|
|
16
|
+
/** Minimum Darwin kernel major corresponding to macOS 26. */
|
|
17
|
+
export const MIN_DARWIN_MAJOR = 25;
|
|
18
|
+
|
|
19
|
+
/** Packaged Swift coordinator script, relative to the package root. */
|
|
20
|
+
export const DAEMON_SCRIPT_RELATIVE = "daemon/CapsBlinkDaemon.swift";
|
|
21
|
+
|
|
22
|
+
export const INITIAL_RETRY_MS = 50;
|
|
23
|
+
export const MAX_RETRY_MS = 2_000;
|
|
24
|
+
export const SPAWN_COOLDOWN_MS = 10_000;
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { MIN_DARWIN_MAJOR } from "./constants.ts";
|
|
3
|
+
|
|
4
|
+
export type PlatformSupport =
|
|
5
|
+
| { ok: true }
|
|
6
|
+
| { ok: false; reason: string };
|
|
7
|
+
|
|
8
|
+
export type PlatformInfo = {
|
|
9
|
+
platform: NodeJS.Platform;
|
|
10
|
+
/** Darwin kernel release string, e.g. `25.5.0`. */
|
|
11
|
+
release: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Caps Lock LED control is supported on macOS 26+ only (Darwin major >= 25).
|
|
16
|
+
*/
|
|
17
|
+
export function checkPlatformSupport(
|
|
18
|
+
info: PlatformInfo = {
|
|
19
|
+
platform: process.platform,
|
|
20
|
+
release: os.release(),
|
|
21
|
+
},
|
|
22
|
+
): PlatformSupport {
|
|
23
|
+
if (info.platform !== "darwin") {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: `pi-caps-blink is macOS-only (got ${info.platform})`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const major = Number.parseInt(info.release.split(".")[0] ?? "", 10);
|
|
31
|
+
if (!Number.isFinite(major) || major < MIN_DARWIN_MAJOR) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
reason: `pi-caps-blink requires macOS 26+ (Darwin ${MIN_DARWIN_MAJOR}+; got ${info.release})`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { ok: true };
|
|
39
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
MAX_SOCKET_PATH_BYTES,
|
|
6
|
+
RUNTIME_DIR_NAME,
|
|
7
|
+
SOCKET_NAME,
|
|
8
|
+
} from "./constants.ts";
|
|
9
|
+
|
|
10
|
+
export type RuntimePaths = {
|
|
11
|
+
/** Canonical Darwin per-user temp + `pi-caps-blink-v1`. */
|
|
12
|
+
runtimeDir: string;
|
|
13
|
+
/** Socket path: `{runtimeDir}/s`. */
|
|
14
|
+
socketPath: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class RuntimePathError extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "RuntimePathError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ResolveRuntimePathsOptions = {
|
|
25
|
+
/** Override `getconf DARWIN_USER_TEMP_DIR` result (tests). */
|
|
26
|
+
tempRoot?: string;
|
|
27
|
+
/** Override realpath (tests). */
|
|
28
|
+
realpath?: (input: string) => string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Canonical runtime directory: realpath(`/usr/bin/getconf DARWIN_USER_TEMP_DIR`)
|
|
33
|
+
* + fixed `pi-caps-blink-v1`, with socket path UTF-8 length < 104.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveRuntimePaths(
|
|
36
|
+
options: ResolveRuntimePathsOptions = {},
|
|
37
|
+
): RuntimePaths {
|
|
38
|
+
const rawTemp =
|
|
39
|
+
options.tempRoot ??
|
|
40
|
+
execFileSync("/usr/bin/getconf", ["DARWIN_USER_TEMP_DIR"], {
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
}).trim();
|
|
43
|
+
|
|
44
|
+
if (!rawTemp) {
|
|
45
|
+
throw new RuntimePathError("getconf DARWIN_USER_TEMP_DIR returned empty");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const realpath = options.realpath ?? ((p: string) => fs.realpathSync(p));
|
|
49
|
+
const tempRoot = realpath(rawTemp);
|
|
50
|
+
const runtimeDir = path.join(tempRoot, RUNTIME_DIR_NAME);
|
|
51
|
+
const socketPath = path.join(runtimeDir, SOCKET_NAME);
|
|
52
|
+
const bytes = Buffer.byteLength(socketPath, "utf8");
|
|
53
|
+
|
|
54
|
+
if (bytes >= MAX_SOCKET_PATH_BYTES) {
|
|
55
|
+
throw new RuntimePathError(
|
|
56
|
+
`socket path is ${bytes} UTF-8 bytes; must be < ${MAX_SOCKET_PATH_BYTES}: ${socketPath}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { runtimeDir, socketPath };
|
|
61
|
+
}
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { DAEMON_SCRIPT_RELATIVE } from "./constants.ts";
|
|
5
|
+
|
|
6
|
+
export function packageRootFrom(importMetaUrl: string): string {
|
|
7
|
+
return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveDaemonScriptPath(packageRoot: string): string {
|
|
11
|
+
return path.join(packageRoot, DAEMON_SCRIPT_RELATIVE);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SpawnDaemonOptions = {
|
|
15
|
+
runtimeDir: string;
|
|
16
|
+
scriptPath: string;
|
|
17
|
+
/** Injectable spawn for tests. Defaults to detached `xcrun swift`. */
|
|
18
|
+
spawnImpl?: typeof spawn;
|
|
19
|
+
/**
|
|
20
|
+
* Called if the child emits `error` (e.g. xcrun missing). Attaching this
|
|
21
|
+
* prevents an async spawn failure from becoming an unhandled error event.
|
|
22
|
+
*/
|
|
23
|
+
onError?: (err: Error) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Start the packaged Swift coordinator detached. Does not wait for readiness;
|
|
28
|
+
* callers retry connect with backoff.
|
|
29
|
+
*/
|
|
30
|
+
export function spawnDaemon(options: SpawnDaemonOptions): ChildProcess {
|
|
31
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
32
|
+
const child = spawnImpl(
|
|
33
|
+
"/usr/bin/xcrun",
|
|
34
|
+
["swift", options.scriptPath, "--runtime-dir", options.runtimeDir],
|
|
35
|
+
{
|
|
36
|
+
detached: true,
|
|
37
|
+
stdio: "ignore",
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
child.on("error", (err) => {
|
|
42
|
+
options.onError?.(err);
|
|
43
|
+
});
|
|
44
|
+
child.unref();
|
|
45
|
+
return child;
|
|
46
|
+
}
|