impel-cli 0.20.48 → 0.20.50

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.50 — Restore ChatGPT Tasks safely
4
+
5
+ - Restores one embedded Tasks entry in the managed ChatGPT sidebar while
6
+ keeping Claude's production preload inert and preserving normal ChatGPT
7
+ startup and navigation.
8
+ - Disables the native fallback in the production ChatGPT profile, so Tasks
9
+ cannot create a second control view or cover the vendor app during startup.
10
+ - Debounces sidebar discovery, makes attached-control checks constant-time,
11
+ and removes the outer reinjection retry chain that could repeatedly scan the
12
+ renderer while its sidebar anchor was late.
13
+ - Injects and accepts Tasks navigation only in the main vendor frame; subframes
14
+ cannot open, resize, or bootstrap the authenticated board.
15
+ - Deduplicates paired Electron navigation events and reuses the loaded board,
16
+ keeping one tenant-bound credential exchange across ordinary hide/reopen
17
+ cycles while allowing an expired or failed board session to recover.
18
+ - Advances the managed profile contract to v41 so existing macOS ChatGPT apps
19
+ rewrite and reload the repaired preload without rebuilding vendor bundles.
20
+ - Adds a known-bad `0.20.48` control and a real packaged-app gate with screenshot
21
+ artifacts for the normal surface, one Tasks tab, and the interactive board;
22
+ it also externally observes the board target becoming compositor-hidden when
23
+ host navigation is restored, with zero fallback controls and one bootstrap.
24
+
25
+ ## 0.20.49 — Restore managed app startup
26
+
27
+ - Restores normal ChatGPT and Claude startup by disabling the experimental
28
+ embedded Tasks UI in managed vendor apps. The fallback's Electron window and
29
+ DOM-injection paths no longer run in production.
30
+ - Retains tenant-bound Tasks through the CLI and MCP while the desktop entry
31
+ point is redesigned against an explicitly supported vendor integration seam.
32
+ - Makes every production-generated main preload an exact inert stub. The
33
+ reviewed prototype remains test-only and cannot be enabled by a managed app
34
+ environment variable.
35
+ - Advances the managed profile contract to v40 so every existing macOS app
36
+ rewrites and reloads the corrected external Tasks preload. Vendor pins and
37
+ built bundle bytes remain unchanged.
38
+ - Adds a production-default contract proving the embedded path is inert, while
39
+ retaining deterministic coverage for the prototype behind its opt-in. The
40
+ pinned managed-app smoke also requires the normal working surface, the exact
41
+ inert preload bytes, zero injected controls, and zero Tasks web requests.
42
+
3
43
  ## 0.20.48 — Confine Tasks to the primary app window
4
44
 
5
45
  - Fixes the remaining `0.20.47` regression that let an auxiliary ChatGPT or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.48",
3
+ "version": "0.20.50",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -660,9 +660,12 @@ export function appPaths(homeDir = os.homedir(), tenantId = null, {
660
660
  };
661
661
  }
662
662
 
