Skip to content

Commit 7d4049b

Browse files
authored
Keep the native Linux tray alive (ilysenko#1053)
Fixes ilysenko#1052 and ilysenko#1054.
1 parent b24e5ff commit 7d4049b

5 files changed

Lines changed: 183 additions & 121 deletions

File tree

linux-features/shared-app-server-socket/test.js

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,13 @@ test("injected transport serializes startup and removes only its owned socket",
303303
const servers = new Map();
304304
const children = [];
305305
let replacement;
306-
let installReplacementBeforeClose = false;
306+
let replacementError;
307+
let installReplacementBeforeChildClose = false;
307308
const identityFs = {
308309
...fs,
309310
lstatSync(candidate, ...args) {
310311
const stat = fs.lstatSync(candidate, ...args);
311-
if (candidate !== socketPath || !installReplacementBeforeClose) return stat;
312+
if (candidate !== socketPath || !installReplacementBeforeChildClose) return stat;
312313
return new Proxy(stat, {
313314
get(target, property, receiver) {
314315
if (property === "ino") return target.ino + 1;
@@ -329,9 +330,19 @@ test("injected transport serializes startup and removes only its owned socket",
329330
child.kill = () => {
330331
child.killed = true;
331332
child.signalCode = "SIGTERM";
332-
server.close(async () => {
333-
if (installReplacementBeforeClose) replacement = await listenUnix(target);
334-
child.emit("close", null, "SIGTERM");
333+
server.close(() => {
334+
setImmediate(() => {
335+
Promise.resolve()
336+
.then(async () => {
337+
if (installReplacementBeforeChildClose) replacement = await listenUnix(target);
338+
})
339+
.catch((error) => {
340+
replacementError = error;
341+
})
342+
.finally(() => {
343+
child.emit("close", null, "SIGTERM");
344+
});
345+
});
335346
});
336347
return true;
337348
};
@@ -348,10 +359,11 @@ test("injected transport serializes startup and removes only its owned socket",
348359
assert.equal(fs.existsSync(`${socketPath}.lock`), true);
349360
await assert.rejects(second.ensureAuthority(), /already owned/);
350361

351-
installReplacementBeforeClose = true;
362+
installReplacementBeforeChildClose = true;
352363
const childClosed = once(children[0], "close");
353364
first.dispose();
354365
await childClosed;
366+
assert.ifError(replacementError);
355367
assert.equal(fs.lstatSync(socketPath).isSocket(), true, "replacement socket must survive dispose");
356368
await closeServer(replacement);
357369
} finally {

scripts/lib/package-common.sh

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ resolve_package_icon_source() {
248248
while IFS= read -r -d '' candidate; do
249249
candidates+=("$candidate")
250250
done < <(
251-
find "$icon_dir" -maxdepth 1 -type f -name '*.png' ! -name '*-tray.png' -print0 |
251+
find "$icon_dir" -maxdepth 1 -type f -name '*.png' -print0 |
252252
sort -z
253253
)
254254
fi
@@ -744,7 +744,6 @@ stage_common_package_files() {
744744
cp -aT "$APP_DIR" "$app_root"
745745
mkdir -p "$app_root/.codex-linux"
746746
cp "$ICON_SOURCE" "$app_root/.codex-linux/$PACKAGE_NAME.png"
747-
cp "$(resolve_tray_icon_source "$app_root")" "$app_root/.codex-linux/$PACKAGE_NAME-tray.png"
748747
cp "$REPO_DIR/launcher/cli-launch-path.py" "$app_root/.codex-linux/cli-launch-path.py"
749748
render_desktop_entry_doctor_helper "$app_root/.codex-linux/codex-desktop-entry-doctor.sh"
750749
render_desktop_entry "$root/usr/share/applications/$PACKAGE_NAME.desktop"
@@ -763,31 +762,6 @@ stage_common_package_files() {
763762
render_packaged_runtime_helper "$app_root/.codex-linux/codex-packaged-runtime.sh"
764763
}
765764

766-
resolve_tray_icon_source() {
767-
local app_root="$1"
768-
local assets_dir="$app_root/content/webview/assets"
769-
local -a candidates=()
770-
local candidate
771-
772-
if [ -d "$assets_dir" ]; then
773-
while IFS= read -r -d '' candidate; do
774-
candidates+=("$candidate")
775-
done < <(find "$assets_dir" -maxdepth 1 -type f -name 'app-*.png' -print0 | sort -z)
776-
fi
777-
778-
if [ "${#candidates[@]}" -eq 1 ]; then
779-
printf '%s\n' "${candidates[0]}"
780-
return 0
781-
fi
782-
783-
if [ "${#candidates[@]}" -gt 1 ]; then
784-
warn "Multiple tray icon candidates found in $assets_dir; falling back to package icon"
785-
else
786-
warn "Could not resolve a unique tray icon in $assets_dir; falling back to package icon"
787-
fi
788-
printf '%s\n' "$ICON_SOURCE"
789-
}
790-
791765
stage_update_builder_bundle() {
792766
local root="$1"
793767
local update_builder_root="$root/opt/$PACKAGE_NAME/update-builder"

scripts/patch-linux-window-ui.test.js

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,9 +1215,10 @@ test("subagent nickname metadata descriptor follows upstream metadata bundle nam
12151215

12161216
function trayBundleFixture() {
12171217
return [
1218-
"async function fae(e){let t=await pae(e.buildFlavor,e.appBrand,e.repoRoot),r=new n.Tray(t.defaultIcon);r.setToolTip(n.app.getName());let i=new pb(r);return i}",
1219-
"async function pae(e,t,r){if(process.platform===`darwin`)return null;if(process.platform===`linux`){let e=n.nativeImage.createFromPath(`tray.png`);return{defaultIcon:e,chronicleRunningIcon:null}}return null}",
1220-
"var pb=class{trayMenuThreads={runningThreads:[],unreadThreads:[],pinnedThreads:[],recentThreads:[],usageLimits:[]};constructor(e={on(){},setContextMenu(){}}){this.tray=e;if(process.platform===`linux`){this.tray.on(`click`,()=>{}),this.updatePersistentTrayMenu();return}}getNativeTrayMenuItems(){return[]}updatePersistentTrayMenu(){process.platform===`linux`&&this.tray.setContextMenu(n.Menu.buildFromTemplate(this.getNativeTrayMenuItems()))}}",
1218+
"async function gj(e){let t=e;if(typeof t.whenReady!=`function`)return process.platform!==`linux`;try{return await t.whenReady(),!0}catch{return!1}}function _j(e){let t=e;return typeof t.isReady==`function`?t.isReady():process.platform!==`linux`}",
1219+
"async function fae(e){let t=await pae(e.buildFlavor,e.appBrand,e.repoRoot),r=new c.Tray(t.defaultIcon);r.setToolTip(c.app.getName());let i=new pb(r);return!await i.waitForReady()?(i.destroy(),null):i}",
1220+
"async function pae(e,t,n){if(process.platform===`darwin`)return null;if(process.platform===`linux`){let r=`${fv(e,t)}.png`,i=c.nativeImage.createFromPath(c.app.isPackaged?(0,u.join)(process.resourcesPath,r):(0,u.join)(n,`electron`,`src`,`icons`,r));if(i.isEmpty())throw Error(`Linux tray application icon is unavailable`);return{defaultIcon:i.resize({width:V9,height:V9,quality:`best`}),chronicleRunningIcon:null}}return null}",
1221+
"var pb=class{trayMenuThreads={runningThreads:[],unreadThreads:[],pinnedThreads:[],recentThreads:[],usageLimits:[]};constructor(e={on(){},setContextMenu(){}}){this.tray=e;if(process.platform===`linux`){this.tray.on(`click`,()=>{}),this.updatePersistentTrayMenu();return}}destroy(){this.tray.destroy()}isReady(){return _j(this.tray)}waitForReady(){return gj(this.tray)}getNativeTrayMenuItems(){return[]}updatePersistentTrayMenu(){process.platform===`linux`&&this.tray.setContextMenu(c.Menu.buildFromTemplate(this.getNativeTrayMenuItems()))}}",
12211222
"v&&k.on(`close`,e=>{this.persistPrimaryWindowBounds(k);let t=this.getPrimaryWindows().some(e=>e!==k);if((process.platform===`win32`||process.platform===`linux`)&&!this.isAppQuitting&&this.options.canHideLastWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}if(process.platform===`darwin`&&!this.isAppQuitting&&!t){e.preventDefault(),k.hide()}});",
12221223
"let oe=async()=>{try{await fae({appBrand:a.U(),buildFlavor:b,repoRoot:j.repoRoot})}catch(e){v.reportNonFatal(e)}};(E||process.platform===`linux`)&&oe();",
12231224
].join("");
@@ -2403,18 +2404,19 @@ test("adds the Linux quit guard for the current interleaved bundler prelude", ()
24032404

24042405
test("destroys the registered Linux tray before the app exits", () => {
24052406
const source = `${currentMainBundlePrefix}${trayBundleFixture()}`;
2407+
const iconPathExpression = "process.resourcesPath+`/../content/webview/assets/app-test.png`";
24062408
const patched = applyPatchTwice(
24072409
applyLinuxTrayPatch,
24082410
applyLinuxQuitGuardPatch(source),
2409-
null,
2411+
iconPathExpression,
24102412
);
24112413

24122414
assert.match(patched, /codexLinuxRegisterTray=e=>\(codexLinuxTray=e,e\)/);
24132415
assert.match(patched, /codexLinuxDestroyTray=\(\)=>\{if\(process\.platform!==`linux`\)return;/);
24142416
assert.match(patched, /codexLinuxTray=null;try\{e\?\.destroy\(\)\}catch\{\}/);
24152417
assert.match(patched, /codexLinuxMarkQuitInProgress=\(\)=>\{codexLinuxQuitInProgress=!0,codexLinuxDestroyTray\(\)\}/);
24162418
assert.match(patched, /c\.app\.on\(`before-quit`,\(\)=>codexLinuxDestroyTray\(\)\)/);
2417-
assert.match(patched, /r=typeof codexLinuxRegisterTray===`function`\?codexLinuxRegisterTray\(new n\.Tray\(t\.defaultIcon\)\):new n\.Tray\(t\.defaultIcon\)/);
2419+
assert.match(patched, /r=typeof codexLinuxRegisterTray===`function`\?codexLinuxRegisterTray\(new c\.Tray\(t\.defaultIcon\)\):new c\.Tray\(t\.defaultIcon\)/);
24182420
assert.doesNotMatch(patched, /codexLinuxTrayQuitDelayMs/);
24192421

24202422
const helperStart = patched.indexOf("let codexLinuxTray=null");
@@ -2427,6 +2429,104 @@ test("destroys the registered Linux tray before the app exits", () => {
24272429
assert.equal(runDestroy({ platform: "linux" }), 1);
24282430
});
24292431

2432+
test("accepts stock Electron tray readiness and falls back to the Linux app icon", async () => {
2433+
const iconPathExpression = "process.resourcesPath+`/../content/webview/assets/app-test.png`";
2434+
const source = `${currentMainBundlePrefix}${trayBundleFixture()}`;
2435+
const patched = applyPatchTwice(applyLinuxTrayPatch, source, iconPathExpression);
2436+
2437+
assert.match(
2438+
patched,
2439+
/if\(typeof t\.whenReady!=`function`\)return!0;try\{return await t\.whenReady\(\),!0\}catch\{return!1\}/,
2440+
);
2441+
assert.match(
2442+
patched,
2443+
/return typeof t\.isReady==`function`\?t\.isReady\(\):!0/,
2444+
);
2445+
assert.match(
2446+
patched,
2447+
new RegExp(
2448+
`let __codexLinuxTrayFallbackIcon=c\\.nativeImage\\.createFromPath\\(${escapeRegExp(iconPathExpression)}\\)`,
2449+
),
2450+
);
2451+
assert.match(
2452+
patched,
2453+
/if\(!__codexLinuxTrayFallbackIcon\.isEmpty\(\)\)i=__codexLinuxTrayFallbackIcon/,
2454+
);
2455+
2456+
const readinessHelpers = patched.match(
2457+
/async function gj\(e\)\{let t=e;[^]*?\}function _j\(e\)\{let t=e;[^}]+\}/,
2458+
)?.[0];
2459+
assert.ok(readinessHelpers);
2460+
const context = { process: { platform: "linux" }, result: null };
2461+
await vm.runInNewContext(
2462+
`${readinessHelpers};result=(async()=>({stockWait:await gj({}),stockReady:_j({}),nativeWait:await gj({whenReady:async()=>{}}),nativeReady:_j({isReady:()=>!1}),failedWait:await gj({whenReady:async()=>{throw Error(\`not ready\`)}})}))()`,
2463+
context,
2464+
);
2465+
assert.deepEqual(
2466+
JSON.parse(JSON.stringify(await context.result)),
2467+
{
2468+
stockWait: true,
2469+
stockReady: true,
2470+
nativeWait: true,
2471+
nativeReady: false,
2472+
failedWait: false,
2473+
},
2474+
);
2475+
2476+
const iconLoaderStart = patched.indexOf("async function pae(");
2477+
const iconLoaderEnd = patched.indexOf("var pb=class", iconLoaderStart);
2478+
assert.notEqual(iconLoaderStart, -1);
2479+
assert.notEqual(iconLoaderEnd, -1);
2480+
const iconLoaderSource = patched.slice(iconLoaderStart, iconLoaderEnd);
2481+
const iconCalls = [];
2482+
const iconContext = {
2483+
process: { platform: "linux", resourcesPath: "/resources" },
2484+
upstreamEmpty: true,
2485+
iconCalls,
2486+
c: {
2487+
app: { isPackaged: true },
2488+
nativeImage: {
2489+
createFromPath(iconPath) {
2490+
iconCalls.push(iconPath);
2491+
const fallback = iconPath.includes("content/webview/assets/app-test.png");
2492+
return {
2493+
isEmpty: () => fallback ? false : iconContext.upstreamEmpty,
2494+
resize: () => ({ source: fallback ? "fallback" : "upstream" }),
2495+
};
2496+
},
2497+
},
2498+
},
2499+
u: path,
2500+
fv: () => "icon-chatgpt",
2501+
V9: 16,
2502+
result: null,
2503+
};
2504+
await vm.runInNewContext(
2505+
`${iconLoaderSource};result=pae(\`prod\`,\`chatgpt\`,\`/repo\`)`,
2506+
iconContext,
2507+
);
2508+
assert.deepEqual(JSON.parse(JSON.stringify(await iconContext.result)), {
2509+
defaultIcon: { source: "fallback" },
2510+
chronicleRunningIcon: null,
2511+
});
2512+
assert.deepEqual(iconCalls, [
2513+
"/resources/icon-chatgpt.png",
2514+
"/resources/../content/webview/assets/app-test.png",
2515+
]);
2516+
2517+
iconContext.upstreamEmpty = false;
2518+
iconCalls.length = 0;
2519+
await vm.runInNewContext(
2520+
`${iconLoaderSource};result=pae(\`prod\`,\`chatgpt\`,\`/repo\`)`,
2521+
iconContext,
2522+
);
2523+
assert.deepEqual(JSON.parse(JSON.stringify(await iconContext.result)), {
2524+
defaultIcon: { source: "upstream" },
2525+
chronicleRunningIcon: null,
2526+
});
2527+
assert.deepEqual(iconCalls, ["/resources/icon-chatgpt.png"]);
2528+
});
2529+
24302530
test("bypasses the upstream before-quit confirmation after a Linux explicit quit", () => {
24312531
const source = `${currentMainBundlePrefix}${beforeQuitConfirmationBundleFixture()}`;
24322532
const patched = applyPatchTwice(

scripts/patches/impl/main-process/tray.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const { requireName } = require("../../lib/minified-js.js");
44

55
function applyLinuxTrayPatch(currentSource, iconPathExpression) {
66
let patchedSource = currentSource;
7-
void iconPathExpression;
87

98
const closeToTrayPattern =
109
/if\(\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&!this\.isAppQuitting&&this\.options\.canHideLastWindowToTray\?\.\(\)===!0&&!([A-Za-z_$][\w$]*)\)\{([A-Za-z_$][\w$]*)\.preventDefault\(\),([A-Za-z_$][\w$]*)\.hide\(\);return\}/;
@@ -23,6 +22,54 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) {
2322
);
2423
}
2524

25+
const trayWhenReadyFallbackPattern =
26+
/if\(typeof ([A-Za-z_$][\w$]*)\.whenReady!=`function`\)return process\.platform!==`linux`;try\{return await \1\.whenReady\(\),!0\}catch\{return!1\}/;
27+
const compatibleTrayWhenReadyPattern =
28+
/if\(typeof ([A-Za-z_$][\w$]*)\.whenReady!=`function`\)return!0;try\{return await \1\.whenReady\(\),!0\}catch\{return!1\}/;
29+
if (!compatibleTrayWhenReadyPattern.test(patchedSource)) {
30+
if (!trayWhenReadyFallbackPattern.test(patchedSource)) {
31+
console.warn("WARN: Could not find current Linux tray whenReady fallback — skipping Linux tray compatibility patch");
32+
return currentSource;
33+
}
34+
patchedSource = patchedSource.replace(
35+
trayWhenReadyFallbackPattern,
36+
"if(typeof $1.whenReady!=`function`)return!0;try{return await $1.whenReady(),!0}catch{return!1}",
37+
);
38+
}
39+
40+
const trayIsReadyFallbackPattern =
41+
/return typeof ([A-Za-z_$][\w$]*)\.isReady==`function`\?\1\.isReady\(\):process\.platform!==`linux`/;
42+
const compatibleTrayIsReadyPattern =
43+
/return typeof ([A-Za-z_$][\w$]*)\.isReady==`function`\?\1\.isReady\(\):!0/;
44+
if (!compatibleTrayIsReadyPattern.test(patchedSource)) {
45+
if (!trayIsReadyFallbackPattern.test(patchedSource)) {
46+
console.warn("WARN: Could not find current Linux tray isReady fallback — skipping Linux tray compatibility patch");
47+
return currentSource;
48+
}
49+
patchedSource = patchedSource.replace(
50+
trayIsReadyFallbackPattern,
51+
"return typeof $1.isReady==`function`?$1.isReady():!0",
52+
);
53+
}
54+
55+
if (
56+
iconPathExpression != null &&
57+
!patchedSource.includes("let __codexLinuxTrayFallbackIcon=")
58+
) {
59+
const linuxTrayIconPattern =
60+
/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\.nativeImage\.createFromPath\(([^;]+)\);if\(\1\.isEmpty\(\)\)throw Error\(`Linux tray application icon is unavailable`\)/;
61+
const match = patchedSource.match(linuxTrayIconPattern);
62+
if (match == null) {
63+
console.warn("WARN: Could not find current Linux tray icon loader — skipping Linux tray compatibility patch");
64+
return currentSource;
65+
}
66+
const [iconLoader, imageVar, electronVar, upstreamIconPath] = match;
67+
patchedSource = patchedSource.replace(
68+
iconLoader,
69+
`${imageVar}=${electronVar}.nativeImage.createFromPath(${upstreamIconPath});if(${imageVar}.isEmpty()){let __codexLinuxTrayFallbackIcon=${electronVar}.nativeImage.createFromPath(${iconPathExpression});if(!__codexLinuxTrayFallbackIcon.isEmpty())${imageVar}=__codexLinuxTrayFallbackIcon}if(${imageVar}.isEmpty())throw Error(\`Linux tray application icon is unavailable\`)`,
70+
);
71+
}
72+
2673
if (!patchedSource.includes("codexLinuxRegisterTray(")) {
2774
const trayConstructorPattern =
2875
/([A-Za-z_$][\w$]*)=new ([A-Za-z_$][\w$]*)\.Tray\(([^;)]+)\)/;

0 commit comments

Comments
 (0)