impel-cli 0.20.46-beta.0 → 0.20.46

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.
@@ -1,6 +1,10 @@
1
1
  import path from "node:path";
2
2
 
3
- export const DESKTOP_TASKS_RESOURCE_URI = "ui://impel/tasks/v1/index.html";
3
+ import { RUNTIME_BRAND } from "./runtimeBrand.js";
4
+
5
+ export const DESKTOP_TASKS_BOOTSTRAP_PATH = "/api/cli/desktop-session";
6
+ export const DESKTOP_TASKS_BOARD_PATH = "/tasks";
7
+ export const DESKTOP_TASKS_READY_MARKER = "IMPEL_DESKTOP_SESSION_READY";
4
8
 
5
9
  // Claude's mode switch is the stable landmark across Home and Code, but its
6
10
  // plain New row is the visual template. ChatGPT/Codex keeps the established
@@ -21,130 +25,139 @@ export function desktopTasksAssetPaths(root) {
21
25
  directory,
22
26
  mainPreload: path.join(directory, "main-preload.cjs"),
23
27
  viewPreload: path.join(directory, "view-preload.cjs"),
28
+ // Kept as an owned cleanup target while older bundle launch metadata still
29
+ // contains the ignored environment key. No host document is generated.
24
30
  hostHtml: path.join(directory, "host.html"),
25
31
  };
26
32
  }
27
33
 
34
+ export function desktopTasksAppOrigin(value) {
35
+ let url;
36
+ try {
37
+ url = new URL(String(value));
38
+ } catch {
39
+ throw new Error("desktop Tasks app URL is invalid");
40
+ }
41
+ if (
42
+ !["http:", "https:"].includes(url.protocol)
43
+ || url.username
44
+ || url.password
45
+ ) {
46
+ throw new Error("desktop Tasks app URL must be an HTTP(S) origin without credentials");
47
+ }
48
+ return url.origin;
49
+ }
50
+
51
+ function escapeRegex(value) {
52
+ return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
53
+ }
54
+
55
+ export function parseDesktopTasksCredential(output, tenantId, {
56
+ patPrefix = RUNTIME_BRAND.auth.patPrefix,
57
+ tenantPrefix = RUNTIME_BRAND.auth.tenantPrefix,
58
+ } = {}) {
59
+ if (typeof output !== "string" || output.length === 0 || output.length > 8192) {
60
+ throw new Error("desktop Tasks token helper returned invalid output");
61
+ }
62
+ const line = output.endsWith("\n") ? output.slice(0, -1) : output;
63
+ if (!line || /[\r\n\u0000-\u001f\u007f]/u.test(line)) {
64
+ throw new Error("desktop Tasks token helper returned invalid output");
65
+ }
66
+ const pattern = new RegExp(
67
+ `^${escapeRegex(tenantPrefix)}([A-Za-z0-9_-]+)\\.(${escapeRegex(patPrefix)}[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)?)$`,
68
+ "u",
69
+ );
70
+ const match = pattern.exec(line);
71
+ if (!match) throw new Error("desktop Tasks token helper returned invalid output");
72
+
73
+ let decodedTenant;
74
+ try {
75
+ decodedTenant = Buffer.from(match[1], "base64url").toString("utf8");
76
+ } catch {
77
+ throw new Error("desktop Tasks token helper returned invalid output");
78
+ }
79
+ if (
80
+ Buffer.from(decodedTenant, "utf8").toString("base64url") !== match[1]
81
+ || decodedTenant !== tenantId
82
+ ) {
83
+ throw new Error("desktop Tasks token helper tenant did not match");
84
+ }
85
+ return match[2];
86
+ }
87
+
28
88
  const NAV_ANCHOR_RULES_SOURCE = JSON.stringify(DESKTOP_TASKS_NAV_ANCHOR_RULES);
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>';
29
90
 