663
- function writeDesktopTasksProfileAssets(root, appUrl) {
663
+ function writeDesktopTasksProfileAssets(root, appUrl, { enableEmbed = false } = {}) {
664
664
  const assets = desktopTasksAssetPaths(root);
665
- writeAtomic(assets.mainPreload, desktopTasksMainPreload(appUrl), 0o600);
665
+ writeAtomic(assets.mainPreload, desktopTasksMainPreload(appUrl, {
666
+ enableEmbed,
667
+ enableNativeFallback: false,
668
+ }), 0o600);
666
669
  writeAtomic(assets.viewPreload, desktopTasksViewPreload(), 0o600);
667
670
  // This exact path is Impel-owned. Remove the former local MCP Apps host when
668
671
  // a profile is regenerated; old released clients keep the immutable server
@@ -879,6 +882,7 @@ export function installManagedAppFiles({
879
882
  writeDesktopTasksProfileAssets(
880
883
  paths[target].root,
881
884
  config.appUrl || RUNTIME_BRAND.controlPlane.defaultOrigin,
885
+ { enableEmbed: target === "chatgpt" },
882
886
  );
883
887
  // Non-bundle targets are the background-refresh / fast-open path: configs,
884
888
  // token helper, catalog, and manifest only. Bundle swaps require the app
@@ -87,6 +87,7 @@ export function parseDesktopTasksCredential(output, tenantId, {
87
87
 
88
88
  const NAV_ANCHOR_RULES_SOURCE = JSON.stringify(DESKTOP_TASKS_NAV_ANCHOR_RULES);
89
89
  const TASKS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M9 3v18"></path><path d="M15 3v18"></path></svg>';
90
+ const NAVIGATION_NONCE_PLACEHOLDER = "IMPEL_DESKTOP_TASKS_NAVIGATION_NONCE_PLACEHOLDER";
90
91
 
91
92
  const NAVIGATION_SOURCE = String.raw`(() => {
92
93
  const NAV_ID = "impel-desktop-tasks-nav";
@@ -103,6 +104,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
103
104
  let slot = null;
104
105
  let active = false;
105
106
  let lastAnchor = null;
107
+ let readySignalled = false;
108
+ let ensureTimer = null;
109
+ const signalSequenceKey = "__impelDesktopTasksSignalSequence";
110
+ const navigationNonce = ${JSON.stringify(NAVIGATION_NONCE_PLACEHOLDER)};
106
111
  const roots = () => {
107
112
  const result = [document];
108
113
  for (let index = 0; index < result.length; index += 1) {
@@ -156,6 +161,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
156
161
  };
157
162
  const signal = (action) => {
158
163
  const query = new URLSearchParams(Object.entries(mainBounds()).map(([key, value]) => [key, String(value)]));
164
+ const signalSequence = Number(window[signalSequenceKey] || 0) + 1;
165
+ window[signalSequenceKey] = signalSequence;
166
+ query.set("event", Date.now().toString(36) + "-" + signalSequence.toString(36));
167
+ query.set("nonce", navigationNonce);
159
168
  location.href = "impel-tasks://" + action + "?" + query;
160
169
  };
161
170
  const select = (selected) => {
@@ -209,6 +218,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
209
218
  return button;
210
219
  };
211
220
  const ensureNav = () => {
221
+ if (nav?.isConnected) {
222
+ select(active);
223
+ return;
224
+ }
212
225
  let rule = null;
213
226
  let anchor = null;
214
227
  for (const candidate of RULES) {
@@ -233,6 +246,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
233
246
  slot = nav;
234
247
  }
235
248
  select(active);
249
+ if (nav?.isConnected && !readySignalled) {
250
+ readySignalled = true;
251
+ setTimeout(() => signal("ready"), 0);
252
+ }
236
253
  };
237
254
  const style = document.createElement("style");
238
255
  style.textContent = "#" + NAV_ID + " svg{width:16px;height:16px;flex:0 0 auto}#" + NAV_ID + "." + ACTIVE_CLASS + "{background:color-mix(in srgb,CanvasText 10%,transparent)}#" + NAV_ID + ":focus-visible{outline:2px solid #7c8cff;outline-offset:2px}";
@@ -248,11 +265,19 @@ const NAVIGATION_SOURCE = String.raw`(() => {
248
265
  const onResize = () => { if (active) signal("layout"); };
249
266
  window.addEventListener("click", onClick, true);
250
267
  window.addEventListener("resize", onResize);
251
- const observer = new MutationObserver(() => ensureNav());
268
+ const scheduleEnsure = () => {
269
+ if (ensureTimer !== null) return;
270
+ ensureTimer = setTimeout(() => {
271
+ ensureTimer = null;
272
+ ensureNav();
273
+ }, 250);
274
+ };
275
+ const observer = new MutationObserver(scheduleEnsure);
252
276
  observer.observe(document.body, { childList: true, subtree: true });
253
277
  ensureNav();
254
278
  window.__impelDesktopTasksCleanup = () => {
255
279
  observer.disconnect();
280
+ if (ensureTimer !== null) clearTimeout(ensureTimer);
256
281
  window.removeEventListener("click", onClick, true);
257
282
  window.removeEventListener("resize", onResize);
258
283
  nav?.remove();
@@ -260,13 +285,22 @@ const NAVIGATION_SOURCE = String.raw`(() => {
260
285
  style.remove();
261
286
  delete window.__impelDesktopTasksSetActive;
262
287
  };
263
- return { anchor: lastAnchor ? label(lastAnchor) : null, connected: Boolean(nav?.isConnected) };
288
+ const shellControls = controls().filter((element) => visible(element) && Boolean(label(element)));
289
+ return {
290
+ anchor: lastAnchor ? label(lastAnchor) : null,
291
+ connected: Boolean(nav?.isConnected),
292
+ shellReady: shellControls.length >= 2,
293
+ };
264
294
  })();`;
265
295
 
266
296
  const NATIVE_NAVIGATION_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none';script-src 'unsafe-inline';style-src 'unsafe-inline'"><style>:root{color-scheme:light dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}button{width:100%;height:100%;display:flex;align-items:center;gap:9px;border:0;border-radius:8px;padding:0 12px;background:color-mix(in srgb,Canvas 92%,CanvasText 8%);color:CanvasText;cursor:pointer}svg{width:16px;height:16px}</style></head><body><button id="tasks" type="button" aria-label="Tasks">${TASKS_ICON}<span>Tasks</span></button><script>const button=document.getElementById("tasks");button.addEventListener("click",()=>{button.dataset.active="true";window.impelDesktopTasks.show()});window.__impelDesktopTasksSetActive=active=>{button.dataset.active=String(Boolean(active))}</script></body></html>`;
267
297
 
268
- export function desktopTasksMainPreload(appUrl) {
298
+ export function desktopTasksMainPreload(appUrl, {
299
+ enableEmbed = false,
300
+ enableNativeFallback = false,
301
+ } = {}) {
269
302
  const appOrigin = desktopTasksAppOrigin(appUrl);
303
+ if (!enableEmbed) return `"use strict";\n`;
270
304
  const parserSource = parseDesktopTasksCredential.toString();
271
305
  const escapeRegexSource = escapeRegex.toString();
272
306
  return `"use strict";
@@ -283,12 +317,16 @@ if (
283
317
  const fs = require("node:fs");
284
318
  const path = require("node:path");
285
319
  const { spawn } = require("node:child_process");
320
+ const { randomBytes } = require("node:crypto");
286
321
  const navSource = ${JSON.stringify(NAVIGATION_SOURCE)};
322
+ const navigationNoncePlaceholder = ${JSON.stringify(NAVIGATION_NONCE_PLACEHOLDER)};
287
323
  const nativeNavUrl = "data:text/html;charset=utf-8," + encodeURIComponent(${JSON.stringify(NATIVE_NAVIGATION_HTML)});
288
324
  const appOrigin = ${JSON.stringify(appOrigin)};
289
325
  const bootstrapUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOOTSTRAP_PATH)};
290
326
  const tasksUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOARD_PATH)};
291
327
  const readyMarker = ${JSON.stringify(DESKTOP_TASKS_READY_MARKER)};
328
+ const nativeFallbackEnabled = ${JSON.stringify(enableNativeFallback)};
329
+ const boardSessionMaxAgeMs = 7 * 60 * 60 * 1000;
292
330
  const tenantId = process.env.IMPEL_DESKTOP_TASKS_TENANT;
293
331
  const nativeShowChannel = "impel-desktop-tasks:native-show";
294
332
  const runtimeStatusPath = path.join(__dirname, "runtime-status.json");
@@ -364,26 +402,15 @@ if (
364
402
  return;
365
403
  }
366
404
  const windows = new Map();
367
- const internalWindows = new WeakSet();
368
405
  const internalContents = new Set();
369
406
  const nativeNavOwners = new Map();
370
407
  const configuredSessions = new WeakSet();
371
- let creatingInternalWindow = false;
372
408
  let hasDomNavigation = false;
373
409
  let primaryHost = null;
374
- const createInternalWindow = (options) => {
375
- creatingInternalWindow = true;
376
- try {
377
- const window = new BrowserWindow(options);
378
- internalWindows.add(window);
379
- return window;
380
- }
381
- finally { creatingInternalWindow = false; }
382
- };
383
410
  const stateFor = (window) => {
384
411
  let state = windows.get(window.id);
385
412
  if (!state) {
386
- state = { view: null, bounds: null, visible: false, nativeNav: null, hasDomNavigation: false, loading: null, bootstrapping: false };
413
+ state = { view: null, bounds: null, visible: false, nativeNav: null, hasDomNavigation: false, loaded: false, loadedAt: 0, loading: null, bootstrapping: false };
387
414
  windows.set(window.id, state);
388
415
  }
389
416
  return state;
@@ -425,7 +452,16 @@ if (
425
452
  const errorDocument = "data:text/html;charset=utf-8," + encodeURIComponent(
426
453
  "<!doctype html><html><body style='font:14px system-ui;padding:32px'>Impel Tasks could not be loaded. Hide Tasks, check your connection, and try again.</body></html>",
427
454
  );
455
+ const loadErrorDocument = async (state) => {
456
+ const contents = state.view?.webContents;
457
+ if (!contents || contents.isDestroyed()) return;
458
+ await contents.loadURL(errorDocument).catch(() => {});
459
+ };
428
460
  const loadBoard = async (state) => {
461
+ const contents = state.view?.webContents;
462
+ if (!contents || contents.isDestroyed()) return;
463
+ state.loaded = false;
464
+ state.loadedAt = 0;
429
465
  state.bootstrapping = true;
430
466
  let pat = null;
431
467
  try {
@@ -439,27 +475,32 @@ if (
439
475
  ].join("\\n"),
440
476
  postData: [{ type: "rawData", bytes: Buffer.alloc(0) }],
441
477
  };
442
- await state.view.webContents.loadURL(bootstrapUrl, request);
443
- if (state.view.webContents.getURL() !== bootstrapUrl) throw new Error("bootstrap redirected");
444
- const ready = await state.view.webContents.executeJavaScript(
478
+ await contents.loadURL(bootstrapUrl, request);
479
+ if (contents.isDestroyed() || contents.getURL() !== bootstrapUrl) throw new Error("bootstrap redirected");
480
+ const ready = await contents.executeJavaScript(
445
481
  "document.body?.textContent?.trim() === " + JSON.stringify(readyMarker),
446
482
  true,
447
483
  );
448
484
  if (!ready) throw new Error("bootstrap marker missing");
449
485
  } catch {
450
486
  record("board-bootstrap-failed");
451
- await state.view.webContents.loadURL(errorDocument).catch(() => {});
487
+ await loadErrorDocument(state);
452
488
  return;
453
489
  } finally {
454
490
  pat = null;
455
491
  state.bootstrapping = false;
456
492
  }
457
493
  try {
458
- await state.view.webContents.loadURL(tasksUrl);
494
+ await contents.loadURL(tasksUrl);
495
+ if (contents.isDestroyed()) return;
496
+ state.loaded = true;
497
+ state.loadedAt = Date.now();
459
498
  record("board-loaded");
460
499
  } catch {
500
+ state.loaded = false;
501
+ state.loadedAt = 0;
461
502
  record("board-load-failed");
462
- await state.view.webContents.loadURL(errorDocument).catch(() => {});
503
+ await loadErrorDocument(state);
463
504
  }
464
505
  };
465
506
  const deactivateNavigation = () => {
@@ -468,17 +509,20 @@ if (
468
509
  }
469
510
  for (const contents of webContents.getAllWebContents()) {
470
511
  if (internalContents.has(contents) || contents.isDestroyed()) continue;
471
- for (const frame of contents.mainFrame?.framesInSubtree || []) {
472
- frame.executeJavaScript("window.__impelDesktopTasksSetActive?.(false)", true).catch(() => {});
473
- }
512
+ contents.mainFrame?.executeJavaScript?.(
513
+ "window.__impelDesktopTasksSetActive?.(false)",
514
+ true,
515
+ )?.catch(() => {});
474
516
  }
475
517
  };
476
518
  const isHostWindow = (window) => {
477
- if (!window || window.isDestroyed() || internalWindows.has(window) || window.getParentWindow?.()) return false;
519
+ if (!window || window.isDestroyed() || window.getParentWindow?.()) return false;
478
520
  try {
479
- const url = new URL(window.webContents.getURL());
521
+ const rawUrl = window.webContents.getURL();
522
+ if (!rawUrl || rawUrl === "about:blank") return false;
523
+ const url = new URL(rawUrl);
480
524
  if (url.searchParams.has("initialRoute")) return false;
481
- } catch {}
525
+ } catch { return false; }
482
526
  return true;
483
527
  };
484
528
  const primaryHostWindow = () => {
@@ -496,6 +540,7 @@ if (
496
540
  state.view?.setVisible(false);
497
541
  window.webContents.focus();
498
542
  deactivateNavigation();
543
+ record("board-hidden");
499
544
  };
500
545
  const isPrimaryWindow = (window) => {
501
546
  return isHostWindow(window) && window === primaryHostWindow() && window.isVisible?.() !== false;
@@ -518,6 +563,8 @@ if (
518
563
  },
519
564
  });
520
565
  state.view = view;
566
+ state.loaded = false;
567
+ state.loadedAt = 0;
521
568
  internalContents.add(view.webContents);
522
569
  configureSession(view.webContents.session);
523
570
  window.contentView.addChildView(view);
@@ -545,7 +592,12 @@ if (
545
592
  view.webContents.on("destroyed", () => {
546
593
  internalContents.delete(view.webContents);
547
594
  try { window.contentView.removeChildView(view); } catch {}
548
- if (state.view === view) state.view = null;
595
+ if (state.view === view) {
596
+ state.view = null;
597
+ state.loaded = false;
598
+ state.loadedAt = 0;
599
+ state.loading = null;
600
+ }
549
601
  });
550
602
  return state;
551
603
  };
@@ -558,7 +610,20 @@ if (
558
610
  state.view.webContents.focus();
559
611
  if (state.visible) return;
560
612
  state.visible = true;
561
- state.loading = loadBoard(state).finally(() => { state.loading = null; });
613
+ let boardRoute = false;
614
+ try {
615
+ const current = new URL(state.view.webContents.getURL());
616
+ boardRoute = current.origin === appOrigin
617
+ && (current.pathname === tasksUrl.slice(appOrigin.length)
618
+ || current.pathname.startsWith(tasksUrl.slice(appOrigin.length) + "/"));
619
+ } catch {}
620
+ const reusableBoard = state.loaded
621
+ && Date.now() - state.loadedAt < boardSessionMaxAgeMs
622
+ && boardRoute;
623
+ if (!reusableBoard) state.loaded = false;
624
+ if (!state.loaded && !state.loading) {
625
+ state.loading = loadBoard(state).finally(() => { state.loading = null; });
626
+ }
562
627
  record("board-shown");
563
628
  };
564
629
  const layoutBoard = (window, bounds = null) => {
@@ -593,9 +658,9 @@ if (
593
658
  };
594
659
  const layoutNativeNav = (window) => {
595
660
  const state = windows.get(window.id);
596
- if (!state?.nativeNav || state.nativeNav.isDestroyed()) return;
661
+ if (!state?.nativeNav || state.nativeNav.webContents.isDestroyed()) return;
597
662
  const content = window.getContentBounds();
598
- state.nativeNav.setBounds({ x: content.x + 12, y: content.y + Math.max(48, content.height - 92), width: Math.min(120, Math.max(80, content.width - 24)), height: 38 });
663
+ state.nativeNav.setBounds({ x: 12, y: Math.max(48, content.height - 92), width: Math.min(120, Math.max(80, content.width - 24)), height: 38 });
599
664
  };
600
665
  const ensureNativeNav = (probeWindow) => {
601
666
  // Renderer probes finish independently. Only the canonical host may
@@ -604,21 +669,14 @@ if (
604
669
  const window = fallbackHostWindow();
605
670
  if (!window || probeWindow !== window) return;
606
671
  const state = stateFor(window);
607
- if (state.nativeNav && !state.nativeNav.isDestroyed()) return;
672
+ if (state.nativeNav && !state.nativeNav.webContents.isDestroyed()) return;
608
673
  // Auxiliary vendor windows (for example Codex's hidden avatar overlay)
609
674
  // are also top-level BrowserWindows. Never give them their own Tasks
610
- // fallback: duplicate child windows interfere with first-run navigation
611
- // and can cover the primary shell.
675
+ // fallback: duplicate views can cover the primary shell.
612
676
  if ([...windows.values()].some((candidate) => (
613
- candidate.nativeNav && !candidate.nativeNav.isDestroyed()
677
+ candidate.nativeNav && !candidate.nativeNav.webContents.isDestroyed()
614
678
  ))) return;
615
- const nativeNav = createInternalWindow({
616
- parent: window,
617
- frame: false,
618
- show: false,
619
- resizable: false,
620
- skipTaskbar: true,
621
- backgroundColor: "#292929",
679
+ const nativeNav = new WebContentsView({
622
680
  webPreferences: {
623
681
  preload: process.env.IMPEL_DESKTOP_TASKS_VIEW_PRELOAD,
624
682
  contextIsolation: true,
@@ -629,15 +687,19 @@ if (
629
687
  state.nativeNav = nativeNav;
630
688
  internalContents.add(nativeNav.webContents);
631
689
  nativeNavOwners.set(nativeNav.webContents, window);
690
+ window.contentView.addChildView(nativeNav);
691
+ nativeNav.setVisible(false);
692
+ nativeNav.setBackgroundColor("#292929");
632
693
  nativeNav.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
633
694
  nativeNav.webContents.on("will-navigate", (event, url) => { if (url !== nativeNavUrl) event.preventDefault(); });
634
695
  nativeNav.webContents.on("destroyed", () => {
635
696
  internalContents.delete(nativeNav.webContents);
636
697
  nativeNavOwners.delete(nativeNav.webContents);
698
+ if (state.nativeNav === nativeNav) state.nativeNav = null;
637
699
  });
638
700
  nativeNav.webContents.loadURL(nativeNavUrl).catch(() => record("native-navigation-load-failed"));
639
701
  layoutNativeNav(window);
640
- nativeNav.showInactive();
702
+ nativeNav.setVisible(true);
641
703
  record("native-navigation-installed");
642
704
  };
643
705
  const removeNativeNav = (window) => {
@@ -647,7 +709,13 @@ if (
647
709
  state.nativeNav = null;
648
710
  internalContents.delete(nativeNav.webContents);
649
711
  nativeNavOwners.delete(nativeNav.webContents);
650
- try { nativeNav.destroy(); } catch {}
712
+ try { window.contentView.removeChildView(nativeNav); } catch {}
713
+ try { nativeNav.webContents.close(); } catch {}
714
+ };
715
+ const markDomNavigation = (window) => {
716
+ hasDomNavigation = true;
717
+ stateFor(window).hasDomNavigation = true;
718
+ for (const owner of BrowserWindow.getAllWindows()) removeNativeNav(owner);
651
719
  };
652
720
  const ownerWindow = (contents) => {
653
721
  const window = BrowserWindow.fromWebContents(contents) || contents.getOwnerBrowserWindow?.() || null;
@@ -656,69 +724,100 @@ if (
656
724
  const attachContents = (contents) => {
657
725
  if (!contents || contents.isDestroyed() || contents.__impelDesktopTasksAttached || internalContents.has(contents)) return;
658
726
  contents.__impelDesktopTasksAttached = true;
659
- const inject = (attempt = 0) => {
727
+ const navigationNonce = randomBytes(32).toString("base64url");
728
+ const contentsNavSource = navSource.replace(
729
+ JSON.stringify(navigationNoncePlaceholder),
730
+ JSON.stringify(navigationNonce),
731
+ );
732
+ const inject = () => {
660
733
  if (internalContents.has(contents)) return;
661
- Promise.all((contents.mainFrame?.framesInSubtree || []).map((frame) => frame.executeJavaScript(navSource, true)))
662
- .then((results) => {
734
+ const frame = contents.mainFrame;
735
+ if (!frame || typeof frame.executeJavaScript !== "function") return;
736
+ frame.executeJavaScript(contentsNavSource, true)
737
+ .then((result) => {
663
738
  const window = ownerWindow(contents);
664
739
  if (!window || window !== primaryHostWindow()) return;
665
740
  const state = stateFor(window);
666
- if (results.some((result) => result?.anchor)) {
667
- hasDomNavigation = true;
668
- state.hasDomNavigation = true;
669
- for (const owner of BrowserWindow.getAllWindows()) removeNativeNav(owner);
670
- } else if (attempt < 3) setTimeout(() => inject(attempt + 1), 1000);
671
- else if (!state.hasDomNavigation && !hasDomNavigation) ensureNativeNav(window);
741
+ if (result?.anchor) {
742
+ markDomNavigation(window);
743
+ } else if (result?.shellReady && !state.hasDomNavigation && !hasDomNavigation && nativeFallbackEnabled) {
744
+ ensureNativeNav(window);
745
+ }
672
746
  })
673
747
  .catch(() => record("navigation-injection-failed"));
674
748
  };
675
749
  contents.on("did-finish-load", () => inject());
676
- contents.on("did-frame-finish-load", () => inject());
677
750
  if (!contents.isLoadingMainFrame()) setTimeout(() => inject(), 0);
678
- let handledNavigation = null;
751
+ const handledNavigationEvents = new Set();
752
+ let handledLegacyNavigation = null;
679
753
  const handleNavigation = (event, url) => {
680
754
  if (!String(url).startsWith("impel-tasks://")) return;
681
755
  event.preventDefault();
682
756
  const window = ownerWindow(contents);
683
757
  if (!window || window !== primaryHostWindow()) return;
684
- if (handledNavigation === url) return;
685
- handledNavigation = url;
686
- queueMicrotask(() => { if (handledNavigation === url) handledNavigation = null; });
687
- let action;
688
- try { action = new URL(url).hostname; } catch { return; }
689
- if (action === "hide") hide(window);
758
+ let navigation;
759
+ try { navigation = new URL(url); } catch { return; }
760
+ if (navigation.searchParams.get("nonce") !== navigationNonce) return;
761
+ const action = navigation.hostname;
762
+ const eventId = navigation.searchParams.get("event");
763
+ if (eventId) {
764
+ const navigationKey = action + ":" + eventId;
765
+ if (handledNavigationEvents.has(navigationKey)) return;
766
+ handledNavigationEvents.add(navigationKey);
767
+ if (handledNavigationEvents.size > 128) {
768
+ handledNavigationEvents.delete(handledNavigationEvents.values().next().value);
769
+ }
770
+ } else {
771
+ if (handledLegacyNavigation === url) return;
772
+ handledLegacyNavigation = url;
773
+ queueMicrotask(() => { if (handledLegacyNavigation === url) handledLegacyNavigation = null; });
774
+ }
775
+ if (action === "ready") markDomNavigation(window);
776
+ else if (action === "hide") hide(window);
690
777
  else if (action === "show") show(window, parseBounds(url, window));
691
778
  else if (action === "layout") layoutBoard(window, parseBounds(url, window));
692
779
  };
693
- contents.on("will-navigate", handleNavigation);
694
- contents.on("will-frame-navigate", handleNavigation);
780
+ const eventUrl = (event, deprecatedUrl) => (
781
+ typeof event?.url === "string" ? event.url : String(deprecatedUrl || "")
782
+ );
783
+ contents.on("will-navigate", (event, deprecatedUrl) => {
784
+ handleNavigation(event, eventUrl(event, deprecatedUrl));
785
+ });
786
+ contents.on("will-frame-navigate", (event, deprecatedUrl, _isInPlace, deprecatedIsMainFrame) => {
787
+ const url = eventUrl(event, deprecatedUrl);
788
+ if (!url.startsWith("impel-tasks://")) return;
789
+ event.preventDefault();
790
+ const isMainFrame = typeof event?.isMainFrame === "boolean"
791
+ ? event.isMainFrame
792
+ : deprecatedIsMainFrame === true;
793
+ if (isMainFrame) handleNavigation(event, url);
794
+ });
695
795
  };
696
796
  const attachWindow = (window) => {
697
- if (!window || window.isDestroyed() || creatingInternalWindow || window.getParentWindow?.() || window.__impelDesktopTasksWindowAttached) return;
797
+ if (!window || window.isDestroyed() || window.getParentWindow?.() || window.__impelDesktopTasksWindowAttached) return;
698
798
  window.__impelDesktopTasksWindowAttached = true;
699
799
  attachContents(window.webContents);
700
800
  window.on("resize", () => { layoutNativeNav(window); layoutBoard(window); });
701
801
  window.on("focus", () => {
702
802
  const state = windows.get(window.id);
703
- state?.nativeNav?.showInactive();
803
+ state?.nativeNav?.setVisible(true);
704
804
  if (state?.visible) state.view?.setVisible(true);
705
805
  });
706
806
  window.on("blur", () => setTimeout(() => {
707
807
  const state = windows.get(window.id);
708
- if (BrowserWindow.getFocusedWindow() === state?.nativeNav) return;
709
808
  // Keep the only path to Tasks available while the app is frontmost.
710
- // BrowserWindow blur also fires when a user clicks the child fallback;
711
- // hiding it here made the button disappear before its click arrived.
712
- if (state?.nativeNav && (!window.isVisible() || window.isMinimized())) state.nativeNav.hide();
809
+ // Moving focus into the fallback view must not hide it before the click
810
+ // arrives; only a hidden or minimized host hides the affordance.
811
+ if (state?.nativeNav && (!window.isVisible() || window.isMinimized())) state.nativeNav.setVisible(false);
713
812
  }, 150));
714
813
  window.on("minimize", () => {
715
814
  const state = windows.get(window.id);
716
- state?.nativeNav?.hide();
815
+ state?.nativeNav?.setVisible(false);
717
816
  state?.view?.setVisible(false);
718
817
  });
719
818
  window.on("restore", () => {
720
819
  const state = windows.get(window.id);
721
- state?.nativeNav?.showInactive();
820
+ state?.nativeNav?.setVisible(true);
722
821
  if (state?.visible) state.view?.setVisible(true);
723
822
  });
724
823
  window.on("closed", () => {
@@ -726,7 +825,8 @@ if (
726
825
  if (state) {
727
826
  try { if (state.view) window.contentView.removeChildView(state.view); } catch {}
728
827
  try { state.view?.webContents.close(); } catch {}
729
- try { state.nativeNav?.destroy(); } catch {}
828
+ try { if (state.nativeNav) window.contentView.removeChildView(state.nativeNav); } catch {}
829
+ try { state.nativeNav?.webContents.close(); } catch {}
730
830
  }
731
831
  windows.delete(window.id);
732
832
  if (primaryHost === window) primaryHost = null;
@@ -1,6 +1,8 @@
1
1
  // Fleet generation shared by managed-profile writers and upstream clients.
2
2
  // Keep this isolated from apps.js so latency-sensitive transports do not load
3
3
  // desktop bundle machinery just to identify their managed config contract.
4
- // v39 confines the authenticated desktop Tasks view to one canonical primary
5
- // vendor window and forces every existing managed profile to receive the fix.
6
- export const CURRENT_CONFIG_VERSION = 39;
4
+ // v41 restores ChatGPT's embedded Tasks entry through its real sidebar only.
5
+ // The native fallback stays disabled, and a valid authenticated board is
6
+ // reused across hide/show cycles so it cannot obscure or repeatedly bootstrap
7
+ // the host.
8
+ export const CURRENT_CONFIG_VERSION = 41;