codex-ringside 0.0.0-stage → 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.
@@ -0,0 +1,212 @@
1
+ // Small AppKit helper for the startup handoff. Every command prints one JSON line.
2
+ //
3
+ // bridge snapshot <app>
4
+ // bridge watch <app>
5
+ // bridge quit <app> <pid> <key> <stamp>
6
+ // bridge launch <app> <port>
7
+ // bridge show <app> <pid> <key> <stamp> <frontmostPid>
8
+ // bridge wallpaper <app>
9
+ //
10
+ // It only reads input event counters and timestamps, never what was typed, and it only
11
+ // ever calls terminate(): a refused quit stays refused.
12
+ import AppKit
13
+ import CoreGraphics
14
+ import Foundation
15
+
16
+ let inputEvents: [CGEventType] = [.keyDown, .leftMouseDown, .rightMouseDown, .otherMouseDown, .scrollWheel]
17
+
18
+ func emit(_ value: Any) {
19
+ guard let data = try? JSONSerialization.data(withJSONObject: value),
20
+ let line = String(data: data, encoding: .utf8) else { exit(1) }
21
+ print(line)
22
+ fflush(stdout)
23
+ }
24
+
25
+ func fail(_ message: String) -> Never {
26
+ FileHandle.standardError.write(Data("\(message)\n".utf8))
27
+ exit(2)
28
+ }
29
+
30
+ func inputStamp() -> Int {
31
+ inputEvents.reduce(0) { $0 + Int(CGEventSource.counterForEventType(.combinedSessionState, eventType: $1)) }
32
+ }
33
+
34
+ // Mouse movement is left out on purpose: moving away from the Dock is not "using" the app.
35
+ func inputIdleMs() -> Double {
36
+ let seconds = inputEvents
37
+ .map { CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: $0) }
38
+ .min() ?? .infinity
39
+ return seconds.isFinite ? seconds * 1000 : 1e12
40
+ }
41
+
42
+ func canonical(_ path: String) -> String {
43
+ URL(fileURLWithPath: path).resolvingSymlinksInPath().path
44
+ }
45
+
46
+ func runningApps(_ appPath: String) -> [NSRunningApplication] {
47
+ NSWorkspace.shared.runningApplications.filter {
48
+ guard let url = $0.bundleURL else { return false }
49
+ return canonical(url.path) == appPath && !$0.isTerminated
50
+ }
51
+ }
52
+
53
+ func launchMs(_ app: NSRunningApplication) -> Int {
54
+ Int((app.launchDate?.timeIntervalSince1970 ?? 0) * 1000)
55
+ }
56
+
57
+ func processKey(_ app: NSRunningApplication) -> String {
58
+ "\(app.processIdentifier):\(launchMs(app))"
59
+ }
60
+
61
+ func commandLine(_ pid: pid_t) -> String? {
62
+ let process = Process()
63
+ let pipe = Pipe()
64
+ process.executableURL = URL(fileURLWithPath: "/bin/ps")
65
+ process.arguments = ["-ww", "-o", "command=", "-p", String(pid)]
66
+ process.standardOutput = pipe
67
+ process.standardError = FileHandle.nullDevice
68
+ do { try process.run() } catch { return nil }
69
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
70
+ process.waitUntilExit()
71
+ guard process.terminationStatus == 0, let text = String(data: data, encoding: .utf8) else { return nil }
72
+ return text.isEmpty ? nil : text
73
+ }
74
+
75
+ func debugPort(_ command: String) -> Int? {
76
+ guard let range = command.range(of: #"--remote-debugging-port=\d+"#, options: .regularExpression) else { return nil }
77
+ return Int(command[range].split(separator: "=").last ?? "")
78
+ }
79
+
80
+ func describe(_ app: NSRunningApplication) -> [String: Any] {
81
+ let command = commandLine(app.processIdentifier)
82
+ return [
83
+ "pid": Int(app.processIdentifier),
84
+ "key": processKey(app),
85
+ "launchedAt": launchMs(app),
86
+ "finishedLaunching": app.isFinishedLaunching,
87
+ "argumentsKnown": command != nil,
88
+ "debugPort": command.flatMap(debugPort) as Any? ?? NSNull()
89
+ ]
90
+ }
91
+
92
+ func snapshot(_ appPath: String) -> [String: Any] {
93
+ [
94
+ "apps": runningApps(appPath).map(describe),
95
+ "frontmostPid": NSWorkspace.shared.frontmostApplication.map { Int($0.processIdentifier) } as Any? ?? NSNull(),
96
+ "inputIdleMs": inputIdleMs(),
97
+ "inputStamp": inputStamp()
98
+ ]
99
+ }
100
+
101
+ func find(_ appPath: String, pid: String, key: String) -> NSRunningApplication? {
102
+ guard let pid = pid_t(pid) else { return nil }
103
+ return runningApps(appPath).first { $0.processIdentifier == pid && processKey($0) == key }
104
+ }
105
+
106
+ // openApplication is asynchronous and needs the main run loop to deliver its completion.
107
+ func open(_ appPath: String, _ configuration: NSWorkspace.OpenConfiguration, timeout: TimeInterval) -> NSRunningApplication? {
108
+ var opened: NSRunningApplication?
109
+ var done = false
110
+ NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: appPath), configuration: configuration) { app, _ in
111
+ DispatchQueue.main.async {
112
+ opened = app
113
+ done = true
114
+ }
115
+ }
116
+ let deadline = Date(timeIntervalSinceNow: timeout)
117
+ while !done && Date() < deadline {
118
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05))
119
+ }
120
+ return opened
121
+ }
122
+
123
+ func watch(_ appPath: String) -> Never {
124
+ let center = NSWorkspace.shared.notificationCenter
125
+ let names: [NSNotification.Name] = [
126
+ NSWorkspace.didLaunchApplicationNotification,
127
+ NSWorkspace.didTerminateApplicationNotification,
128
+ NSWorkspace.didActivateApplicationNotification
129
+ ]
130
+ let tokens = names.map { name in
131
+ center.addObserver(forName: name, object: nil, queue: .main) { note in
132
+ guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
133
+ let url = app.bundleURL, canonical(url.path) == appPath else { return }
134
+ emit(["event": name.rawValue, "pid": Int(app.processIdentifier)])
135
+ }
136
+ }
137
+ emit(["event": "watching"])
138
+ withExtendedLifetime(tokens) { RunLoop.main.run() }
139
+ exit(0)
140
+ }
141
+
142
+ // Cocoa measures from the bottom of the primary screen, the renderer from its top.
143
+ func wallpaper() -> [String: Any] {
144
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
145
+ return ["screens": NSScreen.screens.map { screen -> [String: Any] in
146
+ let frame = screen.frame
147
+ return [
148
+ "x": frame.minX,
149
+ "y": primaryHeight - frame.maxY,
150
+ "width": frame.width,
151
+ "height": frame.height,
152
+ "image": NSWorkspace.shared.desktopImageURL(for: screen)?.path as Any? ?? NSNull()
153
+ ]
154
+ }]
155
+ }
156
+
157
+ let args = CommandLine.arguments
158
+ guard args.count >= 3 else { fail("usage: bridge <command> <app> [...]") }
159
+ let command = args[1]
160
+ let appPath = canonical(args[2])
161
+
162
+ switch command {
163
+ case "snapshot":
164
+ emit(snapshot(appPath))
165
+
166
+ case "watch":
167
+ watch(appPath)
168
+
169
+ case "quit":
170
+ guard args.count == 6 else { fail("usage: bridge quit <app> <pid> <key> <stamp>") }
171
+ // The user started working between the decision and now: leave the app alone.
172
+ guard String(inputStamp()) == args[5], let app = find(appPath, pid: args[3], key: args[4]) else {
173
+ emit(["accepted": false])
174
+ break
175
+ }
176
+ emit(["accepted": app.terminate()])
177
+
178
+ case "launch":
179
+ guard args.count == 4 else { fail("usage: bridge launch <app> <port>") }
180
+ guard runningApps(appPath).isEmpty else {
181
+ emit(["launched": false])
182
+ break
183
+ }
184
+ let configuration = NSWorkspace.OpenConfiguration()
185
+ configuration.arguments = ["--remote-debugging-port=\(args[3])", "--remote-debugging-address=127.0.0.1"]
186
+ configuration.activates = false
187
+ configuration.addsToRecentItems = false
188
+ configuration.createsNewApplicationInstance = false
189
+ guard let app = open(appPath, configuration, timeout: 20) else {
190
+ emit(["launched": false])
191
+ break
192
+ }
193
+ emit(["launched": true, "pid": Int(app.processIdentifier), "key": processKey(app)])
194
+
195
+ case "show":
196
+ guard args.count == 7 else { fail("usage: bridge show <app> <pid> <key> <stamp> <frontmostPid>") }
197
+ let frontmost = NSWorkspace.shared.frontmostApplication.map { String($0.processIdentifier) } ?? ""
198
+ guard String(inputStamp()) == args[5], frontmost == args[6], find(appPath, pid: args[3], key: args[4]) != nil else {
199
+ emit(["shown": false])
200
+ break
201
+ }
202
+ let configuration = NSWorkspace.OpenConfiguration()
203
+ configuration.activates = true
204
+ configuration.addsToRecentItems = false
205
+ emit(["shown": open(appPath, configuration, timeout: 10) != nil])
206
+
207
+ case "wallpaper":
208
+ emit(wallpaper())
209
+
210
+ default:
211
+ fail("unknown command: \(command)")
212
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "codex-ringside",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Usage rings and customizable backgrounds for the Codex desktop app. Unofficial macOS add-on.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "codex-ringside": "bin/codex-ringside.cjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "os": [
14
+ "darwin"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test test/*.test.cjs",
18
+ "install-agent": "node bin/codex-ringside.cjs install",
19
+ "uninstall-agent": "node bin/codex-ringside.cjs uninstall",
20
+ "status": "node bin/codex-ringside.cjs status",
21
+ "check": "node scripts/check.cjs",
22
+ "check:package": "node scripts/check-package.cjs",
23
+ "verify": "npm run check && npm test && npm run check:package"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/tccpc/codex-ringside.git"
28
+ },
29
+ "homepage": "https://github.com/tccpc/codex-ringside#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/tccpc/codex-ringside/issues"
32
+ },
33
+ "author": "tccpc",
34
+ "keywords": [
35
+ "codex",
36
+ "macos",
37
+ "usage",
38
+ "sidebar",
39
+ "background"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public",
43
+ "registry": "https://registry.npmjs.org/"
44
+ },
45
+ "files": [
46
+ "bin/*.cjs",
47
+ "src/**/*.cjs",
48
+ "src/**/*.js",
49
+ "native/bridge.swift",
50
+ "README*.md",
51
+ "LICENSE",
52
+ "NOTICE",
53
+ "CHANGELOG.md"
54
+ ]
55
+ }
package/src/agent.cjs ADDED
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { AppServerClient } = require('./app-server.cjs');
5
+ const { BackgroundStore, readScreens } = require('./background.cjs');
6
+ const { CDP_PORT, RendererInjector } = require('./cdp.cjs');
7
+ const { formatUsage, unavailable } = require('./limits.cjs');
8
+ const { authFile, resolveCodexBin } = require('./resolve.cjs');
9
+ const { version } = require('../package.json');
10
+
11
+ const SCAN_MS = 5000;
12
+ const POLL_MS = 60000;
13
+
14
+ const log = message => process.stdout.write(`[${new Date().toISOString()}] ${message}\n`);
15
+
16
+ // Only the timestamp is read. Account switchers such as Cockpit Tools rewrite this file.
17
+ function authStamp() {
18
+ try {
19
+ return fs.statSync(authFile()).mtimeMs;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ async function runAgent({ appPath, root, signal }) {
26
+ const injector = new RendererInjector({ port: CDP_PORT });
27
+ injector.currentValue = { rings: [], message: '正在读取 Codex 剩余额度' };
28
+ const background = new BackgroundStore({ root, readScreens: () => readScreens(root, appPath) });
29
+ let backgroundWork = Promise.resolve();
30
+ let client = null;
31
+ let reading = false;
32
+ let nextRead = 0;
33
+ let failures = 0;
34
+ let connected = false;
35
+ let seenAuth = authStamp();
36
+ let ticking = false;
37
+
38
+ const dropClient = () => {
39
+ const old = client;
40
+ client = null;
41
+ old?.stop();
42
+ };
43
+ const showUnavailable = () => injector.update(unavailable(injector.currentValue)).catch(() => {});
44
+
45
+ async function scan() {
46
+ try {
47
+ await injector.scan();
48
+ if (!connected && injector.sessions.size > 0) log('connected to Codex window');
49
+ connected = injector.sessions.size > 0;
50
+ } catch {
51
+ connected = false;
52
+ injector.clearSessions();
53
+ }
54
+ // No window to draw on means nothing to read: stop the app-server until Codex is back.
55
+ if (injector.sessions.size === 0) {
56
+ dropClient();
57
+ nextRead = 0;
58
+ injector.currentValue = unavailable(injector.currentValue);
59
+ }
60
+ }
61
+
62
+ function checkAccount() {
63
+ const stamp = authStamp();
64
+ if (stamp === seenAuth) return;
65
+ seenAuth = stamp;
66
+ log('account file changed, reconnecting');
67
+ dropClient();
68
+ nextRead = 0;
69
+ }
70
+
71
+ function createClient() {
72
+ const current = new AppServerClient({ command: resolveCodexBin(appPath), version });
73
+ current.on('rate-limits', data => {
74
+ if (client !== current) return;
75
+ injector.update({ ...formatUsage(data), updatedAt: Date.now(), stale: false }).catch(() => {});
76
+ });
77
+ current.on('server-exit', () => {
78
+ if (client !== current) return;
79
+ client = null;
80
+ nextRead = Date.now() + 10000;
81
+ showUnavailable();
82
+ });
83
+ return current;
84
+ }
85
+
86
+ async function readUsage() {
87
+ if (reading || Date.now() < nextRead || injector.sessions.size === 0) return;
88
+ reading = true;
89
+ try {
90
+ if (client) {
91
+ await client.refresh();
92
+ } else {
93
+ client = createClient();
94
+ await client.start();
95
+ }
96
+ failures = 0;
97
+ if (client) nextRead = Date.now() + POLL_MS;
98
+ } catch {
99
+ dropClient();
100
+ failures++;
101
+ nextRead = Date.now() + Math.min(POLL_MS, 10000 * failures);
102
+ await showUnavailable();
103
+ } finally {
104
+ reading = false;
105
+ }
106
+ }
107
+
108
+ // One job at a time: a panel change and a scan must not convert or prune images together.
109
+ function queueBackground(job) {
110
+ backgroundWork = backgroundWork
111
+ .then(job)
112
+ .then(next => next && injector.setBackground(next))
113
+ .catch(error => log(`background: ${error.message}`));
114
+ }
115
+
116
+ injector.onPanelMessage = text => queueBackground(async () => {
117
+ try {
118
+ await background.commit(JSON.parse(text));
119
+ } catch (error) {
120
+ await injector.notifyBackground(error.message);
121
+ return null;
122
+ }
123
+ return background.rebuild();
124
+ });
125
+
126
+ async function tick() {
127
+ if (ticking || signal.aborted) return;
128
+ ticking = true;
129
+ try {
130
+ await scan();
131
+ checkAccount();
132
+ readUsage().catch(() => {});
133
+ if (injector.sessions.size > 0) queueBackground(() => background.refresh());
134
+ } finally {
135
+ ticking = false;
136
+ }
137
+ }
138
+
139
+ const timer = setInterval(() => tick().catch(() => {}), SCAN_MS);
140
+ signal.addEventListener('abort', () => {
141
+ clearInterval(timer);
142
+ dropClient();
143
+ injector.stop();
144
+ }, { once: true });
145
+ await tick();
146
+ }
147
+
148
+ function loadSettings(root) {
149
+ return JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8'));
150
+ }
151
+
152
+ async function main() {
153
+ const { startWatcher } = require('./startup/watch.cjs');
154
+ const root = path.resolve(__dirname, '..');
155
+ const settings = loadSettings(root);
156
+ const abort = new AbortController();
157
+ log(`codex-ringside ${version} started`);
158
+
159
+ const watcher = startWatcher({ root, settings, log });
160
+ const shutdown = () => {
161
+ abort.abort();
162
+ watcher.stop();
163
+ process.exit(0);
164
+ };
165
+ process.once('SIGINT', shutdown);
166
+ process.once('SIGTERM', shutdown);
167
+
168
+ await runAgent({ appPath: settings.app, root, signal: abort.signal });
169
+ await watcher.exited;
170
+ }
171
+
172
+ module.exports = { loadSettings, runAgent };
173
+
174
+ if (require.main === module) {
175
+ main().catch(error => {
176
+ log(`fatal: ${error.message}`);
177
+ process.exitCode = 1;
178
+ });
179
+ }
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+ const { EventEmitter } = require('node:events');
3
+ const { spawn } = require('node:child_process');
4
+ const readline = require('node:readline');
5
+ const { mergeResponse } = require('./limits.cjs');
6
+
7
+ // Minimal JSON-RPC client for `codex app-server` over stdio. Only ever asks for rate limits.
8
+ class AppServerClient extends EventEmitter {
9
+ constructor({ command, args = ['app-server'], version = '0', requestTimeoutMs = 15000 }) {
10
+ super();
11
+ Object.assign(this, { command, args, version, requestTimeoutMs });
12
+ this.child = null;
13
+ this.lines = null;
14
+ this.pending = new Map();
15
+ this.serial = 0;
16
+ this.rateLimits = null;
17
+ }
18
+
19
+ async start() {
20
+ if (this.child) return;
21
+ const child = spawn(this.command, this.args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env });
22
+ this.child = child;
23
+ const fail = error => {
24
+ if (this.child !== child) return;
25
+ this.stop();
26
+ this.emit('server-exit', error);
27
+ };
28
+ child.once('error', fail);
29
+ child.once('exit', code => fail(new Error(`App Server exited (${code})`)));
30
+ child.stdin.on('error', fail);
31
+ child.stderr.resume(); // Drain it: the stream can carry account details, so it is never logged.
32
+ this.lines = readline.createInterface({ input: child.stdout });
33
+ this.lines.on('line', line => this.handleLine(line));
34
+ await this.request('initialize', {
35
+ clientInfo: { name: 'codex_ringside', title: 'Codex Ringside', version: this.version }
36
+ });
37
+ this.child?.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
38
+ await this.refresh();
39
+ }
40
+
41
+ handleLine(line) {
42
+ let message;
43
+ try { message = JSON.parse(line); } catch { return; }
44
+ if (!message || typeof message !== 'object') return;
45
+ const request = this.pending.get(message.id);
46
+ if (request) {
47
+ this.pending.delete(message.id);
48
+ clearTimeout(request.timer);
49
+ if (message.error) request.reject(new Error(message.error.message || 'App Server request failed'));
50
+ else request.resolve(message.result);
51
+ } else if (message.method === 'account/rateLimits/updated') {
52
+ this.rateLimits = mergeResponse(this.rateLimits, message.params);
53
+ this.emit('rate-limits', this.rateLimits);
54
+ }
55
+ }
56
+
57
+ request(method, params) {
58
+ if (!this.child?.stdin.writable) return Promise.reject(new Error('App Server is not running'));
59
+ const id = ++this.serial;
60
+ return new Promise((resolve, reject) => {
61
+ const timer = setTimeout(() => {
62
+ this.pending.delete(id);
63
+ reject(new Error(`${method} timed out`));
64
+ }, this.requestTimeoutMs);
65
+ this.pending.set(id, { resolve, reject, timer });
66
+ const body = JSON.stringify({ id, method, ...(params === undefined ? {} : { params }) });
67
+ this.child.stdin.write(`${body}\n`, error => {
68
+ if (error && this.pending.delete(id)) {
69
+ clearTimeout(timer);
70
+ reject(error);
71
+ }
72
+ });
73
+ });
74
+ }
75
+
76
+ async refresh() {
77
+ this.rateLimits = mergeResponse(null, await this.request('account/rateLimits/read'));
78
+ this.emit('rate-limits', this.rateLimits);
79
+ return this.rateLimits;
80
+ }
81
+
82
+ stop() {
83
+ const child = this.child;
84
+ this.child = null;
85
+ this.lines?.close();
86
+ this.lines = null;
87
+ if (child && !child.killed) child.kill();
88
+ for (const item of this.pending.values()) {
89
+ clearTimeout(item.timer);
90
+ item.reject(new Error('App Server stopped'));
91
+ }
92
+ this.pending.clear();
93
+ }
94
+ }
95
+
96
+ module.exports = { AppServerClient };