30
91
  const NAVIGATION_SOURCE = String.raw`(() => {
31
92
  const NAV_ID = "impel-desktop-tasks-nav";
32
- const NAV_SLOT_ID = "impel-desktop-tasks-nav-slot";
93
+ const SLOT_ID = "impel-desktop-tasks-nav-slot";
33
94
  const ACTIVE_CLASS = "impel-desktop-tasks-active";
34
- const NAV_ANCHOR_RULES = ${NAV_ANCHOR_RULES_SOURCE}.map((rule) => ({
95
+ const RULES = ${NAV_ANCHOR_RULES_SOURCE}.map((rule) => ({
35
96
  ...rule,
36
97
  pattern: new RegExp(rule.pattern, "i"),
37
98
  template: rule.template ? new RegExp(rule.template, "i") : null,
38
99
  }));
39
- 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>';
40
- if (window.__impelDesktopTasksCleanup) window.__impelDesktopTasksCleanup();
100
+ window.__impelDesktopTasksCleanup?.();
41
101
 
42
102
  let nav = null;
43
103
  let slot = null;
44
104
  let active = false;
45
105
  let lastAnchor = null;
46
-
106
+ const roots = () => {
107
+ const result = [document];
108
+ for (let index = 0; index < result.length; index += 1) {
109
+ for (const element of result[index].querySelectorAll("*")) {
110
+ if (element.shadowRoot && !result.includes(element.shadowRoot)) result.push(element.shadowRoot);
111
+ }
112
+ }
113
+ return result;
114
+ };
115
+ const all = (selector) => roots().flatMap((root) => [...root.querySelectorAll(selector)]);
47
116
  const label = (element) => [
48
117
  element.getAttribute("aria-label"),
49
118
  element.getAttribute("title"),
50
119
  element.textContent,
51
120
  ].filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
52
-
53
121
  const visible = (element) => {
54
122
  const rect = element.getBoundingClientRect();
55
123
  const style = getComputedStyle(element);
56
- return rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0
57
- && rect.left < innerWidth && rect.top < innerHeight
58
- && style.display !== "none" && style.visibility !== "hidden";
59
- };
60
-
61
- const roots = () => {
62
- const found = [document];
63
- for (let index = 0; index < found.length; index += 1) {
64
- for (const element of found[index].querySelectorAll("*")) {
65
- if (element.shadowRoot && !found.includes(element.shadowRoot)) found.push(element.shadowRoot);
66
- }
67
- }
68
- return found;
124
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
69
125
  };
70
-
71
- const queryAll = (selector) => roots().flatMap((root) => [...root.querySelectorAll(selector)]);
72
- const queryFirst = (selector) => queryAll(selector)[0] || null;
73
-
74
- const controls = () => queryAll("button, a, [role=\"button\"], [role=\"link\"], [tabindex]")
75
- .filter((element) => !element.closest("#" + NAV_ID + ", #" + NAV_SLOT_ID));
76
-
126
+ const sidebar = () => all("aside.app-shell-left-panel, aside, [data-app-shell-left-panel], nav[aria-label]")
127
+ .find(visible) || null;
128
+ const controls = () => all('button, a, [role="button"], [role="link"], [tabindex]')
129
+ .filter((element) => !element.closest("#" + NAV_ID + ", #" + SLOT_ID));
77
130
  const findControl = (pattern) => {
78
- const matches = controls().filter((element) => pattern.test(label(element)));
79
- const sidebar = findSidebar();
80
- if (sidebar) {
81
- const sidebarMatch = matches.find((element) => sidebar.contains(element) && visible(element))
82
- || matches.find((element) => sidebar.contains(element));
83
- if (sidebarMatch) return sidebarMatch;
84
- }
85
- return matches.find(visible) || matches[0] || null;
86
- };
87
-
88
- const findSidebar = () => {
89
- const explicit = queryFirst("aside.app-shell-left-panel, aside, [data-app-shell-left-panel], nav[aria-label]");
90
- if (explicit && visible(explicit)) return explicit;
91
- return null;
131
+ const candidates = controls().filter((element) => pattern.test(label(element)));
132
+ const side = sidebar();
133
+ return candidates.find((element) => side?.contains(element) && visible(element))
134
+ || candidates.find((element) => side?.contains(element))
135
+ || candidates.find(visible)
136
+ || candidates[0]
137
+ || null;
92
138
  };
93
-
94
- const findMain = () => {
95
- const candidates = [
96
- queryFirst("main[data-app-shell-main-surface]"),
97
- queryFirst("main.main-surface"),
98
- queryFirst("main"),
99
- queryFirst("[role=\"main\"]"),
100
- ].filter(Boolean);
101
- return candidates.find(visible) || candidates[0] || null;
102
- };
103
-
104
139
  const mainBounds = () => {
105
- const main = findMain();
106
- let rect = main?.getBoundingClientRect();
107
- const sidebar = findSidebar();
108
- const side = sidebar?.getBoundingClientRect();
140
+ const main = all('main[data-app-shell-main-surface], main.main-surface, main, [role="main"]')
141
+ .find(visible);
142
+ const side = sidebar()?.getBoundingClientRect();
109
143
  const anchor = lastAnchor?.getBoundingClientRect();
110
- const inferred = anchor
111
- && anchor.left < 48
112
- && anchor.right >= 180
113
- && anchor.right < innerWidth * 0.55
114
- ? anchor.right + 8
115
- : 0;
116
144
  const sidebarRight = side && side.left <= 8 && side.width < innerWidth * 0.55
117
145
  ? side.right
118
- : inferred;
119
- if (!rect || rect.width < 240 || rect.height < 240) {
120
- rect = { left: sidebarRight, top: 0, width: innerWidth - sidebarRight, height: innerHeight };
121
- } else if (sidebarRight > rect.left + 8 && rect.width > innerWidth * 0.7) {
122
- rect = {
123
- left: sidebarRight,
124
- top: rect.top,
125
- width: innerWidth - sidebarRight,
126
- height: rect.height,
127
- };
128
- }
146
+ : anchor && anchor.left < 48 && anchor.right < innerWidth * 0.55
147
+ ? anchor.right + 8
148
+ : 0;
149
+ const rect = main?.getBoundingClientRect();
129
150
  return {
130
- x: Math.max(0, Math.round(rect.left)),
131
- y: Math.max(0, Math.round(rect.top)),
132
- width: Math.max(320, Math.round(rect.width)),
133
- height: Math.max(320, Math.round(rect.height)),
151
+ x: Math.max(0, Math.round(rect && rect.width >= 240 ? Math.max(rect.left, sidebarRight) : sidebarRight)),
152
+ y: Math.max(0, Math.round(rect && rect.height >= 240 ? rect.top : 0)),
153
+ width: Math.max(320, Math.round(innerWidth - sidebarRight)),
154
+ height: Math.max(320, Math.round(rect && rect.height >= 240 ? rect.height : innerHeight)),
134
155
  };
135
156
  };
136
-
137
157
  const signal = (action) => {
138
- const bounds = mainBounds();
139
- const query = new URLSearchParams({
140
- x: String(bounds.x),
141
- y: String(bounds.y),
142
- width: String(bounds.width),
143
- height: String(bounds.height),
144
- });
145
- location.href = "impel-tasks://" + action + "?" + query.toString();
158
+ const query = new URLSearchParams(Object.entries(mainBounds()).map(([key, value]) => [key, String(value)]));
159
+ location.href = "impel-tasks://" + action + "?" + query;
146
160
  };
147
-
148
161
  const select = (selected) => {
149
162
  active = selected;
150
163
  if (!nav) return;
@@ -154,195 +167,90 @@ const NAVIGATION_SOURCE = String.raw`(() => {
154
167
  nav.classList.toggle(ACTIVE_CLASS, selected);
155
168
  };
156
169
  window.__impelDesktopTasksSetActive = select;
157
-
158
- const replaceText = (element) => {
159
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
160
- let changed = false;
161
- while (walker.nextNode()) {
162
- const text = walker.currentNode.nodeValue || "";
163
- if (!/(?:new(?:\s+(?:chat|thread))?|pull requests?|scheduled|plugins?|skills?|cowork|code)\b/i.test(text)) continue;
164
- walker.currentNode.nodeValue = text.replace(
165
- /(?:new(?:\s+(?:chat|thread))?|pull requests?|scheduled|plugins?|skills?|cowork|code)\b/i,
166
- "Tasks",
167
- );
168
- changed = true;
170
+ const visualRow = (control) => {
171
+ const controlRect = control.getBoundingClientRect();
172
+ let child = control;
173
+ let parent = control.parentElement;
174
+ while (parent && parent !== document.body) {
175
+ const rect = parent.getBoundingClientRect();
176
+ const sameVisualRow = Math.abs(rect.top - controlRect.top) <= 8
177
+ && Math.abs(rect.bottom - controlRect.bottom) <= 8;
178
+ if (!sameVisualRow) break;
179
+ child = parent;
180
+ parent = parent.parentElement;
169
181
  }
170
- return changed;
182
+ return parent && parent !== document.body ? { parent, child } : null;
171
183
  };
172
-
173
- const stripVendorActions = (element) => {
174
- for (const candidate of [element, ...element.querySelectorAll("*")]) {
184
+ const makeNav = (template) => {
185
+ const button = template.cloneNode(true);
186
+ for (const candidate of [button, ...button.querySelectorAll("*")]) {
175
187
  for (const attribute of [...candidate.attributes]) {
176
188
  if (attribute.name.startsWith("data-app-action-") || attribute.name === "data-testid") {
177
189
  candidate.removeAttribute(attribute.name);
178
190
  }
179
191
  }
180
192
  }
181
- element.removeAttribute("href");
182
- element.removeAttribute("target");
183
- element.querySelectorAll("[id]").forEach((candidate) => candidate.removeAttribute("id"));
184
- element.querySelectorAll("kbd, [data-slot=\"shortcut\"]").forEach((candidate) => candidate.remove());
185
- };
186
-
187
- const stripTemplateGlyphs = (element) => {
188
- element.querySelectorAll("svg, [aria-hidden=\"true\"]").forEach((candidate) => candidate.remove());
189
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
190
- const glyphs = [];
191
- while (walker.nextNode()) {
192
- if (/^(?:\+|+|<\/>|<>)$/u.test((walker.currentNode.nodeValue || "").trim())) {
193
- glyphs.push(walker.currentNode);
194
- }
195
- }
196
- glyphs.forEach((candidate) => {
197
- const parent = candidate.parentElement;
198
- candidate.remove();
199
- if (
200
- parent
201
- && parent !== element
202
- && !parent.textContent?.trim()
203
- && !parent.querySelector("svg, img")
204
- ) parent.remove();
205
- });
206
- [...element.querySelectorAll("*")].reverse().forEach((candidate) => {
207
- if (
208
- !candidate.textContent?.trim()
209
- && !candidate.querySelector("svg, img, input")
210
- ) candidate.remove();
211
- });
212
- };
213
-
214
- const makeNav = (template) => {
215
- const button = template ? template.cloneNode(true) : document.createElement("button");
216
- stripVendorActions(button);
217
- stripTemplateGlyphs(button);
193
+ button.querySelectorAll('svg, [aria-hidden="true"], kbd, [data-slot="shortcut"]').forEach((node) => node.remove());
194
+ button.querySelectorAll("[id]").forEach((node) => node.removeAttribute("id"));
195
+ button.removeAttribute("href");
196
+ button.removeAttribute("target");
218
197
  button.id = NAV_ID;
219
198
  button.type = "button";
220
199
  button.setAttribute("aria-label", "Tasks");
221
200
  button.setAttribute("title", "Tasks");
222
- button.classList.add("impel-desktop-tasks-nav");
223
- if (!replaceText(button)) button.textContent = "Tasks";
224
- button.insertAdjacentHTML("afterbegin", TASKS_ICON);
201
+ button.textContent = "Tasks";
202
+ button.insertAdjacentHTML("afterbegin", ${JSON.stringify(TASKS_ICON)});
225
203
  button.addEventListener("click", (event) => {
226
204
  event.preventDefault();
227
205
  event.stopPropagation();
228
206
  select(true);
229
207
  signal("show");
230
208
  });
231
- button.addEventListener("keydown", (event) => {
232
- if (event.key !== "Enter" && event.key !== " ") return;
233
- event.preventDefault();
234
- select(true);
235
- signal("show");
236
- });
237
209
  return button;
238
210
  };
239
-
240
- const placement = (control) => {
241
- if (!control) return null;
242
- const controlRect = control.getBoundingClientRect();
243
- let child = control;
244
- let parent = control.parentElement;
245
- while (parent && parent !== document.body) {
246
- const rect = parent.getBoundingClientRect();
247
- const sameVisualRow = Math.abs(rect.top - controlRect.top) <= 8
248
- && Math.abs(rect.bottom - controlRect.bottom) <= 8;
249
- if (!sameVisualRow) break;
250
- child = parent;
251
- parent = parent.parentElement;
252
- }
253
- return parent && parent !== document.body ? { stack: parent, child } : null;
254
- };
255
-
256
211
  const ensureNav = () => {
257
- let anchorRule = null;
212
+ let rule = null;
258
213
  let anchor = null;
259
- for (const rule of NAV_ANCHOR_RULES) {
260
- anchor = findControl(rule.pattern);
261
- if (anchor) {
262
- anchorRule = rule;
263
- break;
264
- }
214
+ for (const candidate of RULES) {
215
+ anchor = findControl(candidate.pattern);
216
+ if (anchor) { rule = candidate; break; }
265
217
  }
266
- const template = anchorRule?.template ? findControl(anchorRule.template) : anchor;
218
+ const template = rule?.template ? findControl(rule.template) : anchor;
267
219
  lastAnchor = anchor;
268
- // Claude mounts its Home/Code switcher before the New row. Do not clone the
269
- // switcher as a temporary fallback: its Code glyph would remain in the
270
- // injected control after the intended visual template appears.
271
220
  if (!anchor || !template) return;
272
221
  if (!nav?.isConnected) nav = makeNav(template);
273
- const target = placement(anchor);
274
- const templateTarget = placement(template);
222
+ const target = visualRow(anchor);
223
+ const templateTarget = visualRow(template);
275
224
  if (target) {
276
225
  if (!slot?.isConnected || (slot !== nav && !slot.contains(nav))) {
277
226
  slot = templateTarget?.child === template ? nav : templateTarget?.child.cloneNode(false) || nav;
278
- if (slot !== nav) {
279
- slot.id = NAV_SLOT_ID;
280
- slot.removeAttribute("aria-label");
281
- slot.removeAttribute("title");
282
- slot.append(nav);
283
- }
227
+ if (slot !== nav) { slot.id = SLOT_ID; slot.append(nav); }
284
228
  }
285
- const after = anchorRule?.position === "after";
286
- const correctlyPlaced = slot.parentElement === target.stack
287
- && (after ? slot.previousElementSibling === target.child : slot.nextElementSibling === target.child);
288
- if (!correctlyPlaced) {
289
- target.stack.insertBefore(slot, after ? target.child.nextElementSibling : target.child);
290
- }
291
- } else if (anchor?.parentElement) {
292
- anchor.insertAdjacentElement(anchorRule?.position === "after" ? "afterend" : "beforebegin", nav);
229
+ const reference = rule.position === "after" ? target.child.nextElementSibling : target.child;
230
+ if (slot.parentElement !== target.parent || slot !== reference) target.parent.insertBefore(slot, reference);
231
+ } else if (anchor.parentElement) {
232
+ anchor.insertAdjacentElement(rule.position === "after" ? "afterend" : "beforebegin", nav);
293
233
  slot = nav;
294
- } else if (!nav.isConnected) {
295
- slot = null;
296
234
  }
297
235
  select(active);
298
236
  };
299
-
300
237
  const style = document.createElement("style");
301
- style.id = "impel-desktop-tasks-nav-style";
302
- style.textContent = [
303
- "#" + NAV_ID + " svg { width: 16px; height: 16px; flex: 0 0 auto; }",
304
- "#" + NAV_ID + "." + ACTIVE_CLASS + " { background: color-mix(in srgb, CanvasText 10%, transparent); }",
305
- "#" + NAV_ID + ":focus-visible { outline: 2px solid #7c8cff; outline-offset: 2px; }",
306
- ].join("\n");
238
+ 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}";
307
239
  document.head.append(style);
308
-
309
240
  const onClick = (event) => {
310
241
  if (!active || event.target instanceof Element && event.target.closest("#" + NAV_ID)) return;
311
- const sidebar = findSidebar();
312
- if (!sidebar || !(event.target instanceof Node) || !sidebar.contains(event.target)) return;
313
- select(false);
314
- setTimeout(() => signal("hide"), 0);
315
- };
316
- const onResize = () => {
317
- if (active) signal("layout");
242
+ const side = sidebar();
243
+ if (side && event.target instanceof Node && side.contains(event.target)) {
244
+ select(false);
245
+ setTimeout(() => signal("hide"), 0);
246
+ }
318
247
  };
248
+ const onResize = () => { if (active) signal("layout"); };
319
249
  window.addEventListener("click", onClick, true);
320
250
  window.addEventListener("resize", onResize);
321
-
322
- const observer = new MutationObserver((records) => {
323
- const external = records.some((record) => {
324
- const target = record.target instanceof Element ? record.target : record.target?.parentElement;
325
- return target && !target.closest("#" + NAV_ID + ", #" + NAV_SLOT_ID);
326
- });
327
- if (external) ensureNav();
328
- });
251
+ const observer = new MutationObserver(() => ensureNav());
329
252
  observer.observe(document.body, { childList: true, subtree: true });
330
253
  ensureNav();
331
-
332
- const navRect = nav?.getBoundingClientRect();
333
- const result = {
334
- connected: Boolean(nav?.isConnected),
335
- visible: Boolean(nav && visible(nav)),
336
- text: nav?.textContent?.replace(/\s+/g, " ").trim() || null,
337
- anchor: lastAnchor ? label(lastAnchor) : null,
338
- bounds: navRect ? {
339
- x: Math.round(navRect.x),
340
- y: Math.round(navRect.y),
341
- width: Math.round(navRect.width),
342
- height: Math.round(navRect.height),
343
- } : null,
344
- };
345
-
346
254
  window.__impelDesktopTasksCleanup = () => {
347
255
  observer.disconnect();
348
256
  window.removeEventListener("click", onClick, true);
@@ -352,50 +260,15 @@ const NAVIGATION_SOURCE = String.raw`(() => {
352
260
  style.remove();
353
261
  delete window.__impelDesktopTasksSetActive;
354
262
  };
355
- return result;
263
+ return { anchor: lastAnchor ? label(lastAnchor) : null, connected: Boolean(nav?.isConnected) };
356
264
  })();`;
357
265
 
358
- const NATIVE_NAVIGATION_HTML = `<!doctype html>
359
- <html lang="en">
360
- <head>
361
- <meta charset="utf-8">
362
- <meta name="viewport" content="width=device-width,initial-scale=1">
363
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'">
364
- <style>
365
- :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
366
- * { box-sizing: border-box; }
367
- html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
368
- button {
369
- width: 100%; height: 100%; display: flex; align-items: center; gap: 9px;
370
- border: 0; border-radius: 8px; padding: 0 12px;
371
- background: color-mix(in srgb, Canvas 92%, CanvasText 8%); color: CanvasText;
372
- font: 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
373
- text-align: left; cursor: pointer;
374
- }
375
- button:hover, button[data-active="true"] { background: color-mix(in srgb, CanvasText 13%, Canvas); }
376
- button:focus-visible { outline: 2px solid #7c8cff; outline-offset: -2px; }
377
- svg { width: 16px; height: 16px; flex: 0 0 auto; }
378
- </style>
379
- </head>
380
- <body>
381
- <button id="tasks" type="button" aria-label="Tasks">
382
- <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>
383
- <span>Tasks</span>
384
- </button>
385
- <script>
386
- const button = document.getElementById("tasks");
387
- button.addEventListener("pointerdown", (event) => {
388
- if (event.button !== 0) return;
389
- event.preventDefault();
390
- button.dataset.active = "true";
391
- window.impelDesktopTasks.show();
392
- });
393
- window.__impelDesktopTasksSetActive = (active) => { button.dataset.active = String(Boolean(active)); };
394
- </script>
395
- </body>
396
- </html>`;
266
+ 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>`;
397
267
 
398
- export function desktopTasksMainPreload() {
268
+ export function desktopTasksMainPreload(appUrl) {
269
+ const appOrigin = desktopTasksAppOrigin(appUrl);
270
+ const parserSource = parseDesktopTasksCredential.toString();
271
+ const escapeRegexSource = escapeRegex.toString();
399
272
  return `"use strict";
400
273
 
401
274
  if (
@@ -403,36 +276,29 @@ if (
403
276
  && process.env.IMPEL_DESKTOP_TASKS_NODE
404
277
  && process.env.IMPEL_DESKTOP_TASKS_CLI
405
278
  && process.env.IMPEL_DESKTOP_TASKS_TENANT
406
- && process.env.IMPEL_DESKTOP_TASKS_HOST_HTML
407
279
  && process.env.IMPEL_DESKTOP_TASKS_VIEW_PRELOAD
408
280
  && !globalThis.__impelDesktopTasksMainInstalled
409
281
  ) {
410
282
  globalThis.__impelDesktopTasksMainInstalled = true;
411
283
  const fs = require("node:fs");
412
284
  const path = require("node:path");
413
- const readline = require("node:readline");
414
285
  const { spawn } = require("node:child_process");
415
- const { pathToFileURL } = require("node:url");
416
286
  const navSource = ${JSON.stringify(NAVIGATION_SOURCE)};
417
287
  const nativeNavUrl = "data:text/html;charset=utf-8," + encodeURIComponent(${JSON.stringify(NATIVE_NAVIGATION_HTML)});
418
- const nodePath = process.env.IMPEL_DESKTOP_TASKS_NODE;
419
- const cliPath = process.env.IMPEL_DESKTOP_TASKS_CLI;
288
+ const appOrigin = ${JSON.stringify(appOrigin)};
289
+ const bootstrapUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOOTSTRAP_PATH)};
290
+ const tasksUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOARD_PATH)};
291
+ const readyMarker = ${JSON.stringify(DESKTOP_TASKS_READY_MARKER)};
420
292
  const tenantId = process.env.IMPEL_DESKTOP_TASKS_TENANT;
421
- const hostHtml = process.env.IMPEL_DESKTOP_TASKS_HOST_HTML;
422
- const viewPreload = process.env.IMPEL_DESKTOP_TASKS_VIEW_PRELOAD;
423
- const resourceUri = ${JSON.stringify(DESKTOP_TASKS_RESOURCE_URI)};
424
- const rpcChannel = "impel-desktop-tasks:rpc";
425
- const openLinkChannel = "impel-desktop-tasks:open-link";
426
- const closeChannel = "impel-desktop-tasks:close";
427
293
  const nativeShowChannel = "impel-desktop-tasks:native-show";
428
- const runtimeStatusPath = path.join(path.dirname(hostHtml), "runtime-status.json");
429
- const record = (stage, error = null) => {
294
+ const runtimeStatusPath = path.join(__dirname, "runtime-status.json");
295
+ const escapeRegex = ${escapeRegexSource};
296
+ const parseCredential = ${parserSource};
297
+ const record = (stage) => {
430
298
  try {
431
- const detail = error instanceof Error ? error.message : error == null ? null : String(error);
432
299
  fs.writeFileSync(runtimeStatusPath, JSON.stringify({
433
- schemaVersion: 1,
300
+ schemaVersion: 2,
434
301
  stage,
435
- detail,
436
302
  processType: process.type || null,
437
303
  electronVersion: process.versions.electron || null,
438
304
  pid: process.pid,
@@ -440,366 +306,344 @@ if (
440
306
  }, null, 2) + "\\n", { mode: 0o600 });
441
307
  } catch {}
442
308
  };
443
-
444
- class TasksMcpClient {
445
- constructor() {
446
- this.child = null;
447
- this.starting = null;
448
- this.pending = new Map();
449
- this.sequence = 0;
450
- }
451
-
452
- failPending(message) {
453
- for (const entry of this.pending.values()) entry.reject(new Error(message));
454
- this.pending.clear();
455
- }
456
-
457
- send(method, params) {
458
- if (!this.child?.stdin?.writable) return Promise.reject(new Error("Tasks bridge is unavailable."));
459
- const id = ++this.sequence;
460
- return new Promise((resolve, reject) => {
461
- const timer = setTimeout(() => {
462
- this.pending.delete(id);
463
- try {
464
- this.child?.stdin?.write(JSON.stringify({
465
- jsonrpc: "2.0",
466
- method: "notifications/cancelled",
467
- params: { requestId: id, reason: "desktop Tasks request timed out" },
468
- }) + "\\n");
469
- } catch {}
470
- reject(new Error("Tasks request timed out."));
471
- }, 75000);
472
- this.pending.set(id, {
473
- resolve: (value) => { clearTimeout(timer); resolve(value); },
474
- reject: (error) => { clearTimeout(timer); reject(error); },
475
- });
476
- this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\\n");
477
- });
478
- }
479
-
480
- async ensureStarted() {
481
- if (this.child?.stdin?.writable) return;
482
- if (this.starting) return this.starting;
483
- this.starting = (async () => {
484
- if (!nodePath || !cliPath || !tenantId) throw new Error("Tasks bridge configuration is incomplete.");
485
- const child = spawn(nodePath, [cliPath, "mcp", "--target", "tasks", "--tenant", tenantId], {
486
- stdio: ["pipe", "pipe", "pipe"],
487
- env: { ...process.env, IMPEL_MANAGED_MCP: "1" },
488
- });
489
- this.child = child;
490
- child.stderr.resume();
491
- const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
492
- lines.on("line", (line) => {
493
- let message;
494
- try { message = JSON.parse(line); } catch { return; }
495
- const entry = this.pending.get(message.id);
496
- if (!entry) return;
497
- this.pending.delete(message.id);
498
- if (message.error) entry.reject(new Error(message.error.message || "Tasks request failed."));
499
- else entry.resolve(message.result);
500
- });
501
- const closed = () => {
502
- if (this.child === child) this.child = null;
503
- this.failPending("Tasks bridge exited.");
504
- };
505
- child.once("error", closed);
506
- child.once("exit", closed);
507
- try {
508
- await this.send("initialize", {
509
- protocolVersion: "2025-06-18",
510
- capabilities: {},
511
- clientInfo: { name: "impel-desktop-tasks", version: "1.0.0" },
512
- });
513
- child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\\n");
514
- } catch (error) {
515
- if (this.child === child) this.child = null;
516
- try { child.kill(); } catch {}
517
- throw error;
518
- }
519
- })().finally(() => { this.starting = null; });
520
- return this.starting;
521
- }
522
-
523
- async request(method, params) {
524
- await this.ensureStarted();
525
- return this.send(method, params);
526
- }
527
-
528
- close() {
529
- const child = this.child;
530
- this.child = null;
531
- this.failPending("Tasks bridge closed.");
532
- try { child?.kill(); } catch {}
309
+ const token = () => new Promise((resolve, reject) => {
310
+ const childEnvironment = { ...process.env };
311
+ for (const name of ["ANTHROPIC_API_KEY", "CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY"]) {
312
+ delete childEnvironment[name];
533
313
  }
534
- }
314
+ const child = spawn(
315
+ process.env.IMPEL_DESKTOP_TASKS_NODE,
316
+ [process.env.IMPEL_DESKTOP_TASKS_CLI, "token", "--tenant", tenantId],
317
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: childEnvironment },
318
+ );
319
+ let stdout = "";
320
+ let stderrBytes = 0;
321
+ let settled = false;
322
+ const finish = (error, value) => {
323
+ if (settled) return;
324
+ settled = true;
325
+ clearTimeout(timer);
326
+ if (error) reject(error); else resolve(value);
327
+ };
328
+ const timer = setTimeout(() => {
329
+ try { child.kill(); } catch {}
330
+ finish(new Error("desktop Tasks token helper timed out"));
331
+ }, 15000);
332
+ child.stdout.on("data", (chunk) => {
333
+ stdout += chunk.toString("utf8");
334
+ if (stdout.length > 8192) {
335
+ try { child.kill(); } catch {}
336
+ finish(new Error("desktop Tasks token helper output was too large"));
337
+ }
338
+ });
339
+ child.stderr.on("data", (chunk) => {
340
+ stderrBytes += chunk.length;
341
+ if (stderrBytes > 8192) {
342
+ try { child.kill(); } catch {}
343
+ finish(new Error("desktop Tasks token helper output was too large"));
344
+ }
345
+ });
346
+ child.once("error", () => finish(new Error("desktop Tasks token helper failed")));
347
+ child.once("close", (code) => {
348
+ if (code !== 0) return finish(new Error("desktop Tasks token helper failed"));
349
+ try {
350
+ finish(null, parseCredential(stdout, tenantId, {
351
+ patPrefix: ${JSON.stringify(RUNTIME_BRAND.auth.patPrefix)},
352
+ tenantPrefix: ${JSON.stringify(RUNTIME_BRAND.auth.tenantPrefix)},
353
+ }));
354
+ } catch {
355
+ finish(new Error("desktop Tasks token helper returned invalid output"));
356
+ }
357
+ });
358
+ });
535
359
 
536
360
  const install = (electron) => {
537
- const {
538
- app,
539
- BrowserWindow,
540
- WebContentsView,
541
- ipcMain,
542
- shell,
543
- webContents: electronWebContents,
544
- } = electron;
545
- if (!app || !BrowserWindow || !WebContentsView || !ipcMain || !electronWebContents) {
361
+ const { app, BrowserWindow, WebContentsView, ipcMain, shell, webContents } = electron;
362
+ if (!app || !BrowserWindow || !WebContentsView || !ipcMain || !webContents) {
546
363
  record("unsupported-electron-surface");
547
364
  return;
548
365
  }
549
- const client = new TasksMcpClient();
550
366
  const windows = new Map();
551
- const allowedViewContents = new Set();
552
- const internalViewContents = new Set();
367
+ const internalContents = new Set();
553
368
  const nativeNavOwners = new Map();
554
- const hostUrl = pathToFileURL(hostHtml).href;
555
- let hasAnyDomNavigation = false;
369
+ const configuredSessions = new WeakSet();
556
370
  let creatingInternalWindow = false;
371
+ let hasDomNavigation = false;
557
372
  const createInternalWindow = (options) => {
558
373
  creatingInternalWindow = true;
559
- try {
560
- return new BrowserWindow(options);
561
- } finally {
562
- creatingInternalWindow = false;
563
- }
564
- };
565
-
566
- const parseBounds = (url, window) => {
567
- const parsed = new URL(url);
568
- const content = window.getContentBounds();
569
- const interactiveTop = process.platform === "darwin" ? 48 : 0;
570
- const number = (name, fallback) => {
571
- const value = Number.parseInt(parsed.searchParams.get(name) || "", 10);
572
- return Number.isFinite(value) ? value : fallback;
573
- };
574
- const x = Math.max(0, Math.min(content.width - 240, number("x", 0)));
575
- const y = Math.max(
576
- interactiveTop,
577
- Math.min(content.height - 240, number("y", 0)),
578
- );
579
- return {
580
- x,
581
- y,
582
- width: Math.max(240, Math.min(content.width - x, number("width", content.width - x))),
583
- height: Math.max(240, Math.min(content.height - y, number("height", content.height - y))),
584
- };
374
+ try { return new BrowserWindow(options); }
375
+ finally { creatingInternalWindow = false; }
585
376
  };
586
-
587
377
  const stateFor = (window) => {
588
378
  let state = windows.get(window.id);
589
379
  if (!state) {
590
- state = {
591
- view: null,
592
- bounds: null,
593
- visible: false,
594
- nativeNav: null,
595
- hasDomNavigation: false,
596
- };
380
+ state = { view: null, bounds: null, visible: false, nativeNav: null, hasDomNavigation: false, loading: null, bootstrapping: false };
597
381
  windows.set(window.id, state);
598
382
  }
599
383
  return state;
600
384
  };
601
-
385
+ const exactOrigin = (rawUrl) => {
386
+ try { return new URL(rawUrl).origin === appOrigin; }
387
+ catch { return false; }
388
+ };
389
+ const external = (rawUrl) => {
390
+ try {
391
+ const url = new URL(rawUrl);
392
+ if (url.protocol === "https:" && !url.username && !url.password && url.origin !== appOrigin) {
393
+ shell.openExternal(url.href).catch(() => record("external-link-failed"));
394
+ }
395
+ } catch {}
396
+ };
397
+ const configureSession = (session) => {
398
+ if (configuredSessions.has(session)) return;
399
+ configuredSessions.add(session);
400
+ session.setPermissionCheckHandler(() => false);
401
+ session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
402
+ session.on("will-download", (event) => event.preventDefault());
403
+ const allowedOrigins = new Set([
404
+ appOrigin,
405
+ "https://api.liveblocks.io",
406
+ "wss://api.liveblocks.io",
407
+ "https://api.dicebear.com",
408
+ "https://avatars.githubusercontent.com",
409
+ ]);
410
+ session.webRequest.onBeforeRequest({ urls: ["<all_urls>"] }, (details, callback) => {
411
+ let allowed = false;
412
+ try {
413
+ const url = new URL(details.url);
414
+ allowed = ["data:", "blob:"].includes(url.protocol) || allowedOrigins.has(url.origin);
415
+ } catch {}
416
+ callback({ cancel: !allowed });
417
+ });
418
+ };
419
+ const errorDocument = "data:text/html;charset=utf-8," + encodeURIComponent(
420
+ "<!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>",
421
+ );
422
+ const loadBoard = async (state) => {
423
+ state.bootstrapping = true;
424
+ let pat = null;
425
+ try {
426
+ pat = await token();
427
+ const request = {
428
+ extraHeaders: [
429
+ "Authorization: Bearer " + pat,
430
+ "X-Impel-Tenant: " + tenantId,
431
+ "Cache-Control: no-store",
432
+ "Pragma: no-cache",
433
+ ].join("\\n"),
434
+ postData: [{ type: "rawData", bytes: Buffer.alloc(0) }],
435
+ };
436
+ await state.view.webContents.loadURL(bootstrapUrl, request);
437
+ if (state.view.webContents.getURL() !== bootstrapUrl) throw new Error("bootstrap redirected");
438
+ const ready = await state.view.webContents.executeJavaScript(
439
+ "document.body?.textContent?.trim() === " + JSON.stringify(readyMarker),
440
+ true,
441
+ );
442
+ if (!ready) throw new Error("bootstrap marker missing");
443
+ } catch {
444
+ record("board-bootstrap-failed");
445
+ await state.view.webContents.loadURL(errorDocument).catch(() => {});
446
+ return;
447
+ } finally {
448
+ pat = null;
449
+ state.bootstrapping = false;
450
+ }
451
+ try {
452
+ await state.view.webContents.loadURL(tasksUrl);
453
+ record("board-loaded");
454
+ } catch {
455
+ record("board-load-failed");
456
+ await state.view.webContents.loadURL(errorDocument).catch(() => {});
457
+ }
458
+ };
459
+ const deactivateNavigation = () => {
460
+ for (const state of windows.values()) {
461
+ state.nativeNav?.webContents.executeJavaScript("window.__impelDesktopTasksSetActive?.(false)", true).catch(() => {});
462
+ }
463
+ for (const contents of webContents.getAllWebContents()) {
464
+ if (internalContents.has(contents) || contents.isDestroyed()) continue;
465
+ for (const frame of contents.mainFrame?.framesInSubtree || []) {
466
+ frame.executeJavaScript("window.__impelDesktopTasksSetActive?.(false)", true).catch(() => {});
467
+ }
468
+ }
469
+ };
470
+ const hide = (window) => {
471
+ const state = windows.get(window.id);
472
+ if (!state) return;
473
+ state.visible = false;
474
+ state.view?.setVisible(false);
475
+ window.webContents.focus();
476
+ deactivateNavigation();
477
+ };
478
+ 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;
485
+ };
602
486
  const createView = (window) => {
603
487
  const state = stateFor(window);
604
488
  if (state.view && !state.view.webContents.isDestroyed()) return state;
605
489
  const view = new WebContentsView({
606
490
  webPreferences: {
607
- preload: viewPreload,
608
491
  contextIsolation: true,
609
492
  nodeIntegration: false,
610
493
  sandbox: true,
611
494
  spellcheck: false,
495
+ partition: "persist:impel-desktop-tasks",
612
496
  },
613
497
  });
498
+ state.view = view;
499
+ internalContents.add(view.webContents);
500
+ configureSession(view.webContents.session);
614
501
  window.contentView.addChildView(view);
615
502
  view.setVisible(false);
616
503
  view.setBackgroundColor("#181818");
617
- allowedViewContents.add(view.webContents);
618
- internalViewContents.add(view.webContents);
619
- view.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
504
+ view.webContents.setWindowOpenHandler(({ url }) => {
505
+ if (exactOrigin(url)) view.webContents.loadURL(url).catch(() => record("board-navigation-failed"));
506
+ else external(url);
507
+ return { action: "deny" };
508
+ });
620
509
  view.webContents.on("will-navigate", (event, url) => {
621
- if (url !== hostUrl) event.preventDefault();
510
+ if (exactOrigin(url)) return;
511
+ event.preventDefault();
512
+ external(url);
513
+ });
514
+ view.webContents.on("will-redirect", (event, url) => {
515
+ if (state.bootstrapping || !exactOrigin(url)) event.preventDefault();
516
+ });
517
+ view.webContents.on("before-input-event", (event, input) => {
518
+ if (input.key === "Escape" && input.type === "keyDown") {
519
+ event.preventDefault();
520
+ hide(window);
521
+ }
622
522
  });
623
523
  view.webContents.on("destroyed", () => {
624
- allowedViewContents.delete(view.webContents);
625
- internalViewContents.delete(view.webContents);
524
+ internalContents.delete(view.webContents);
626
525
  try { window.contentView.removeChildView(view); } catch {}
627
526
  if (state.view === view) state.view = null;
628
527
  });
629
- view.webContents.loadURL(hostUrl).catch((error) => record("view-load-failed", error));
630
- state.view = view;
631
528
  return state;
632
529
  };
633
-
634
530
  const show = (window, bounds) => {
635
531
  const state = createView(window);
636
532
  state.bounds = bounds;
637
- state.visible = true;
638
533
  state.view.setBounds(bounds);
639
534
  state.view.setVisible(true);
640
535
  state.view.webContents.focus();
641
- const boardStatus = () => ({
642
- parentBounds: window.getBounds(),
643
- parentContentBounds: window.getContentBounds(),
644
- boardBounds: state.view.getBounds(),
645
- boardVisible: state.visible,
646
- boardFocused: state.view.webContents.isFocused(),
647
- });
648
- record("board-shown", JSON.stringify(boardStatus()));
649
- setTimeout(() => {
650
- if (state.view && !state.view.webContents.isDestroyed()) record("board-visible-check", JSON.stringify(boardStatus()));
651
- }, 2000);
652
- };
653
- const hide = (window) => {
654
- const state = windows.get(window.id);
655
- if (!state) return;
656
- state.visible = false;
657
- state.view?.setVisible(false);
658
- window.webContents.focus();
536
+ if (state.visible) return;
537
+ state.visible = true;
538
+ state.loading = loadBoard(state).finally(() => { state.loading = null; });
539
+ record("board-shown");
659
540
  };
660
-
661
- const layoutBoard = (window) => {
541
+ const layoutBoard = (window, bounds = null) => {
662
542
  const state = windows.get(window.id);
663
- if (!state?.visible || !state.view || !state.bounds || state.view.webContents.isDestroyed()) return;
664
- state.view.setBounds(state.bounds);
543
+ if (!state?.visible || !state.view || state.view.webContents.isDestroyed()) return;
544
+ if (bounds) state.bounds = bounds;
545
+ if (state.bounds) state.view.setBounds(state.bounds);
665
546
  };
666
-
667
- const nativeBoardBounds = (window) => {
547
+ const parseBounds = (rawUrl, window) => {
548
+ const parsed = new URL(rawUrl);
668
549
  const content = window.getContentBounds();
669
- const x = Math.min(296, Math.max(0, content.width - 240));
670
550
  const interactiveTop = process.platform === "darwin" ? 48 : 0;
551
+ const number = (name, fallback) => {
552
+ const value = Number.parseInt(parsed.searchParams.get(name) || "", 10);
553
+ return Number.isFinite(value) ? value : fallback;
554
+ };
555
+ const x = Math.max(0, Math.min(content.width - 240, number("x", 0)));
556
+ const y = Math.max(interactiveTop, Math.min(content.height - 240, number("y", interactiveTop)));
671
557
  return {
672
558
  x,
673
- y: interactiveTop,
674
- width: Math.max(240, content.width - x),
675
- height: Math.max(240, content.height - interactiveTop),
559
+ y,
560
+ width: Math.max(240, Math.min(content.width - x, number("width", content.width - x))),
561
+ height: Math.max(240, Math.min(content.height - y, number("height", content.height - y))),
676
562
  };
677
563
  };
678
-
564
+ const nativeBoardBounds = (window) => {
565
+ const content = window.getContentBounds();
566
+ const x = Math.min(296, Math.max(0, content.width - 240));
567
+ const interactiveTop = process.platform === "darwin" ? 48 : 0;
568
+ return { x, y: interactiveTop, width: Math.max(240, content.width - x), height: Math.max(240, content.height - interactiveTop) };
569
+ };
679
570
  const layoutNativeNav = (window) => {
680
571
  const state = windows.get(window.id);
681
572
  if (!state?.nativeNav || state.nativeNav.isDestroyed()) return;
682
573
  const content = window.getContentBounds();
683
- state.nativeNav.setBounds({
684
- x: content.x + 12,
685
- y: content.y + Math.max(48, content.height - 92),
686
- width: Math.min(120, Math.max(80, content.width - 24)),
687
- height: 38,
688
- });
574
+ 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 });
689
575
  };
690
-
691
576
  const ensureNativeNav = (window) => {
692
- if (!window || window.isDestroyed()) return;
577
+ if (!isPrimaryWindow(window)) return;
693
578
  const state = stateFor(window);
694
579
  if (state.nativeNav && !state.nativeNav.isDestroyed()) return;
695
- const navView = createInternalWindow({
580
+ // Auxiliary vendor windows (for example Codex's hidden avatar overlay)
581
+ // are also top-level BrowserWindows. Never give them their own Tasks
582
+ // fallback: duplicate child windows interfere with first-run navigation
583
+ // and can cover the primary shell.
584
+ if ([...windows.values()].some((candidate) => (
585
+ candidate.nativeNav && !candidate.nativeNav.isDestroyed()
586
+ ))) return;
587
+ const nativeNav = createInternalWindow({
696
588
  parent: window,
697
589
  frame: false,
698
- transparent: false,
699
590
  show: false,
700
591
  resizable: false,
701
- movable: false,
702
- minimizable: false,
703
- maximizable: false,
704
- fullscreenable: false,
705
592
  skipTaskbar: true,
706
- hasShadow: false,
707
- acceptFirstMouse: true,
708
593
  backgroundColor: "#292929",
709
594
  webPreferences: {
710
- preload: viewPreload,
595
+ preload: process.env.IMPEL_DESKTOP_TASKS_VIEW_PRELOAD,
711
596
  contextIsolation: true,
712
597
  nodeIntegration: false,
713
598
  sandbox: true,
714
- spellcheck: false,
715
599
  },
716
600
  });
717
- navView.setAlwaysOnTop(true, "floating");
718
- state.nativeNav = navView;
719
- internalViewContents.add(navView.webContents);
720
- nativeNavOwners.set(navView.webContents, window);
721
- navView.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
722
- navView.webContents.on("will-navigate", (event, url) => {
723
- if (url !== nativeNavUrl) event.preventDefault();
724
- });
725
- navView.webContents.on("destroyed", () => {
726
- internalViewContents.delete(navView.webContents);
727
- nativeNavOwners.delete(navView.webContents);
728
- });
729
- navView.webContents.on("did-finish-load", () => {
730
- navView.webContents.executeJavaScript(
731
- "typeof window.impelDesktopTasks?.show === 'function'",
732
- true,
733
- ).then((ready) => record(ready ? "native-navigation-ready" : "native-navigation-bridge-missing"))
734
- .catch((error) => record("native-navigation-check-failed", error));
601
+ state.nativeNav = nativeNav;
602
+ internalContents.add(nativeNav.webContents);
603
+ nativeNavOwners.set(nativeNav.webContents, window);
604
+ nativeNav.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
605
+ nativeNav.webContents.on("will-navigate", (event, url) => { if (url !== nativeNavUrl) event.preventDefault(); });
606
+ nativeNav.webContents.on("destroyed", () => {
607
+ internalContents.delete(nativeNav.webContents);
608
+ nativeNavOwners.delete(nativeNav.webContents);
735
609
  });
736
- navView.webContents.loadURL(nativeNavUrl).catch((error) => record("native-navigation-load-failed", error));
610
+ nativeNav.webContents.loadURL(nativeNavUrl).catch(() => record("native-navigation-load-failed"));
737
611
  layoutNativeNav(window);
738
- navView.showInactive();
739
- navView.moveTop();
612
+ nativeNav.showInactive();
740
613
  record("native-navigation-installed");
741
614
  };
742
-
743
615
  const removeNativeNav = (window) => {
744
616
  const state = windows.get(window.id);
745
- if (!state?.nativeNav) return;
746
- const navView = state.nativeNav;
617
+ const nativeNav = state?.nativeNav;
618
+ if (!nativeNav) return;
747
619
  state.nativeNav = null;
748
- internalViewContents.delete(navView.webContents);
749
- nativeNavOwners.delete(navView.webContents);
750
- try {
751
- navView.destroy();
752
- } catch {}
753
- };
754
-
755
- const ownerWindow = (contents) => {
756
- const direct = BrowserWindow.fromWebContents(contents);
757
- if (direct && !direct.isDestroyed()) return direct;
758
- const owner = contents.getOwnerBrowserWindow?.();
759
- if (owner && !owner.isDestroyed()) return owner;
760
- const focused = BrowserWindow.getFocusedWindow();
761
- if (focused && !focused.isDestroyed()) return focused;
762
- return BrowserWindow.getAllWindows().find((window) => !window.isDestroyed() && window.isVisible())
763
- || BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
764
- || null;
620
+ internalContents.delete(nativeNav.webContents);
621
+ nativeNavOwners.delete(nativeNav.webContents);
622
+ try { nativeNav.destroy(); } catch {}
765
623
  };
766
-
624
+ const ownerWindow = (contents) => BrowserWindow.fromWebContents(contents)
625
+ || contents.getOwnerBrowserWindow?.()
626
+ || BrowserWindow.getFocusedWindow()
627
+ || BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
628
+ || null;
767
629
  const attachContents = (contents) => {
768
- if (
769
- !contents
770
- || contents.isDestroyed()
771
- || contents.__impelDesktopTasksAttached
772
- || internalViewContents.has(contents)
773
- ) return;
630
+ if (!contents || contents.isDestroyed() || contents.__impelDesktopTasksAttached || internalContents.has(contents)) return;
774
631
  contents.__impelDesktopTasksAttached = true;
775
- const surface = () => ({
776
- id: contents.id,
777
- type: contents.getType?.() || null,
778
- url: contents.getURL?.() || null,
779
- });
780
632
  const inject = (attempt = 0) => {
781
- if (internalViewContents.has(contents) || contents.getURL?.() === hostUrl) return;
782
- const frames = contents.mainFrame?.framesInSubtree || [];
783
- Promise.all(frames.map(async (frame) => ({
784
- frameUrl: frame.url,
785
- result: await frame.executeJavaScript(navSource, true),
786
- })))
633
+ if (internalContents.has(contents)) return;
634
+ Promise.all((contents.mainFrame?.framesInSubtree || []).map((frame) => frame.executeJavaScript(navSource, true)))
787
635
  .then((results) => {
788
- record("navigation-injected", JSON.stringify({ ...surface(), frames: results }));
789
636
  const window = ownerWindow(contents);
790
637
  if (!window) return;
791
638
  const state = stateFor(window);
792
- if (results.some(({ result }) => result?.anchor)) {
793
- hasAnyDomNavigation = true;
639
+ if (results.some((result) => result?.anchor)) {
640
+ hasDomNavigation = true;
794
641
  state.hasDomNavigation = true;
795
642
  for (const owner of BrowserWindow.getAllWindows()) removeNativeNav(owner);
796
643
  } else if (attempt < 3) setTimeout(() => inject(attempt + 1), 1000);
797
- else if (!state.hasDomNavigation && !hasAnyDomNavigation) ensureNativeNav(window);
644
+ else if (!state.hasDomNavigation && !hasDomNavigation) ensureNativeNav(window);
798
645
  })
799
- .catch((error) => record(
800
- "navigation-injection-failed",
801
- JSON.stringify(surface()) + ": " + String(error),
802
- ));
646
+ .catch(() => record("navigation-injection-failed"));
803
647
  };
804
648
  contents.on("did-finish-load", () => inject());
805
649
  contents.on("did-frame-finish-load", () => inject());
@@ -813,45 +657,29 @@ if (
813
657
  try { action = new URL(url).hostname; } catch { return; }
814
658
  if (action === "hide") hide(window);
815
659
  else if (action === "show") show(window, parseBounds(url, window));
816
- else if (action === "layout") {
817
- const state = windows.get(window.id);
818
- if (state?.visible) show(window, parseBounds(url, window));
819
- }
660
+ else if (action === "layout") layoutBoard(window, parseBounds(url, window));
820
661
  };
821
662
  contents.on("will-navigate", handleNavigation);
822
663
  contents.on("will-frame-navigate", handleNavigation);
823
664
  };
824
-
825
665
  const attachWindow = (window) => {
826
- if (
827
- !window
828
- || window.isDestroyed()
829
- || creatingInternalWindow
830
- || window.getParentWindow?.()
831
- || window.__impelDesktopTasksWindowAttached
832
- ) return;
666
+ if (!window || window.isDestroyed() || creatingInternalWindow || window.getParentWindow?.() || window.__impelDesktopTasksWindowAttached) return;
833
667
  window.__impelDesktopTasksWindowAttached = true;
834
668
  attachContents(window.webContents);
835
- window.on("resize", () => {
836
- layoutNativeNav(window);
837
- layoutBoard(window);
838
- });
839
- window.on("move", () => layoutBoard(window));
669
+ window.on("resize", () => { layoutNativeNav(window); layoutBoard(window); });
840
670
  window.on("focus", () => {
841
671
  const state = windows.get(window.id);
842
- if (state?.nativeNav && !state.nativeNav.isDestroyed()) {
843
- state.nativeNav.showInactive();
844
- state.nativeNav.moveTop();
845
- }
846
- if (state?.visible && state.view && !state.view.webContents.isDestroyed()) state.view.setVisible(true);
672
+ state?.nativeNav?.showInactive();
673
+ if (state?.visible) state.view?.setVisible(true);
847
674
  });
848
675
  window.on("blur", () => setTimeout(() => {
849
676
  const state = windows.get(window.id);
850
- const focused = BrowserWindow.getFocusedWindow();
851
- if (focused === state?.nativeNav) return;
852
- state?.nativeNav?.hide();
853
- state?.view?.setVisible(false);
854
- }, 1000));
677
+ if (BrowserWindow.getFocusedWindow() === state?.nativeNav) return;
678
+ // Keep the only path to Tasks available while the app is frontmost.
679
+ // BrowserWindow blur also fires when a user clicks the child fallback;
680
+ // hiding it here made the button disappear before its click arrived.
681
+ if (state?.nativeNav && (!window.isVisible() || window.isMinimized())) state.nativeNav.hide();
682
+ }, 150));
855
683
  window.on("minimize", () => {
856
684
  const state = windows.get(window.id);
857
685
  state?.nativeNav?.hide();
@@ -864,231 +692,36 @@ if (
864
692
  });
865
693
  window.on("closed", () => {
866
694
  const state = windows.get(window.id);
867
- if (state) {
868
- if (state.view) {
869
- allowedViewContents.delete(state.view.webContents);
870
- internalViewContents.delete(state.view.webContents);
871
- }
872
- if (state.nativeNav) internalViewContents.delete(state.nativeNav.webContents);
873
- if (state.nativeNav) nativeNavOwners.delete(state.nativeNav.webContents);
874
- try {
875
- if (state.view) {
876
- window.contentView.removeChildView(state.view);
877
- state.view.webContents.close();
878
- }
879
- if (state.nativeNav) {
880
- state.nativeNav.destroy();
881
- }
882
- } catch {}
883
- windows.delete(window.id);
884
- }
695
+ if (!state) return;
696
+ try { if (state.view) window.contentView.removeChildView(state.view); } catch {}
697
+ try { state.view?.webContents.close(); } catch {}
698
+ try { state.nativeNav?.destroy(); } catch {}
699
+ windows.delete(window.id);
885
700
  });
886
701
  };
887
-
888
- ipcMain.handle(rpcChannel, async (event, request) => {
889
- if (!allowedViewContents.has(event.sender)) throw new Error("Untrusted Tasks view.");
890
- if (request?.method === "resources/read" && request?.params?.uri === resourceUri) {
891
- return client.request("resources/read", { uri: resourceUri });
892
- }
893
- if (request?.method === "tools/call") {
894
- const name = request?.params?.name;
895
- const allowed = new Set([
896
- "show_tasks",
897
- "list_tasks",
898
- "get_task",
899
- "list_members",
900
- "create_task",
901
- "update_task",
902
- "delete_task",
903
- ]);
904
- if (!allowed.has(name)) throw new Error("Unsupported Tasks tool.");
905
- const args = request?.params?.arguments;
906
- if (args != null && (typeof args !== "object" || Array.isArray(args))) {
907
- throw new Error("Invalid Tasks tool arguments.");
908
- }
909
- return client.request("tools/call", { name, arguments: args || {} });
910
- }
911
- throw new Error("Unsupported Tasks request.");
912
- });
913
- ipcMain.handle(openLinkChannel, async (event, rawUrl) => {
914
- if (!allowedViewContents.has(event.sender)) throw new Error("Untrusted Tasks view.");
915
- const url = new URL(String(rawUrl));
916
- if (url.protocol !== "https:" || url.username || url.password) throw new Error("Invalid Tasks link.");
917
- await shell.openExternal(url.href);
918
- return {};
919
- });
920
- ipcMain.on(closeChannel, (event) => {
921
- if (!allowedViewContents.has(event.sender)) return;
922
- for (const window of BrowserWindow.getAllWindows()) {
923
- if (windows.get(window.id)?.view?.webContents === event.sender) {
924
- hide(window);
925
- const nativeNav = windows.get(window.id)?.nativeNav;
926
- nativeNav?.webContents.executeJavaScript(
927
- "window.__impelDesktopTasksSetActive?.(false);",
928
- true,
929
- ).catch(() => {});
930
- for (const contents of electronWebContents.getAllWebContents()) {
931
- if (internalViewContents.has(contents) || contents.isDestroyed()) continue;
932
- for (const frame of contents.mainFrame?.framesInSubtree || []) {
933
- frame.executeJavaScript(
934
- "window.__impelDesktopTasksSetActive?.(false);",
935
- true,
936
- ).catch(() => {});
937
- }
938
- }
939
- break;
940
- }
941
- }
942
- });
943
702
  ipcMain.on(nativeShowChannel, (event) => {
944
703
  const window = nativeNavOwners.get(event.sender);
945
- if (!window || window.isDestroyed()) return;
946
- record("native-navigation-selected");
947
- show(window, nativeBoardBounds(window));
704
+ if (window && !window.isDestroyed()) show(window, nativeBoardBounds(window));
948
705
  });
949
-
950
- const hideAuxiliaryOutsideApp = () => {
951
- const focused = BrowserWindow.getFocusedWindow();
952
- if (focused && (windows.has(focused.id) || internalViewContents.has(focused.webContents))) return;
953
- for (const state of windows.values()) {
954
- state.nativeNav?.hide();
955
- state.view?.setVisible(false);
956
- }
957
- };
958
-
959
706
  app.on("browser-window-created", (_event, window) => attachWindow(window));
960
- app.on("browser-window-blur", () => setTimeout(hideAuxiliaryOutsideApp, 150));
961
- app.on("hide", hideAuxiliaryOutsideApp);
962
707
  app.on("web-contents-created", (_event, contents) => setTimeout(() => attachContents(contents), 0));
963
- app.on("before-quit", () => client.close());
964
708
  for (const window of BrowserWindow.getAllWindows()) attachWindow(window);
965
- for (const contents of electronWebContents.getAllWebContents()) attachContents(contents);
709
+ for (const contents of webContents.getAllWebContents()) attachContents(contents);
966
710
  record("main-installed");
967
711
  };
968
-
969
712
  record("preload-loaded");
970
- try {
971
- install(require("electron"));
972
- } catch (error) {
973
- record("main-install-failed", error);
974
- }
713
+ try { install(require("electron")); }
714
+ catch { record("main-install-failed"); }
975
715
  }
976
716
  `;
977
717
  }
978
718
 
719
+ /** The fallback native navigation window exposes only its one show action. */
979
720
  export function desktopTasksViewPreload() {
980
721
  return `"use strict";
981
-
982
722
  const { contextBridge, ipcRenderer } = require("electron");
983
723
  contextBridge.exposeInMainWorld("impelDesktopTasks", Object.freeze({
984
- rpc: (request) => ipcRenderer.invoke("impel-desktop-tasks:rpc", request),
985
- openLink: (url) => ipcRenderer.invoke("impel-desktop-tasks:open-link", url),
986
- close: () => ipcRenderer.send("impel-desktop-tasks:close"),
987
724
  show: () => ipcRenderer.send("impel-desktop-tasks:native-show"),
988
725
  }));
989
726
  `;
990
727
  }
991
-
992
- export function desktopTasksHostHtml() {
993
- return `<!doctype html>
994
- <html lang="en">
995
- <head>
996
- <meta charset="utf-8">
997
- <meta name="viewport" content="width=device-width,initial-scale=1">
998
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'self' data:; img-src data:;">
999
- <title>Impel Tasks</title>
1000
- <style>
1001
- :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
1002
- * { box-sizing: border-box; }
1003
- html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: Canvas; color: CanvasText; }
1004
- body { display: grid; grid-template-rows: minmax(0, 1fr); }
1005
- iframe { width: 100%; height: 100%; border: 0; background: Canvas; }
1006
- #status { display: grid; place-items: center; padding: 24px; color: color-mix(in srgb, CanvasText 65%, transparent); text-align: center; }
1007
- #status[hidden] { display: none; }
1008
- </style>
1009
- </head>
1010
- <body>
1011
- <div id="status" role="status">Loading Impel Tasks…</div>
1012
- <iframe id="widget" title="Impel Tasks" sandbox="allow-scripts allow-forms" hidden></iframe>
1013
- <script>
1014
- (() => {
1015
- const bridge = window.impelDesktopTasks;
1016
- const frame = document.getElementById("widget");
1017
- const status = document.getElementById("status");
1018
- let initialResult = null;
1019
- let initialized = false;
1020
- const send = (message) => frame.contentWindow?.postMessage(message, "*");
1021
- const reply = (id, result, error) => send(error
1022
- ? { jsonrpc: "2.0", id, error: { code: -32000, message: error } }
1023
- : { jsonrpc: "2.0", id, result });
1024
-
1025
- window.addEventListener("message", async (event) => {
1026
- if (event.source !== frame.contentWindow) return;
1027
- const message = event.data;
1028
- if (!message || message.jsonrpc !== "2.0") return;
1029
- if (message.method === "ui/initialize") {
1030
- reply(message.id, {
1031
- protocolVersion: "2026-01-26",
1032
- hostInfo: { name: "Impel Desktop Tasks", version: "1.0.0" },
1033
- hostCapabilities: { openLinks: {}, serverTools: {} },
1034
- hostContext: {
1035
- theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
1036
- platform: "desktop",
1037
- displayMode: "fullscreen",
1038
- availableDisplayModes: ["fullscreen"],
1039
- },
1040
- });
1041
- return;
1042
- }
1043
- if (message.method === "ui/notifications/initialized") {
1044
- if (!initialized && initialResult) {
1045
- initialized = true;
1046
- send({ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: initialResult });
1047
- }
1048
- return;
1049
- }
1050
- if (message.method === "tools/call") {
1051
- try {
1052
- const result = await bridge.rpc({ method: "tools/call", params: message.params || {} });
1053
- reply(message.id, result);
1054
- } catch (error) {
1055
- reply(message.id, null, error instanceof Error ? error.message : "Tasks request failed.");
1056
- }
1057
- return;
1058
- }
1059
- if (message.method === "ui/open-link") {
1060
- try {
1061
- await bridge.openLink(message.params?.url);
1062
- reply(message.id, {});
1063
- } catch (error) {
1064
- reply(message.id, null, error instanceof Error ? error.message : "Tasks link failed.");
1065
- }
1066
- return;
1067
- }
1068
- if (message.method === "ui/request-display-mode") {
1069
- reply(message.id, { mode: "fullscreen" });
1070
- }
1071
- });
1072
-
1073
- window.addEventListener("keydown", (event) => {
1074
- if (event.key === "Escape") bridge.close();
1075
- });
1076
- Promise.all([
1077
- bridge.rpc({ method: "resources/read", params: { uri: ${JSON.stringify(DESKTOP_TASKS_RESOURCE_URI)} } }),
1078
- bridge.rpc({ method: "tools/call", params: { name: "show_tasks", arguments: { scope: "visible", limit: 50 } } }),
1079
- ]).then(([resource, result]) => {
1080
- const html = resource?.contents?.find((entry) => entry?.uri === ${JSON.stringify(DESKTOP_TASKS_RESOURCE_URI)})?.text;
1081
- if (typeof html !== "string" || !html.includes("<html")) throw new Error("Tasks widget resource is unavailable.");
1082
- initialResult = result;
1083
- status.hidden = true;
1084
- frame.hidden = false;
1085
- frame.srcdoc = html;
1086
- }).catch((error) => {
1087
- status.textContent = error instanceof Error ? error.message : "Impel Tasks could not be loaded.";
1088
- });
1089
- })();
1090
- </script>
1091
- </body>
1092
- </html>
1093
- `;
1094
- }