impel-cli 0.20.47 → 0.20.49

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,42 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.49 — Restore managed app startup
4
+
5
+ - Restores normal ChatGPT and Claude startup by disabling the experimental
6
+ embedded Tasks UI in managed vendor apps. The fallback's Electron window and
7
+ DOM-injection paths no longer run in production.
8
+ - Retains tenant-bound Tasks through the CLI and MCP while the desktop entry
9
+ point is redesigned against an explicitly supported vendor integration seam.
10
+ - Makes every production-generated main preload an exact inert stub. The
11
+ reviewed prototype remains test-only and cannot be enabled by a managed app
12
+ environment variable.
13
+ - Advances the managed profile contract to v40 so every existing macOS app
14
+ rewrites and reloads the corrected external Tasks preload. Vendor pins and
15
+ built bundle bytes remain unchanged.
16
+ - Adds a production-default contract proving the embedded path is inert, while
17
+ retaining deterministic coverage for the prototype behind its opt-in. The
18
+ pinned managed-app smoke also requires the normal working surface, the exact
19
+ inert preload bytes, zero injected controls, and zero Tasks web requests.
20
+
21
+ ## 0.20.48 — Confine Tasks to the primary app window
22
+
23
+ - Fixes the remaining `0.20.47` regression that let an auxiliary ChatGPT or
24
+ Claude renderer attach a second authenticated Tasks board in a large framed
25
+ window, obscuring the normal app.
26
+ - Binds Tasks once to the canonical primary vendor window, rejects child,
27
+ internal, auxiliary, and unowned renderers fail-closed, and removes focused-
28
+ window guessing from navigation ownership.
29
+ - Couples fallback installation to the canonical renderer and deduplicates
30
+ paired Electron navigation events so one user action creates at most one
31
+ board view.
32
+ - Advances the managed profile contract to v39 so every existing install
33
+ rewrites and reloads the corrected external Tasks preload. Vendor pins and
34
+ built bundle bytes remain unchanged.
35
+ - Adds a behavioral reproduction of the production screenshot: an auxiliary
36
+ renderer fires both navigation events before the fallback is clicked, while
37
+ the test proves exactly one board attaches to the primary window and the
38
+ fallback remains a frameless 120×38 control.
39
+
3
40
  ## 0.20.47 — Keep Tasks on the primary app window
4
41
 
5
42
  - Makes the native Tasks fallback choose the earliest surviving primary host
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.47",
3
+ "version": "0.20.49",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,6 +103,7 @@ const NAVIGATION_SOURCE = String.raw`(() => {
103
103
  let slot = null;
104
104
  let active = false;
105
105
  let lastAnchor = null;
106
+ let readySignalled = false;
106
107
  const roots = () => {
107
108
  const result = [document];
108
109
  for (let index = 0; index < result.length; index += 1) {
@@ -233,6 +234,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
233
234
  slot = nav;
234
235
  }
235
236
  select(active);
237
+ if (nav?.isConnected && !readySignalled) {
238
+ readySignalled = true;
239
+ setTimeout(() => signal("ready"), 0);
240
+ }
236
241
  };
237
242
  const style = document.createElement("style");
238
243
  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}";
@@ -260,13 +265,19 @@ const NAVIGATION_SOURCE = String.raw`(() => {
260
265
  style.remove();
261
266
  delete window.__impelDesktopTasksSetActive;
262
267
  };
263
- return { anchor: lastAnchor ? label(lastAnchor) : null, connected: Boolean(nav?.isConnected) };
268
+ const shellControls = controls().filter((element) => visible(element) && Boolean(label(element)));
269
+ return {
270
+ anchor: lastAnchor ? label(lastAnchor) : null,
271
+ connected: Boolean(nav?.isConnected),
272
+ shellReady: shellControls.length >= 2,
273
+ };
264
274
  })();`;
265
275
 
266
276
  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
277
 
268
- export function desktopTasksMainPreload(appUrl) {
278
+ export function desktopTasksMainPreload(appUrl, { enableEmbed = false } = {}) {
269
279
  const appOrigin = desktopTasksAppOrigin(appUrl);
280
+ if (!enableEmbed) return `"use strict";\n`;
270
281
  const parserSource = parseDesktopTasksCredential.toString();
271
282
  const escapeRegexSource = escapeRegex.toString();
272
283
  return `"use strict";
