codex-web-ui 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/README.md +372 -0
- package/bin/codex-web-ui +4 -0
- package/launch_codex_webui_unpacked.sh +1278 -0
- package/package.json +15 -0
- package/webui-bridge.js +202 -0
|
@@ -0,0 +1,1278 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
APP_PATH="/Applications/Codex.app"
|
|
5
|
+
APP_ASAR="$APP_PATH/Contents/Resources/app.asar"
|
|
6
|
+
CLI_PATH="$APP_PATH/Contents/Resources/codex"
|
|
7
|
+
PORT="${CODEX_WEBUI_PORT:-5999}"
|
|
8
|
+
TOKEN=""
|
|
9
|
+
ORIGINS=""
|
|
10
|
+
KEEP_TEMP=0
|
|
11
|
+
NO_OPEN=0
|
|
12
|
+
USER_DATA_DIR=""
|
|
13
|
+
BRIDGE_PATH="$(cd "$(dirname "$0")" && pwd)/webui-bridge.js"
|
|
14
|
+
AUTO_INSTALL_TOOLS="${AUTO_INSTALL_TOOLS:-1}"
|
|
15
|
+
|
|
16
|
+
usage() {
|
|
17
|
+
cat <<'USAGE'
|
|
18
|
+
Usage:
|
|
19
|
+
launch_codex_webui_unpacked.sh [options] [-- <extra args>]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--app <path> Codex.app path
|
|
23
|
+
--port <n> webui port (default: 5999)
|
|
24
|
+
--token <value> pass --token for auth
|
|
25
|
+
--origins <csv> pass --origins allowlist
|
|
26
|
+
--bridge <path> standalone webui-bridge.js path
|
|
27
|
+
--user-data-dir <path> chromium user data dir override
|
|
28
|
+
--no-open don't open browser
|
|
29
|
+
--keep-temp keep temp extracted app dir
|
|
30
|
+
-h, --help
|
|
31
|
+
USAGE
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
activate_homebrew_path() {
|
|
35
|
+
if command -v brew >/dev/null 2>&1; then
|
|
36
|
+
eval "$(brew shellenv)" >/dev/null 2>&1 || true
|
|
37
|
+
return
|
|
38
|
+
fi
|
|
39
|
+
local brew_bin
|
|
40
|
+
for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew; do
|
|
41
|
+
if [[ -x "$brew_bin" ]]; then
|
|
42
|
+
eval "$("$brew_bin" shellenv)" >/dev/null 2>&1 || export PATH="$(dirname "$brew_bin"):$PATH"
|
|
43
|
+
return
|
|
44
|
+
fi
|
|
45
|
+
done
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ensure_homebrew() {
|
|
49
|
+
activate_homebrew_path
|
|
50
|
+
if command -v brew >/dev/null 2>&1; then
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
if [[ "$AUTO_INSTALL_TOOLS" != "1" ]]; then
|
|
55
|
+
echo "Missing Homebrew (set AUTO_INSTALL_TOOLS=1 to allow auto-install)." >&2
|
|
56
|
+
return 1
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
command -v curl >/dev/null 2>&1 || {
|
|
60
|
+
echo "curl is required to install Homebrew automatically." >&2
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
echo "Homebrew not found. Installing Homebrew..."
|
|
65
|
+
if ! NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then
|
|
66
|
+
return 1
|
|
67
|
+
fi
|
|
68
|
+
activate_homebrew_path
|
|
69
|
+
command -v brew >/dev/null 2>&1 || {
|
|
70
|
+
echo "Homebrew install completed, but brew is still unavailable in PATH." >&2
|
|
71
|
+
return 1
|
|
72
|
+
}
|
|
73
|
+
return 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ensure_brew_package() {
|
|
77
|
+
local command_name="$1"
|
|
78
|
+
local package_name="$2"
|
|
79
|
+
if command -v "$command_name" >/dev/null 2>&1; then
|
|
80
|
+
return
|
|
81
|
+
fi
|
|
82
|
+
ensure_homebrew || return 1
|
|
83
|
+
echo "Installing missing tool: $package_name"
|
|
84
|
+
brew list "$package_name" >/dev/null 2>&1 || brew install "$package_name" || return 1
|
|
85
|
+
activate_homebrew_path
|
|
86
|
+
command -v "$command_name" >/dev/null 2>&1 || {
|
|
87
|
+
echo "Failed to install required tool: $command_name" >&2
|
|
88
|
+
return 1
|
|
89
|
+
}
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
install_node_with_nvm() {
|
|
94
|
+
if [[ "$AUTO_INSTALL_TOOLS" != "1" ]]; then
|
|
95
|
+
return 1
|
|
96
|
+
fi
|
|
97
|
+
command -v curl >/dev/null 2>&1 || return 1
|
|
98
|
+
|
|
99
|
+
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
|
100
|
+
if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then
|
|
101
|
+
echo "Installing nvm (user-space) to bootstrap Node.js..."
|
|
102
|
+
if ! curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash >/dev/null; then
|
|
103
|
+
return 1
|
|
104
|
+
fi
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
[[ -s "$NVM_DIR/nvm.sh" ]] || return 1
|
|
108
|
+
# shellcheck disable=SC1090
|
|
109
|
+
source "$NVM_DIR/nvm.sh" || return 1
|
|
110
|
+
nvm install --lts >/dev/null || return 1
|
|
111
|
+
nvm use --lts >/dev/null || return 1
|
|
112
|
+
command -v node >/dev/null 2>&1 && command -v npx >/dev/null 2>&1
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
install_node_with_fnm() {
|
|
116
|
+
if [[ "$AUTO_INSTALL_TOOLS" != "1" ]]; then
|
|
117
|
+
return 1
|
|
118
|
+
fi
|
|
119
|
+
command -v curl >/dev/null 2>&1 || return 1
|
|
120
|
+
command -v unzip >/dev/null 2>&1 || return 1
|
|
121
|
+
|
|
122
|
+
local fnm_dir="${FNM_DIR:-$HOME/.local/share/fnm}"
|
|
123
|
+
local fnm_bin="$fnm_dir/fnm"
|
|
124
|
+
if ! command -v fnm >/dev/null 2>&1; then
|
|
125
|
+
echo "Installing fnm (user-space) to bootstrap Node.js..."
|
|
126
|
+
local tag asset tmp_dir
|
|
127
|
+
tag="$(curl -fsSL https://api.github.com/repos/Schniz/fnm/releases/latest | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"
|
|
128
|
+
[[ -n "$tag" ]] || return 1
|
|
129
|
+
case "$(uname -s)" in
|
|
130
|
+
Darwin)
|
|
131
|
+
asset="fnm-macos.zip"
|
|
132
|
+
;;
|
|
133
|
+
Linux)
|
|
134
|
+
case "$(uname -m)" in
|
|
135
|
+
arm64|aarch64) asset="fnm-arm64.zip" ;;
|
|
136
|
+
x86_64|amd64) asset="fnm-linux.zip" ;;
|
|
137
|
+
*) return 1 ;;
|
|
138
|
+
esac
|
|
139
|
+
;;
|
|
140
|
+
*)
|
|
141
|
+
return 1
|
|
142
|
+
;;
|
|
143
|
+
esac
|
|
144
|
+
tmp_dir="$(mktemp -d)"
|
|
145
|
+
if ! curl -fsSL "https://github.com/Schniz/fnm/releases/download/$tag/$asset" -o "$tmp_dir/fnm.zip"; then
|
|
146
|
+
rm -rf "$tmp_dir"
|
|
147
|
+
return 1
|
|
148
|
+
fi
|
|
149
|
+
mkdir -p "$fnm_dir"
|
|
150
|
+
if ! unzip -oq "$tmp_dir/fnm.zip" -d "$fnm_dir"; then
|
|
151
|
+
rm -rf "$tmp_dir"
|
|
152
|
+
return 1
|
|
153
|
+
fi
|
|
154
|
+
chmod +x "$fnm_bin" >/dev/null 2>&1 || true
|
|
155
|
+
rm -rf "$tmp_dir"
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
if [[ -x "$fnm_bin" ]]; then
|
|
159
|
+
export PATH="$fnm_dir:$PATH"
|
|
160
|
+
fi
|
|
161
|
+
command -v fnm >/dev/null 2>&1 || return 1
|
|
162
|
+
|
|
163
|
+
# shellcheck disable=SC2046
|
|
164
|
+
eval "$(fnm env --shell bash)" || return 1
|
|
165
|
+
fnm install --lts >/dev/null || return 1
|
|
166
|
+
fnm use lts-latest >/dev/null || return 1
|
|
167
|
+
command -v node >/dev/null 2>&1 && command -v npx >/dev/null 2>&1
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
ensure_required_tools() {
|
|
171
|
+
if ! command -v node >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1; then
|
|
172
|
+
ensure_brew_package node node || install_node_with_nvm || install_node_with_fnm || {
|
|
173
|
+
echo "Failed to install Node.js/npx automatically." >&2
|
|
174
|
+
exit 1
|
|
175
|
+
}
|
|
176
|
+
fi
|
|
177
|
+
command -v node >/dev/null 2>&1 || { echo "node is required" >&2; exit 1; }
|
|
178
|
+
command -v npx >/dev/null 2>&1 || { echo "npx is required" >&2; exit 1; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
has_pattern() {
|
|
182
|
+
local pattern="$1"
|
|
183
|
+
local file="$2"
|
|
184
|
+
if command -v rg >/dev/null 2>&1; then
|
|
185
|
+
rg -q -- "$pattern" "$file"
|
|
186
|
+
else
|
|
187
|
+
grep -Eq -- "$pattern" "$file"
|
|
188
|
+
fi
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
write_main_injection_chunk() {
|
|
192
|
+
local dest="$1"
|
|
193
|
+
cat > "$dest" <<'JS'
|
|
194
|
+
/*__CODEX_WEBUI_RUNTIME_PATCH__*/
|
|
195
|
+
;(() => {
|
|
196
|
+
if (globalThis.__CODEX_WEBUI_RUNTIME_PATCHED__) return;
|
|
197
|
+
globalThis.__CODEX_WEBUI_RUNTIME_PATCHED__ = true;
|
|
198
|
+
|
|
199
|
+
function webUiParsePortArg(value, fallback) {
|
|
200
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
201
|
+
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : fallback;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function webUiParseCliOptions(argv = process.argv, env = process.env) {
|
|
205
|
+
let enabled = false;
|
|
206
|
+
let port = webUiParsePortArg(env.CODEX_WEBUI_PORT, 3210);
|
|
207
|
+
let token = (env.CODEX_WEBUI_TOKEN ?? "").trim();
|
|
208
|
+
let origins = (env.CODEX_WEBUI_ORIGINS ?? "")
|
|
209
|
+
.split(",")
|
|
210
|
+
.map((x) => x.trim())
|
|
211
|
+
.filter(Boolean);
|
|
212
|
+
|
|
213
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
214
|
+
const arg = argv[i];
|
|
215
|
+
if (arg === "--webui") {
|
|
216
|
+
enabled = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (arg === "--port" && i + 1 < argv.length) {
|
|
220
|
+
port = webUiParsePortArg(argv[i + 1], port);
|
|
221
|
+
i += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (arg.startsWith("--port=")) {
|
|
225
|
+
port = webUiParsePortArg(arg.slice("--port=".length), port);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (arg === "--token" && i + 1 < argv.length) {
|
|
229
|
+
token = String(argv[i + 1] ?? "").trim();
|
|
230
|
+
i += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (arg.startsWith("--token=")) {
|
|
234
|
+
token = arg.slice("--token=".length).trim();
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (arg.startsWith("--origins=")) {
|
|
238
|
+
origins = arg
|
|
239
|
+
.slice("--origins=".length)
|
|
240
|
+
.split(",")
|
|
241
|
+
.map((x) => x.trim())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { enabled, port, token, origins };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const webUiOptions = webUiParseCliOptions();
|
|
251
|
+
if (!webUiOptions.enabled) return;
|
|
252
|
+
|
|
253
|
+
const electron = require("electron");
|
|
254
|
+
const app = electron?.app;
|
|
255
|
+
const BrowserWindow = electron?.BrowserWindow;
|
|
256
|
+
if (!app || !BrowserWindow) {
|
|
257
|
+
console.error("[webui] Electron app APIs unavailable.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const http = require("node:http");
|
|
262
|
+
const fs = require("node:fs");
|
|
263
|
+
const path = require("node:path");
|
|
264
|
+
const crypto = require("node:crypto");
|
|
265
|
+
const { EventEmitter } = require("node:events");
|
|
266
|
+
let webUiAppIsQuitting = false;
|
|
267
|
+
function webUiFormatError(err) {
|
|
268
|
+
if (!err) return "Unknown error";
|
|
269
|
+
if (typeof err === "string") return err;
|
|
270
|
+
if (typeof err.message === "string" && err.message.length > 0) return err.message;
|
|
271
|
+
try {
|
|
272
|
+
return JSON.stringify(err);
|
|
273
|
+
} catch {
|
|
274
|
+
return String(err);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const webUiLogger = (() => {
|
|
278
|
+
try {
|
|
279
|
+
if (typeof Xt === "function") {
|
|
280
|
+
const logger = Xt();
|
|
281
|
+
if (logger && typeof logger.info === "function" && typeof logger.warning === "function") {
|
|
282
|
+
return logger;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch {}
|
|
286
|
+
return {
|
|
287
|
+
info(message, data) {
|
|
288
|
+
console.info(`[webui] ${message}`, data ?? "");
|
|
289
|
+
},
|
|
290
|
+
warning(message, data) {
|
|
291
|
+
console.warn(`[webui] ${message}`, data ?? "");
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
})();
|
|
295
|
+
|
|
296
|
+
class WebUiSocket extends EventEmitter {
|
|
297
|
+
constructor(socket) {
|
|
298
|
+
super();
|
|
299
|
+
this.socket = socket;
|
|
300
|
+
this.readyState = WebUiSocket.OPEN;
|
|
301
|
+
this.closed = false;
|
|
302
|
+
this.buffer = Buffer.alloc(0);
|
|
303
|
+
|
|
304
|
+
socket.on("data", (chunk) => this.onData(chunk));
|
|
305
|
+
socket.on("error", (err) => {
|
|
306
|
+
// Avoid unhandled EventEmitter "error" crashes on transient socket resets.
|
|
307
|
+
this.emit("ws-error", err);
|
|
308
|
+
this.finishClose(1006, String(err?.code ?? "socket-error"));
|
|
309
|
+
});
|
|
310
|
+
socket.on("close", () => {
|
|
311
|
+
this.finishClose(1006, "");
|
|
312
|
+
});
|
|
313
|
+
socket.on("end", () => {
|
|
314
|
+
this.finishClose(1006, "");
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
send(data, callback) {
|
|
319
|
+
if (this.readyState !== WebUiSocket.OPEN) {
|
|
320
|
+
if (typeof callback === "function") callback(new Error("Socket is not open"));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
|
324
|
+
const frame = WebUiSocket.buildFrame(0x1, payload);
|
|
325
|
+
this.socket.write(frame, callback);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
close(code = 1000, reason = "") {
|
|
329
|
+
if (this.readyState === WebUiSocket.CLOSED || this.readyState === WebUiSocket.CLOSING) return;
|
|
330
|
+
this.readyState = WebUiSocket.CLOSING;
|
|
331
|
+
let payload;
|
|
332
|
+
try {
|
|
333
|
+
const reasonBuf = Buffer.from(String(reason));
|
|
334
|
+
payload = Buffer.allocUnsafe(2 + reasonBuf.length);
|
|
335
|
+
payload.writeUInt16BE(code, 0);
|
|
336
|
+
reasonBuf.copy(payload, 2);
|
|
337
|
+
} catch {
|
|
338
|
+
payload = Buffer.from([0x03, 0xe8]);
|
|
339
|
+
}
|
|
340
|
+
this.socket.write(WebUiSocket.buildFrame(0x8, payload), () => {
|
|
341
|
+
this.socket.end();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
onData(chunk) {
|
|
346
|
+
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
347
|
+
|
|
348
|
+
while (this.buffer.length >= 2) {
|
|
349
|
+
const first = this.buffer[0];
|
|
350
|
+
const second = this.buffer[1];
|
|
351
|
+
const fin = (first & 0x80) !== 0;
|
|
352
|
+
const opcode = first & 0x0f;
|
|
353
|
+
const masked = (second & 0x80) !== 0;
|
|
354
|
+
let payloadLen = second & 0x7f;
|
|
355
|
+
let offset = 2;
|
|
356
|
+
|
|
357
|
+
if (payloadLen === 126) {
|
|
358
|
+
if (this.buffer.length < 4) return;
|
|
359
|
+
payloadLen = this.buffer.readUInt16BE(2);
|
|
360
|
+
offset = 4;
|
|
361
|
+
} else if (payloadLen === 127) {
|
|
362
|
+
if (this.buffer.length < 10) return;
|
|
363
|
+
const high = this.buffer.readUInt32BE(2);
|
|
364
|
+
const low = this.buffer.readUInt32BE(6);
|
|
365
|
+
payloadLen = high * 2 ** 32 + low;
|
|
366
|
+
if (!Number.isSafeInteger(payloadLen)) {
|
|
367
|
+
this.close(1009, "Frame too large");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
offset = 10;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let mask;
|
|
374
|
+
if (masked) {
|
|
375
|
+
if (this.buffer.length < offset + 4) return;
|
|
376
|
+
mask = this.buffer.subarray(offset, offset + 4);
|
|
377
|
+
offset += 4;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (this.buffer.length < offset + payloadLen) return;
|
|
381
|
+
|
|
382
|
+
let payload = this.buffer.subarray(offset, offset + payloadLen);
|
|
383
|
+
this.buffer = this.buffer.subarray(offset + payloadLen);
|
|
384
|
+
|
|
385
|
+
if (masked && mask) {
|
|
386
|
+
payload = Buffer.from(payload);
|
|
387
|
+
for (let i = 0; i < payload.length; i += 1) {
|
|
388
|
+
payload[i] ^= mask[i & 3];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!fin) {
|
|
393
|
+
this.close(1003, "Fragmented frames are not supported");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (opcode === 0x1) {
|
|
398
|
+
this.emit("message", payload);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (opcode === 0x8) {
|
|
402
|
+
let code = 1000;
|
|
403
|
+
let reason = "";
|
|
404
|
+
if (payload.length >= 2) {
|
|
405
|
+
code = payload.readUInt16BE(0);
|
|
406
|
+
reason = payload.subarray(2).toString();
|
|
407
|
+
}
|
|
408
|
+
if (this.readyState === WebUiSocket.OPEN) {
|
|
409
|
+
this.socket.write(WebUiSocket.buildFrame(0x8, payload), () => {
|
|
410
|
+
this.socket.end();
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
this.finishClose(code, reason);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (opcode === 0x9) {
|
|
417
|
+
this.socket.write(WebUiSocket.buildFrame(0xA, payload));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (opcode === 0xA) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this.close(1003, "Unsupported opcode");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
finishClose(code, reason) {
|
|
430
|
+
if (this.closed) return;
|
|
431
|
+
this.closed = true;
|
|
432
|
+
this.readyState = WebUiSocket.CLOSED;
|
|
433
|
+
this.emit("close", code, reason);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
static buildFrame(opcode, payload) {
|
|
437
|
+
const len = payload.length;
|
|
438
|
+
let headerLen = 2;
|
|
439
|
+
if (len >= 126 && len <= 65535) headerLen = 4;
|
|
440
|
+
else if (len > 65535) headerLen = 10;
|
|
441
|
+
|
|
442
|
+
const out = Buffer.allocUnsafe(headerLen + len);
|
|
443
|
+
out[0] = 0x80 | (opcode & 0x0f);
|
|
444
|
+
|
|
445
|
+
if (headerLen === 2) {
|
|
446
|
+
out[1] = len;
|
|
447
|
+
payload.copy(out, 2);
|
|
448
|
+
} else if (headerLen === 4) {
|
|
449
|
+
out[1] = 126;
|
|
450
|
+
out.writeUInt16BE(len, 2);
|
|
451
|
+
payload.copy(out, 4);
|
|
452
|
+
} else {
|
|
453
|
+
out[1] = 127;
|
|
454
|
+
const high = Math.floor(len / 2 ** 32);
|
|
455
|
+
const low = len >>> 0;
|
|
456
|
+
out.writeUInt32BE(high, 2);
|
|
457
|
+
out.writeUInt32BE(low, 6);
|
|
458
|
+
payload.copy(out, 10);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
WebUiSocket.CONNECTING = 0;
|
|
466
|
+
WebUiSocket.OPEN = 1;
|
|
467
|
+
WebUiSocket.CLOSING = 2;
|
|
468
|
+
WebUiSocket.CLOSED = 3;
|
|
469
|
+
|
|
470
|
+
class WebUiSocketServer extends EventEmitter {
|
|
471
|
+
handleUpgrade(req, socket, head, callback) {
|
|
472
|
+
const upgrade = String(req.headers.upgrade ?? "").toLowerCase();
|
|
473
|
+
const key = req.headers["sec-websocket-key"];
|
|
474
|
+
if (upgrade !== "websocket" || typeof key !== "string" || key.length === 0) {
|
|
475
|
+
socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
476
|
+
socket.destroy();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const accept = crypto
|
|
481
|
+
.createHash("sha1")
|
|
482
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
483
|
+
.digest("base64");
|
|
484
|
+
|
|
485
|
+
const headers = [
|
|
486
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
487
|
+
"Upgrade: websocket",
|
|
488
|
+
"Connection: Upgrade",
|
|
489
|
+
`Sec-WebSocket-Accept: ${accept}`,
|
|
490
|
+
];
|
|
491
|
+
socket.write(`${headers.join("\r\n")}\r\n\r\n`);
|
|
492
|
+
|
|
493
|
+
if (head && head.length > 0) socket.unshift(head);
|
|
494
|
+
|
|
495
|
+
const ws = new WebUiSocket(socket);
|
|
496
|
+
callback(ws, req);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
close() {
|
|
500
|
+
this.emit("close");
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function webUiTokensEqual(a, b) {
|
|
505
|
+
if (!a || !b) return false;
|
|
506
|
+
try {
|
|
507
|
+
const left = Buffer.from(a);
|
|
508
|
+
const right = Buffer.from(b);
|
|
509
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function webUiParseCookieHeader(value) {
|
|
516
|
+
if (!value) return {};
|
|
517
|
+
return value
|
|
518
|
+
.split(";")
|
|
519
|
+
.map((part) => part.trim())
|
|
520
|
+
.filter(Boolean)
|
|
521
|
+
.reduce((acc, segment) => {
|
|
522
|
+
const idx = segment.indexOf("=");
|
|
523
|
+
if (idx <= 0) return acc;
|
|
524
|
+
const key = segment.slice(0, idx).trim();
|
|
525
|
+
const raw = segment.slice(idx + 1).trim();
|
|
526
|
+
try {
|
|
527
|
+
acc[key] = decodeURIComponent(raw);
|
|
528
|
+
} catch {
|
|
529
|
+
acc[key] = raw;
|
|
530
|
+
}
|
|
531
|
+
return acc;
|
|
532
|
+
}, {});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function webUiExtractAuthToken(req, parsedUrl) {
|
|
536
|
+
const auth = req.headers.authorization;
|
|
537
|
+
if (typeof auth === "string" && auth.startsWith("Bearer ")) return auth.slice(7).trim();
|
|
538
|
+
const headerToken = req.headers["x-codex-webui-token"];
|
|
539
|
+
if (typeof headerToken === "string" && headerToken.trim()) return headerToken.trim();
|
|
540
|
+
const qpToken = parsedUrl.searchParams.get("token");
|
|
541
|
+
if (qpToken && qpToken.trim()) return qpToken.trim();
|
|
542
|
+
const cookieToken = webUiParseCookieHeader(req.headers.cookie ?? "").codex_webui_token;
|
|
543
|
+
return typeof cookieToken === "string" ? cookieToken.trim() : "";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function webUiResolveAssetPath(rootDir, requestPath) {
|
|
547
|
+
let decoded;
|
|
548
|
+
try {
|
|
549
|
+
decoded = decodeURIComponent(requestPath);
|
|
550
|
+
} catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const rel = decoded === "/" ? "index.html" : decoded.replace(/^[/\\]+/, "");
|
|
555
|
+
const root = path.resolve(rootDir);
|
|
556
|
+
const resolved = path.resolve(root, rel);
|
|
557
|
+
return resolved === root || resolved.startsWith(`${root}${path.sep}`) ? resolved : null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function webUiSetResponseSecurityHeaders(res) {
|
|
561
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
562
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
563
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
564
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function webUiInjectRuntimeScripts(html) {
|
|
568
|
+
if (html.includes('/webui-bridge.js')) return html;
|
|
569
|
+
const injection =
|
|
570
|
+
"\n <script src=\"/webui-config.js\"></script>\n <script src=\"/webui-bridge.js\"></script>\n";
|
|
571
|
+
return html.includes("</head>") ? html.replace("</head>", `${injection}</head>`) : `${injection}${html}`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function webUiInvokeElectronBridgeMethod(windowRef, method, args) {
|
|
575
|
+
if (windowRef.isDestroyed() || windowRef.webContents.isDestroyed()) {
|
|
576
|
+
throw new Error("WebUI bridge window is not available.");
|
|
577
|
+
}
|
|
578
|
+
const methodJson = JSON.stringify(method);
|
|
579
|
+
const argsJson = JSON.stringify(args ?? []);
|
|
580
|
+
const code = `
|
|
581
|
+
Promise.resolve().then(async () => {
|
|
582
|
+
const bridge = window.electronBridge;
|
|
583
|
+
if (!bridge || typeof bridge[${methodJson}] !== "function") {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
return await bridge[${methodJson}](...${argsJson});
|
|
587
|
+
});
|
|
588
|
+
`;
|
|
589
|
+
return windowRef.webContents.executeJavaScript(code, true);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function webUiDispatchMessageFromView(bridgeWindow, context, payload) {
|
|
593
|
+
// Prefer the renderer bridge API; it is stable across minified builds.
|
|
594
|
+
const bridged = await webUiInvokeElectronBridgeMethod(bridgeWindow, "sendMessageFromView", [
|
|
595
|
+
payload,
|
|
596
|
+
]);
|
|
597
|
+
if (bridged !== null) return;
|
|
598
|
+
|
|
599
|
+
// Fallback for older/newer app internals.
|
|
600
|
+
if (context && typeof context.handleMessage === "function") {
|
|
601
|
+
await context.handleMessage(bridgeWindow.webContents, payload);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
throw new Error("No message dispatch handler available for WebUI bridge");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function waitForPrimaryWindow(timeoutMs = 30000) {
|
|
608
|
+
const start = Date.now();
|
|
609
|
+
while (Date.now() - start < timeoutMs) {
|
|
610
|
+
const win = BrowserWindow.getAllWindows().find((candidate) => candidate && !candidate.isDestroyed());
|
|
611
|
+
if (win && !win.isDestroyed()) return win;
|
|
612
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function webUiForceWindowHidden(win) {
|
|
618
|
+
if (!win || win.isDestroyed()) return;
|
|
619
|
+
const hideNow = () => {
|
|
620
|
+
try {
|
|
621
|
+
win.hide();
|
|
622
|
+
} catch {}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
hideNow();
|
|
626
|
+
|
|
627
|
+
if (!win.__codexWebUiShowPatched) {
|
|
628
|
+
win.__codexWebUiShowPatched = true;
|
|
629
|
+
if (typeof win.show === "function") {
|
|
630
|
+
win.show = () => {
|
|
631
|
+
hideNow();
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (typeof win.showInactive === "function") {
|
|
635
|
+
win.showInactive = () => {
|
|
636
|
+
hideNow();
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
win.on("show", () => {
|
|
642
|
+
setTimeout(hideNow, 0);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function webUiStartBridgeRuntime({ bridgeWindow, context }) {
|
|
647
|
+
const assetRoot = path.join(app.getAppPath(), "webview");
|
|
648
|
+
const host = "0.0.0.0";
|
|
649
|
+
const authRequired = !!webUiOptions.token;
|
|
650
|
+
const token =
|
|
651
|
+
authRequired && !webUiOptions.token
|
|
652
|
+
? crypto.randomBytes(24).toString("hex")
|
|
653
|
+
: webUiOptions.token;
|
|
654
|
+
const originAllowlist = new Set(webUiOptions.origins);
|
|
655
|
+
const sockets = new Set();
|
|
656
|
+
let cachedIndexHtml = "";
|
|
657
|
+
|
|
658
|
+
const originAllowed = (origin, hostHeader) => {
|
|
659
|
+
if (typeof origin !== "string") return false;
|
|
660
|
+
if (originAllowlist.size > 0) return originAllowlist.has(origin);
|
|
661
|
+
try {
|
|
662
|
+
return origin.length === 0 ? true : new URL(origin).host === hostHeader;
|
|
663
|
+
} catch {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const broadcast = (packet) => {
|
|
669
|
+
if (sockets.size === 0) return;
|
|
670
|
+
let serialized;
|
|
671
|
+
try {
|
|
672
|
+
serialized = JSON.stringify(packet);
|
|
673
|
+
} catch {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
for (const ws of sockets) {
|
|
677
|
+
if (ws.readyState !== WebUiSocket.OPEN) continue;
|
|
678
|
+
ws.send(serialized, (err) => {
|
|
679
|
+
if (err) {
|
|
680
|
+
webUiLogger.warning("WebUI socket send failed", {
|
|
681
|
+
message: webUiFormatError(err),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const originalSend = bridgeWindow.webContents.send.bind(bridgeWindow.webContents);
|
|
689
|
+
bridgeWindow.webContents.send = (channel, ...args) => {
|
|
690
|
+
const payload = args.find(
|
|
691
|
+
(value) =>
|
|
692
|
+
value &&
|
|
693
|
+
typeof value === "object" &&
|
|
694
|
+
!Array.isArray(value) &&
|
|
695
|
+
typeof value.type === "string",
|
|
696
|
+
);
|
|
697
|
+
if (payload) {
|
|
698
|
+
broadcast({
|
|
699
|
+
kind: "message-for-view",
|
|
700
|
+
payload,
|
|
701
|
+
});
|
|
702
|
+
} else if (
|
|
703
|
+
typeof channel === "string" &&
|
|
704
|
+
channel.startsWith("codex_desktop:worker:") &&
|
|
705
|
+
channel.endsWith(":for-view")
|
|
706
|
+
) {
|
|
707
|
+
broadcast({
|
|
708
|
+
kind: "worker-message-for-view",
|
|
709
|
+
workerId: channel.slice("codex_desktop:worker:".length, -":for-view".length),
|
|
710
|
+
payload: args[0],
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
originalSend(channel, ...args);
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const server = http.createServer(async (req, res) => {
|
|
717
|
+
const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
718
|
+
webUiSetResponseSecurityHeaders(res);
|
|
719
|
+
|
|
720
|
+
if (authRequired) {
|
|
721
|
+
const provided = webUiExtractAuthToken(req, parsedUrl);
|
|
722
|
+
if (!webUiTokensEqual(provided, token)) {
|
|
723
|
+
res.statusCode = 401;
|
|
724
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
725
|
+
res.end("Unauthorized");
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (parsedUrl.searchParams.get("token")) {
|
|
729
|
+
res.setHeader(
|
|
730
|
+
"Set-Cookie",
|
|
731
|
+
`codex_webui_token=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (parsedUrl.pathname === "/webui-config.js") {
|
|
737
|
+
const config = JSON.stringify({
|
|
738
|
+
wsPath: "/ws",
|
|
739
|
+
buildFlavor: process.env.BUILD_FLAVOR ?? "prod",
|
|
740
|
+
sentryInitOptions: null,
|
|
741
|
+
appSessionId: null,
|
|
742
|
+
});
|
|
743
|
+
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
744
|
+
res.setHeader("Cache-Control", "no-store");
|
|
745
|
+
res.end(`window.__CODEX_WEBUI_CONFIG__=${config};`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
let assetPath = webUiResolveAssetPath(assetRoot, parsedUrl.pathname);
|
|
750
|
+
if (parsedUrl.pathname === "/" || parsedUrl.pathname === "/index.html") {
|
|
751
|
+
assetPath = path.join(assetRoot, "index.html");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (assetPath) {
|
|
755
|
+
try {
|
|
756
|
+
const stat = await fs.promises.stat(assetPath);
|
|
757
|
+
if (stat.isFile()) {
|
|
758
|
+
if (path.basename(assetPath) === "index.html") {
|
|
759
|
+
if (!cachedIndexHtml) {
|
|
760
|
+
cachedIndexHtml = webUiInjectRuntimeScripts(await fs.promises.readFile(assetPath, "utf8"));
|
|
761
|
+
}
|
|
762
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
763
|
+
res.setHeader("Cache-Control", "no-store");
|
|
764
|
+
res.end(cachedIndexHtml);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const ext = path.extname(assetPath).toLowerCase();
|
|
769
|
+
const mime =
|
|
770
|
+
ext === ".html"
|
|
771
|
+
? "text/html"
|
|
772
|
+
: ext === ".js" || ext === ".mjs"
|
|
773
|
+
? "application/javascript"
|
|
774
|
+
: ext === ".css"
|
|
775
|
+
? "text/css"
|
|
776
|
+
: ext === ".json" || ext === ".map"
|
|
777
|
+
? "application/json"
|
|
778
|
+
: ext === ".svg"
|
|
779
|
+
? "image/svg+xml"
|
|
780
|
+
: ext === ".png"
|
|
781
|
+
? "image/png"
|
|
782
|
+
: ext === ".jpg" || ext === ".jpeg"
|
|
783
|
+
? "image/jpeg"
|
|
784
|
+
: ext === ".gif"
|
|
785
|
+
? "image/gif"
|
|
786
|
+
: ext === ".webp"
|
|
787
|
+
? "image/webp"
|
|
788
|
+
: ext === ".ico"
|
|
789
|
+
? "image/x-icon"
|
|
790
|
+
: ext === ".txt"
|
|
791
|
+
? "text/plain"
|
|
792
|
+
: ext === ".wasm"
|
|
793
|
+
? "application/wasm"
|
|
794
|
+
: "application/octet-stream";
|
|
795
|
+
|
|
796
|
+
res.setHeader(
|
|
797
|
+
"Content-Type",
|
|
798
|
+
mime.includes("charset") ? mime : `${mime}; charset=utf-8`,
|
|
799
|
+
);
|
|
800
|
+
res.setHeader("Cache-Control", "no-store");
|
|
801
|
+
|
|
802
|
+
fs.createReadStream(assetPath)
|
|
803
|
+
.on("error", (err) => {
|
|
804
|
+
webUiLogger.warning("WebUI static stream failed", {
|
|
805
|
+
message: webUiFormatError(err),
|
|
806
|
+
});
|
|
807
|
+
if (!res.headersSent) {
|
|
808
|
+
res.statusCode = 500;
|
|
809
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
810
|
+
}
|
|
811
|
+
res.end("Internal Server Error");
|
|
812
|
+
})
|
|
813
|
+
.pipe(res);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
} catch {
|
|
817
|
+
// fall through to SPA fallback
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (
|
|
822
|
+
parsedUrl.pathname === "/api" ||
|
|
823
|
+
parsedUrl.pathname.startsWith("/api/") ||
|
|
824
|
+
parsedUrl.pathname === "/auth" ||
|
|
825
|
+
parsedUrl.pathname.startsWith("/auth/") ||
|
|
826
|
+
parsedUrl.pathname === "/ws"
|
|
827
|
+
) {
|
|
828
|
+
res.statusCode = 404;
|
|
829
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
830
|
+
res.end("Not Found");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const indexPath = path.join(assetRoot, "index.html");
|
|
835
|
+
try {
|
|
836
|
+
if (!cachedIndexHtml) {
|
|
837
|
+
cachedIndexHtml = webUiInjectRuntimeScripts(await fs.promises.readFile(indexPath, "utf8"));
|
|
838
|
+
}
|
|
839
|
+
} catch {
|
|
840
|
+
cachedIndexHtml = "<!doctype html><html><body><h1>Web UI unavailable</h1></body></html>";
|
|
841
|
+
}
|
|
842
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
843
|
+
res.setHeader("Cache-Control", "no-store");
|
|
844
|
+
res.end(cachedIndexHtml);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const wss = new WebUiSocketServer();
|
|
848
|
+
|
|
849
|
+
server.on("upgrade", (req, socket, head) => {
|
|
850
|
+
const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
851
|
+
if (parsedUrl.pathname !== "/ws") {
|
|
852
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
853
|
+
socket.destroy();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!originAllowed(req.headers.origin ?? "", req.headers.host ?? "")) {
|
|
857
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
858
|
+
socket.destroy();
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (authRequired && !webUiTokensEqual(webUiExtractAuthToken(req, parsedUrl), token)) {
|
|
862
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
863
|
+
socket.destroy();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
868
|
+
// Single active client policy: newer tab takes over and prior tabs are disconnected.
|
|
869
|
+
for (const existing of sockets) {
|
|
870
|
+
try {
|
|
871
|
+
existing.send(
|
|
872
|
+
JSON.stringify({
|
|
873
|
+
kind: "bridge-error",
|
|
874
|
+
message: "Another tab took over this session",
|
|
875
|
+
}),
|
|
876
|
+
);
|
|
877
|
+
existing.close(1012, "Replaced by newer client tab");
|
|
878
|
+
} catch {}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
sockets.add(ws);
|
|
882
|
+
ws.on("close", () => {
|
|
883
|
+
sockets.delete(ws);
|
|
884
|
+
});
|
|
885
|
+
ws.on("ws-error", (err) => {
|
|
886
|
+
webUiLogger.warning("WebUI socket error", {
|
|
887
|
+
message: webUiFormatError(err),
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
let bucketStart = Date.now();
|
|
892
|
+
let count = 0;
|
|
893
|
+
const inboundLimit = 5000;
|
|
894
|
+
|
|
895
|
+
ws.on("message", async (raw) => {
|
|
896
|
+
const now = Date.now();
|
|
897
|
+
if (now - bucketStart > 60000) {
|
|
898
|
+
bucketStart = now;
|
|
899
|
+
count = 0;
|
|
900
|
+
}
|
|
901
|
+
count += 1;
|
|
902
|
+
if (count > inboundLimit) {
|
|
903
|
+
webUiLogger.warning("WebUI inbound rate limit exceeded", {
|
|
904
|
+
count,
|
|
905
|
+
limit: inboundLimit,
|
|
906
|
+
});
|
|
907
|
+
ws.close(1008, "Rate limit exceeded");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
let packet;
|
|
912
|
+
try {
|
|
913
|
+
packet = JSON.parse(String(raw));
|
|
914
|
+
} catch {
|
|
915
|
+
ws.send(
|
|
916
|
+
JSON.stringify({
|
|
917
|
+
kind: "bridge-error",
|
|
918
|
+
message: "Invalid payload",
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
try {
|
|
925
|
+
if (packet?.kind === "message-from-view") {
|
|
926
|
+
const payload = packet.payload;
|
|
927
|
+
if (!payload || typeof payload.type !== "string") return;
|
|
928
|
+
await webUiDispatchMessageFromView(bridgeWindow, context, payload);
|
|
929
|
+
if (payload.type === "ready") {
|
|
930
|
+
broadcast({
|
|
931
|
+
kind: "message-for-view",
|
|
932
|
+
payload: {
|
|
933
|
+
type: "ipc-broadcast",
|
|
934
|
+
method: "client-status-changed",
|
|
935
|
+
sourceClientId: null,
|
|
936
|
+
version: 1,
|
|
937
|
+
params: { status: "connected" },
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (packet?.kind === "worker-message-from-view") {
|
|
945
|
+
if (typeof packet.workerId !== "string" || packet.workerId.length === 0) return;
|
|
946
|
+
await webUiInvokeElectronBridgeMethod(bridgeWindow, "sendWorkerMessageFromView", [
|
|
947
|
+
packet.workerId,
|
|
948
|
+
packet.payload,
|
|
949
|
+
]);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (packet?.kind === "trigger-sentry-test") {
|
|
954
|
+
await webUiInvokeElectronBridgeMethod(bridgeWindow, "triggerSentryTestError", []);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
} catch (err) {
|
|
958
|
+
webUiLogger.warning("WebUI bridge dispatch failed", {
|
|
959
|
+
message: webUiFormatError(err),
|
|
960
|
+
});
|
|
961
|
+
ws.send(
|
|
962
|
+
JSON.stringify({
|
|
963
|
+
kind: "bridge-error",
|
|
964
|
+
message: "Bridge dispatch failed",
|
|
965
|
+
}),
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
await new Promise((resolve, reject) => {
|
|
973
|
+
server.once("error", reject);
|
|
974
|
+
server.listen(webUiOptions.port, host, () => {
|
|
975
|
+
const address = server.address();
|
|
976
|
+
if (typeof address === "object" && address && "port" in address) {
|
|
977
|
+
webUiOptions.port = address.port;
|
|
978
|
+
}
|
|
979
|
+
server.off("error", reject);
|
|
980
|
+
resolve();
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
webUiLogger.info("WebUI bridge started", {
|
|
985
|
+
host,
|
|
986
|
+
port: webUiOptions.port,
|
|
987
|
+
authRequired,
|
|
988
|
+
originAllowlist: [...originAllowlist],
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
if (authRequired) {
|
|
992
|
+
webUiLogger.info("WebUI access token", { token });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return {
|
|
996
|
+
host,
|
|
997
|
+
port: webUiOptions.port,
|
|
998
|
+
token: authRequired ? token : "",
|
|
999
|
+
dispose: async () => {
|
|
1000
|
+
wss.close();
|
|
1001
|
+
for (const ws of sockets) {
|
|
1002
|
+
try {
|
|
1003
|
+
ws.close(1001, "Server shutting down");
|
|
1004
|
+
} catch {}
|
|
1005
|
+
}
|
|
1006
|
+
sockets.clear();
|
|
1007
|
+
await new Promise((resolve) => {
|
|
1008
|
+
server.close(() => resolve());
|
|
1009
|
+
});
|
|
1010
|
+
bridgeWindow.webContents.send = originalSend;
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
let webUiRuntime = null;
|
|
1016
|
+
let webUiBridgeWindow = null;
|
|
1017
|
+
let webUiStartPromise = null;
|
|
1018
|
+
|
|
1019
|
+
async function webUiStart() {
|
|
1020
|
+
if (webUiStartPromise) return webUiStartPromise;
|
|
1021
|
+
|
|
1022
|
+
webUiStartPromise = (async () => {
|
|
1023
|
+
const primaryWindow = await waitForPrimaryWindow();
|
|
1024
|
+
if (!primaryWindow) throw new Error("Timed out waiting for primary window");
|
|
1025
|
+
|
|
1026
|
+
webUiBridgeWindow = primaryWindow;
|
|
1027
|
+
webUiForceWindowHidden(primaryWindow);
|
|
1028
|
+
|
|
1029
|
+
const preventClose = (event) => {
|
|
1030
|
+
if (!webUiAppIsQuitting) {
|
|
1031
|
+
event.preventDefault();
|
|
1032
|
+
webUiForceWindowHidden(primaryWindow);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
primaryWindow.on("close", preventClose);
|
|
1036
|
+
primaryWindow.on("minimize", () => {
|
|
1037
|
+
webUiForceWindowHidden(primaryWindow);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
webUiRuntime = await webUiStartBridgeRuntime({
|
|
1041
|
+
bridgeWindow: primaryWindow,
|
|
1042
|
+
context: null,
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
return webUiRuntime;
|
|
1046
|
+
})();
|
|
1047
|
+
|
|
1048
|
+
return webUiStartPromise;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
app.on("browser-window-created", (_event, win) => {
|
|
1052
|
+
if (!webUiOptions.enabled) return;
|
|
1053
|
+
if (win && !win.isDestroyed()) {
|
|
1054
|
+
webUiForceWindowHidden(win);
|
|
1055
|
+
win.once("ready-to-show", () => {
|
|
1056
|
+
webUiForceWindowHidden(win);
|
|
1057
|
+
});
|
|
1058
|
+
setImmediate(() => {
|
|
1059
|
+
webUiForceWindowHidden(win);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
app.whenReady().then(() => {
|
|
1065
|
+
webUiStart().catch((err) => {
|
|
1066
|
+
webUiLogger.warning("WebUI runtime start failed", {
|
|
1067
|
+
message: webUiFormatError(err),
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
app.on("before-quit", () => {
|
|
1073
|
+
webUiAppIsQuitting = true;
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
app.on("activate", () => {
|
|
1077
|
+
if (!webUiOptions.enabled) return;
|
|
1078
|
+
const win =
|
|
1079
|
+
webUiBridgeWindow ??
|
|
1080
|
+
BrowserWindow.getAllWindows().find((candidate) => candidate && !candidate.isDestroyed());
|
|
1081
|
+
if (win && !win.isDestroyed()) {
|
|
1082
|
+
webUiForceWindowHidden(win);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
app.on("will-quit", () => {
|
|
1087
|
+
if (webUiRuntime && typeof webUiRuntime.dispose === "function") {
|
|
1088
|
+
webUiRuntime.dispose().catch((err) => {
|
|
1089
|
+
webUiLogger.warning("WebUI shutdown failed", {
|
|
1090
|
+
message: webUiFormatError(err),
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
webUiRuntime = null;
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
})();
|
|
1097
|
+
JS
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
apply_main_chunk() {
|
|
1101
|
+
local main_file="$1"
|
|
1102
|
+
local chunk_file="$2"
|
|
1103
|
+
|
|
1104
|
+
node - "$main_file" "$chunk_file" <<'NODE'
|
|
1105
|
+
const fs = require("node:fs");
|
|
1106
|
+
const mainFile = process.argv[2];
|
|
1107
|
+
const chunkFile = process.argv[3];
|
|
1108
|
+
|
|
1109
|
+
const marker = "/*__CODEX_WEBUI_RUNTIME_PATCH__*/";
|
|
1110
|
+
let source = fs.readFileSync(mainFile, "utf8");
|
|
1111
|
+
if (source.includes(marker)) {
|
|
1112
|
+
process.exit(0);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const chunk = fs.readFileSync(chunkFile, "utf8");
|
|
1116
|
+
const mapIndex = source.lastIndexOf("//# sourceMappingURL=");
|
|
1117
|
+
if (mapIndex >= 0) {
|
|
1118
|
+
source = `${source.slice(0, mapIndex)}\n${chunk}\n${source.slice(mapIndex)}`;
|
|
1119
|
+
} else {
|
|
1120
|
+
source = `${source}\n${chunk}\n`;
|
|
1121
|
+
}
|
|
1122
|
+
fs.writeFileSync(mainFile, source, "utf8");
|
|
1123
|
+
NODE
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
patch_renderer_bundle() {
|
|
1127
|
+
local renderer_file="$1"
|
|
1128
|
+
node - "$renderer_file" <<'NODE'
|
|
1129
|
+
const fs = require("node:fs");
|
|
1130
|
+
const rendererFile = process.argv[2];
|
|
1131
|
+
let source = fs.readFileSync(rendererFile, "utf8");
|
|
1132
|
+
|
|
1133
|
+
if (/!Array\.isArray\([A-Za-z_$][\w$]*\.roots\)/.test(source)) {
|
|
1134
|
+
process.exit(0);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Older bundle shape (kept for compatibility).
|
|
1138
|
+
const find = "if(!v)return;const M=v.roots.map(A4),A=g.current;";
|
|
1139
|
+
const replace = "if(!v||!Array.isArray(v.roots))return;const M=v.roots.map(A4),A=g.current;";
|
|
1140
|
+
if (source.includes(find)) {
|
|
1141
|
+
source = source.replace(find, replace);
|
|
1142
|
+
fs.writeFileSync(rendererFile, source, "utf8");
|
|
1143
|
+
process.exit(0);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Newer bundle shape where minified variable names change between builds.
|
|
1147
|
+
const generic = /if\(!([A-Za-z_$][\w$]*)\)return;const ([A-Za-z_$][\w$]*)=\1\.roots\.map\(([A-Za-z_$][\w$]*)\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\.current;/;
|
|
1148
|
+
if (generic.test(source)) {
|
|
1149
|
+
source = source.replace(
|
|
1150
|
+
generic,
|
|
1151
|
+
"if(!$1||!Array.isArray($1.roots))return;const $2=$1.roots.map($3),$4=$5.current;"
|
|
1152
|
+
);
|
|
1153
|
+
fs.writeFileSync(rendererFile, source, "utf8");
|
|
1154
|
+
process.exit(0);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Bundle shape with `const M=v.roots.map(A4),A=g.current;` and no preceding guard.
|
|
1158
|
+
const rootsMapOnly = /const ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\.roots\.map\(([A-Za-z_$][\w$]*)\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\.current;/;
|
|
1159
|
+
if (rootsMapOnly.test(source)) {
|
|
1160
|
+
source = source.replace(
|
|
1161
|
+
rootsMapOnly,
|
|
1162
|
+
"if(!$2||!Array.isArray($2.roots))return;const $1=$2.roots.map($3),$4=$5.current;"
|
|
1163
|
+
);
|
|
1164
|
+
fs.writeFileSync(rendererFile, source, "utf8");
|
|
1165
|
+
process.exit(0);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
console.error("Renderer guard patch anchor not found.");
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
NODE
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
EXTRA_ARGS=()
|
|
1174
|
+
while (($#)); do
|
|
1175
|
+
case "$1" in
|
|
1176
|
+
--app)
|
|
1177
|
+
APP_PATH="${2:?missing value}"; APP_ASAR="$APP_PATH/Contents/Resources/app.asar"; CLI_PATH="$APP_PATH/Contents/Resources/codex"; shift 2 ;;
|
|
1178
|
+
--port)
|
|
1179
|
+
PORT="${2:?missing value}"; shift 2 ;;
|
|
1180
|
+
--token)
|
|
1181
|
+
TOKEN="${2:?missing value}"; shift 2 ;;
|
|
1182
|
+
--origins)
|
|
1183
|
+
ORIGINS="${2:?missing value}"; shift 2 ;;
|
|
1184
|
+
--bridge)
|
|
1185
|
+
BRIDGE_PATH="${2:?missing value}"; shift 2 ;;
|
|
1186
|
+
--user-data-dir)
|
|
1187
|
+
USER_DATA_DIR="${2:?missing value}"; shift 2 ;;
|
|
1188
|
+
--no-open)
|
|
1189
|
+
NO_OPEN=1; shift ;;
|
|
1190
|
+
--keep-temp)
|
|
1191
|
+
KEEP_TEMP=1; shift ;;
|
|
1192
|
+
-h|--help)
|
|
1193
|
+
usage; exit 0 ;;
|
|
1194
|
+
--)
|
|
1195
|
+
shift; EXTRA_ARGS+=("$@"); break ;;
|
|
1196
|
+
*)
|
|
1197
|
+
EXTRA_ARGS+=("$1"); shift ;;
|
|
1198
|
+
esac
|
|
1199
|
+
done
|
|
1200
|
+
|
|
1201
|
+
[[ -f "$APP_ASAR" ]] || { echo "Missing app.asar: $APP_ASAR" >&2; exit 1; }
|
|
1202
|
+
[[ -x "$CLI_PATH" ]] || { echo "Missing codex binary: $CLI_PATH" >&2; exit 1; }
|
|
1203
|
+
[[ -f "$BRIDGE_PATH" ]] || { echo "Missing standalone bridge file: $BRIDGE_PATH" >&2; exit 1; }
|
|
1204
|
+
ensure_required_tools
|
|
1205
|
+
|
|
1206
|
+
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-webui-unpacked.XXXXXX")"
|
|
1207
|
+
APP_DIR="$WORKDIR/app"
|
|
1208
|
+
if [[ -z "$USER_DATA_DIR" ]]; then
|
|
1209
|
+
USER_DATA_DIR="$WORKDIR/user-data"
|
|
1210
|
+
fi
|
|
1211
|
+
|
|
1212
|
+
cleanup() {
|
|
1213
|
+
if [[ "$KEEP_TEMP" -eq 0 ]]; then
|
|
1214
|
+
rm -rf "$WORKDIR"
|
|
1215
|
+
else
|
|
1216
|
+
echo "Kept temp dir: $WORKDIR"
|
|
1217
|
+
fi
|
|
1218
|
+
}
|
|
1219
|
+
trap cleanup EXIT
|
|
1220
|
+
|
|
1221
|
+
echo "Extracting app.asar to: $APP_DIR"
|
|
1222
|
+
npx -y @electron/asar extract "$APP_ASAR" "$APP_DIR"
|
|
1223
|
+
|
|
1224
|
+
target_main_js_rel="$(sed -nE 's@.*(main-[A-Za-z0-9_-]+\.js).*@\1@p' "$APP_DIR/.vite/build/main.js" | head -n1 || true)"
|
|
1225
|
+
target_renderer_js_rel="$(sed -nE 's@.*assets/(index-[A-Za-z0-9_-]+\.js).*@\1@p' "$APP_DIR/webview/index.html" | head -n1 || true)"
|
|
1226
|
+
[[ -n "$target_main_js_rel" && -n "$target_renderer_js_rel" ]] || { echo "Failed resolving target bundle names" >&2; exit 1; }
|
|
1227
|
+
|
|
1228
|
+
MAIN_CHUNK_FILE="$WORKDIR/main-webui.chunk.js"
|
|
1229
|
+
write_main_injection_chunk "$MAIN_CHUNK_FILE"
|
|
1230
|
+
apply_main_chunk "$APP_DIR/.vite/build/$target_main_js_rel" "$MAIN_CHUNK_FILE"
|
|
1231
|
+
patch_renderer_bundle "$APP_DIR/webview/assets/$target_renderer_js_rel"
|
|
1232
|
+
|
|
1233
|
+
cp "$BRIDGE_PATH" "$APP_DIR/webview/webui-bridge.js"
|
|
1234
|
+
|
|
1235
|
+
has_pattern '__CODEX_WEBUI_RUNTIME_PATCH__' "$APP_DIR/.vite/build/$target_main_js_rel" || { echo "Patched main missing runtime marker" >&2; exit 1; }
|
|
1236
|
+
has_pattern '!Array\.isArray\([[:alnum:]_$]+\.roots\)' "$APP_DIR/webview/assets/$target_renderer_js_rel" || { echo "Patched renderer missing roots guard" >&2; exit 1; }
|
|
1237
|
+
has_pattern 'sendMessageFromView' "$APP_DIR/webview/webui-bridge.js" || { echo "Bridge file looks invalid" >&2; exit 1; }
|
|
1238
|
+
|
|
1239
|
+
CMD=(npx -y electron "--user-data-dir=$USER_DATA_DIR" "$APP_DIR" --webui --port "$PORT")
|
|
1240
|
+
if [[ -n "$TOKEN" ]]; then
|
|
1241
|
+
CMD+=(--token "$TOKEN")
|
|
1242
|
+
fi
|
|
1243
|
+
if [[ -n "$ORIGINS" ]]; then
|
|
1244
|
+
CMD+=(--origins "$ORIGINS")
|
|
1245
|
+
fi
|
|
1246
|
+
if ((${#EXTRA_ARGS[@]})); then
|
|
1247
|
+
CMD+=("${EXTRA_ARGS[@]}")
|
|
1248
|
+
fi
|
|
1249
|
+
|
|
1250
|
+
unset ELECTRON_RUN_AS_NODE
|
|
1251
|
+
export ELECTRON_FORCE_IS_PACKAGED=true
|
|
1252
|
+
export BUILD_FLAVOR=prod
|
|
1253
|
+
export NODE_ENV=production
|
|
1254
|
+
export CODEX_CLI_PATH="$CLI_PATH"
|
|
1255
|
+
export CUSTOM_CLI_PATH="$CLI_PATH"
|
|
1256
|
+
|
|
1257
|
+
echo "App dir: $APP_DIR"
|
|
1258
|
+
echo "User data dir: $USER_DATA_DIR"
|
|
1259
|
+
printf 'Command:'; printf ' %q' "${CMD[@]}"; echo
|
|
1260
|
+
|
|
1261
|
+
if [[ "$NO_OPEN" -eq 0 ]]; then
|
|
1262
|
+
(
|
|
1263
|
+
if command -v curl >/dev/null 2>&1; then
|
|
1264
|
+
for _ in {1..120}; do
|
|
1265
|
+
if curl -fsS "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
|
|
1266
|
+
open "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || true
|
|
1267
|
+
exit 0
|
|
1268
|
+
fi
|
|
1269
|
+
sleep 0.25
|
|
1270
|
+
done
|
|
1271
|
+
else
|
|
1272
|
+
sleep 1
|
|
1273
|
+
open "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || true
|
|
1274
|
+
fi
|
|
1275
|
+
) &
|
|
1276
|
+
fi
|
|
1277
|
+
|
|
1278
|
+
exec "${CMD[@]}"
|