@@ -367,13 +378,8 @@ if (
367
378
  const internalContents = new Set();
368
379
  const nativeNavOwners = new Map();
369
380
  const configuredSessions = new WeakSet();
370
- let creatingInternalWindow = false;
371
381
  let hasDomNavigation = false;
372
- const createInternalWindow = (options) => {
373
- creatingInternalWindow = true;
374
- try { return new BrowserWindow(options); }
375
- finally { creatingInternalWindow = false; }
376
- };
382
+ let primaryHost = null;
377
383
  const stateFor = (window) => {
378
384
  let state = windows.get(window.id);
379
385
  if (!state) {
@@ -467,7 +473,25 @@ if (
467
473
  }
468
474
  }
469
475
  };
476
+ const isHostWindow = (window) => {
477
+ if (!window || window.isDestroyed() || window.getParentWindow?.()) return false;
478
+ try {
479
+ const rawUrl = window.webContents.getURL();
480
+ if (!rawUrl || rawUrl === "about:blank") return false;
481
+ const url = new URL(rawUrl);
482
+ if (url.searchParams.has("initialRoute")) return false;
483
+ } catch { return false; }
484
+ return true;
485
+ };
486
+ const primaryHostWindow = () => {
487
+ if (isHostWindow(primaryHost)) return primaryHost;
488
+ primaryHost = BrowserWindow.getAllWindows()
489
+ .filter(isHostWindow)
490
+ .sort((left, right) => left.id - right.id)[0] || null;
491
+ return primaryHost;
492
+ };
470
493
  const hide = (window) => {
494
+ if (window !== primaryHostWindow()) return;
471
495
  const state = windows.get(window.id);
472
496
  if (!state) return;
473
497
  state.visible = false;
@@ -476,17 +500,14 @@ if (
476
500
  deactivateNavigation();
477
501
  };
478
502
  const isPrimaryWindow = (window) => {
479
- if (!window || window.isDestroyed() || window.getParentWindow?.()) return false;
480
- try {
481
- const url = new URL(window.webContents.getURL());
482
- if (url.searchParams.has("initialRoute")) return false;
483
- } catch {}
484
- return window.isVisible?.() !== false;
503
+ return isHostWindow(window) && window === primaryHostWindow() && window.isVisible?.() !== false;
504
+ };
505
+ const fallbackHostWindow = () => {
506
+ const window = primaryHostWindow();
507
+ return isPrimaryWindow(window) ? window : null;
485
508
  };
486
- const fallbackHostWindow = () => BrowserWindow.getAllWindows()
487
- .filter(isPrimaryWindow)
488
- .sort((left, right) => left.id - right.id)[0] || null;
489
509
  const createView = (window) => {
510
+ if (!window || window !== primaryHostWindow() || !isHostWindow(window)) return null;
490
511
  const state = stateFor(window);
491
512
  if (state.view && !state.view.webContents.isDestroyed()) return state;
492
513
  const view = new WebContentsView({
@@ -532,6 +553,7 @@ if (
532
553
  };
533
554
  const show = (window, bounds) => {
534
555
  const state = createView(window);
556
+ if (!state) return;
535
557
  state.bounds = bounds;
536
558
  state.view.setBounds(bounds);
537
559
  state.view.setVisible(true);
@@ -542,6 +564,7 @@ if (
542
564
  record("board-shown");
543
565
  };
544
566
  const layoutBoard = (window, bounds = null) => {
567
+ if (window !== primaryHostWindow()) return;
545
568
  const state = windows.get(window.id);
546
569
  if (!state?.visible || !state.view || state.view.webContents.isDestroyed()) return;
547
570
  if (bounds) state.bounds = bounds;
@@ -572,32 +595,25 @@ if (
572
595
  };
573
596
  const layoutNativeNav = (window) => {
574
597
  const state = windows.get(window.id);
575
- if (!state?.nativeNav || state.nativeNav.isDestroyed()) return;
598
+ if (!state?.nativeNav || state.nativeNav.webContents.isDestroyed()) return;
576
599
  const content = window.getContentBounds();
577
- 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 });
600
+ state.nativeNav.setBounds({ x: 12, y: Math.max(48, content.height - 92), width: Math.min(120, Math.max(80, content.width - 24)), height: 38 });
578
601
  };
579
- const ensureNativeNav = () => {
580
- // Renderer probes finish independently. Always bind the singleton
581
- // fallback to the earliest surviving host window, not whichever visible
582
- // auxiliary renderer happens to exhaust its probe retries first.
602
+ const ensureNativeNav = (probeWindow) => {
603
+ // Renderer probes finish independently. Only the canonical host may
604
+ // install the singleton fallback; an auxiliary renderer cannot redirect
605
+ // it or acquire its own authenticated board view.
583
606
  const window = fallbackHostWindow();
584
- if (!window) return;
607
+ if (!window || probeWindow !== window) return;
585
608
  const state = stateFor(window);
586
- if (state.nativeNav && !state.nativeNav.isDestroyed()) return;
609
+ if (state.nativeNav && !state.nativeNav.webContents.isDestroyed()) return;
587
610
  // Auxiliary vendor windows (for example Codex's hidden avatar overlay)
588
611
  // are also top-level BrowserWindows. Never give them their own Tasks
589
- // fallback: duplicate child windows interfere with first-run navigation
590
- // and can cover the primary shell.
612
+ // fallback: duplicate views can cover the primary shell.
591
613
  if ([...windows.values()].some((candidate) => (
592
- candidate.nativeNav && !candidate.nativeNav.isDestroyed()
614
+ candidate.nativeNav && !candidate.nativeNav.webContents.isDestroyed()
593
615
  ))) return;
594
- const nativeNav = createInternalWindow({
595
- parent: window,
596
- frame: false,
597
- show: false,
598
- resizable: false,
599
- skipTaskbar: true,
600
- backgroundColor: "#292929",
616
+ const nativeNav = new WebContentsView({
601
617
  webPreferences: {
602
618
  preload: process.env.IMPEL_DESKTOP_TASKS_VIEW_PRELOAD,
603
619
  contextIsolation: true,
@@ -608,15 +624,19 @@ if (
608
624
  state.nativeNav = nativeNav;
609
625
  internalContents.add(nativeNav.webContents);
610
626
  nativeNavOwners.set(nativeNav.webContents, window);
627
+ window.contentView.addChildView(nativeNav);
628
+ nativeNav.setVisible(false);
629
+ nativeNav.setBackgroundColor("#292929");
611
630
  nativeNav.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
612
631
  nativeNav.webContents.on("will-navigate", (event, url) => { if (url !== nativeNavUrl) event.preventDefault(); });
613
632
  nativeNav.webContents.on("destroyed", () => {
614
633
  internalContents.delete(nativeNav.webContents);
615
634
  nativeNavOwners.delete(nativeNav.webContents);
635
+ if (state.nativeNav === nativeNav) state.nativeNav = null;
616
636
  });
617
637
  nativeNav.webContents.loadURL(nativeNavUrl).catch(() => record("native-navigation-load-failed"));
618
638
  layoutNativeNav(window);
619
- nativeNav.showInactive();
639
+ nativeNav.setVisible(true);
620
640
  record("native-navigation-installed");
621
641
  };
622
642
  const removeNativeNav = (window) => {
@@ -626,13 +646,18 @@ if (
626
646
  state.nativeNav = null;
627
647
  internalContents.delete(nativeNav.webContents);
628
648
  nativeNavOwners.delete(nativeNav.webContents);
629
- try { nativeNav.destroy(); } catch {}
649
+ try { window.contentView.removeChildView(nativeNav); } catch {}
650
+ try { nativeNav.webContents.close(); } catch {}
651
+ };
652
+ const markDomNavigation = (window) => {
653
+ hasDomNavigation = true;
654
+ stateFor(window).hasDomNavigation = true;
655
+ for (const owner of BrowserWindow.getAllWindows()) removeNativeNav(owner);
656
+ };
657
+ const ownerWindow = (contents) => {
658
+ const window = BrowserWindow.fromWebContents(contents) || contents.getOwnerBrowserWindow?.() || null;
659
+ return isHostWindow(window) ? window : null;
630
660
  };
631
- const ownerWindow = (contents) => BrowserWindow.fromWebContents(contents)
632
- || contents.getOwnerBrowserWindow?.()
633
- || BrowserWindow.getFocusedWindow()
634
- || BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
635
- || null;
636
661
  const attachContents = (contents) => {
637
662
  if (!contents || contents.isDestroyed() || contents.__impelDesktopTasksAttached || internalContents.has(contents)) return;
638
663
  contents.__impelDesktopTasksAttached = true;
@@ -641,28 +666,33 @@ if (
641
666
  Promise.all((contents.mainFrame?.framesInSubtree || []).map((frame) => frame.executeJavaScript(navSource, true)))
642
667
  .then((results) => {
643
668
  const window = ownerWindow(contents);
644
- if (!window) return;
669
+ if (!window || window !== primaryHostWindow()) return;
645
670
  const state = stateFor(window);
671
+ const shellReady = results.some((result) => result?.shellReady);
646
672
  if (results.some((result) => result?.anchor)) {
647
- hasDomNavigation = true;
648
- state.hasDomNavigation = true;
649
- for (const owner of BrowserWindow.getAllWindows()) removeNativeNav(owner);
650
- } else if (attempt < 3) setTimeout(() => inject(attempt + 1), 1000);
651
- else if (!state.hasDomNavigation && !hasDomNavigation) ensureNativeNav();
673
+ markDomNavigation(window);
674
+ } else if (!shellReady && attempt < 30) setTimeout(() => inject(attempt + 1), 1000);
675
+ else if (shellReady && !state.hasDomNavigation && !hasDomNavigation) ensureNativeNav(window);
676
+ else if (!shellReady) record("navigation-shell-unready");
652
677
  })
653
678
  .catch(() => record("navigation-injection-failed"));
654
679
  };
655
680
  contents.on("did-finish-load", () => inject());
656
681
  contents.on("did-frame-finish-load", () => inject());
657
682
  if (!contents.isLoadingMainFrame()) setTimeout(() => inject(), 0);
683
+ let handledNavigation = null;
658
684
  const handleNavigation = (event, url) => {
659
685
  if (!String(url).startsWith("impel-tasks://")) return;
660
686
  event.preventDefault();
661
687
  const window = ownerWindow(contents);
662
- if (!window) return;
688
+ if (!window || window !== primaryHostWindow()) return;
689
+ if (handledNavigation === url) return;
690
+ handledNavigation = url;
691
+ queueMicrotask(() => { if (handledNavigation === url) handledNavigation = null; });
663
692
  let action;
664
693
  try { action = new URL(url).hostname; } catch { return; }
665
- if (action === "hide") hide(window);
694
+ if (action === "ready") markDomNavigation(window);
695
+ else if (action === "hide") hide(window);
666
696
  else if (action === "show") show(window, parseBounds(url, window));
667
697
  else if (action === "layout") layoutBoard(window, parseBounds(url, window));
668
698
  };
@@ -670,40 +700,42 @@ if (
670
700
  contents.on("will-frame-navigate", handleNavigation);
671
701
  };
672
702
  const attachWindow = (window) => {
673
- if (!window || window.isDestroyed() || creatingInternalWindow || window.getParentWindow?.() || window.__impelDesktopTasksWindowAttached) return;
703
+ if (!window || window.isDestroyed() || window.getParentWindow?.() || window.__impelDesktopTasksWindowAttached) return;
674
704
  window.__impelDesktopTasksWindowAttached = true;
675
705
  attachContents(window.webContents);
676
706
  window.on("resize", () => { layoutNativeNav(window); layoutBoard(window); });
677
707
  window.on("focus", () => {
678
708
  const state = windows.get(window.id);
679
- state?.nativeNav?.showInactive();
709
+ state?.nativeNav?.setVisible(true);
680
710
  if (state?.visible) state.view?.setVisible(true);
681
711
  });
682
712
  window.on("blur", () => setTimeout(() => {
683
713
  const state = windows.get(window.id);
684
- if (BrowserWindow.getFocusedWindow() === state?.nativeNav) return;
685
714
  // Keep the only path to Tasks available while the app is frontmost.
686
- // BrowserWindow blur also fires when a user clicks the child fallback;
687
- // hiding it here made the button disappear before its click arrived.
688
- if (state?.nativeNav && (!window.isVisible() || window.isMinimized())) state.nativeNav.hide();
715
+ // Moving focus into the fallback view must not hide it before the click
716
+ // arrives; only a hidden or minimized host hides the affordance.
717
+ if (state?.nativeNav && (!window.isVisible() || window.isMinimized())) state.nativeNav.setVisible(false);
689
718
  }, 150));
690
719
  window.on("minimize", () => {
691
720
  const state = windows.get(window.id);
692
- state?.nativeNav?.hide();
721
+ state?.nativeNav?.setVisible(false);
693
722
  state?.view?.setVisible(false);
694
723
  });
695
724
  window.on("restore", () => {
696
725
  const state = windows.get(window.id);
697
- state?.nativeNav?.showInactive();
726
+ state?.nativeNav?.setVisible(true);
698
727
  if (state?.visible) state.view?.setVisible(true);
699
728
  });
700
729
  window.on("closed", () => {
701
730
  const state = windows.get(window.id);
702
- if (!state) return;
703
- try { if (state.view) window.contentView.removeChildView(state.view); } catch {}
704
- try { state.view?.webContents.close(); } catch {}
705
- try { state.nativeNav?.destroy(); } catch {}
731
+ if (state) {
732
+ try { if (state.view) window.contentView.removeChildView(state.view); } catch {}
733
+ try { state.view?.webContents.close(); } catch {}
734
+ try { if (state.nativeNav) window.contentView.removeChildView(state.nativeNav); } catch {}
735
+ try { state.nativeNav?.webContents.close(); } catch {}
736
+ }
706
737
  windows.delete(window.id);
738
+ if (primaryHost === window) primaryHost = null;
707
739
  });
708
740
  };
709
741
  ipcMain.on(nativeShowChannel, (event) => {
@@ -1,6 +1,6 @@
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
- // v38 replaces the generated desktop MCP Apps host with the authenticated web
5
- // board and removes the desktop-only Codex renderer flag.
6
- export const CURRENT_CONFIG_VERSION = 38;
4
+ // v40 renders the Tasks fallback inside the vendor window and waits for the app
5
+ // shell, so no child BrowserWindow can pin ChatGPT's transient startup splash.
6
+ export const CURRENT_CONFIG_VERSION = 40;