cicy-desktop 2.1.271 → 2.1.273

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.
@@ -67,7 +67,7 @@ jobs:
67
67
  - name: Build Linux AppImage
68
68
  shell: bash
69
69
  env:
70
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
70
+ GH_TOKEN: ${{ secrets.CICY_RELEASE_GH_TOKEN }}
71
71
  CICY_OBFUSCATE: "1" # 发版混淆 JS(prebuild 主进程 + Vite 渲染层),仅 release CI
72
72
  run: |
73
73
  set -euo pipefail
@@ -110,7 +110,7 @@ jobs:
110
110
  shell: bash
111
111
  env:
112
112
  CSC_IDENTITY_AUTO_DISCOVERY: "false"
113
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
113
+ GH_TOKEN: ${{ secrets.CICY_RELEASE_GH_TOKEN }}
114
114
  CICY_OBFUSCATE: "1" # 发版混淆 JS(prebuild 主进程 + Vite 渲染层),仅 release CI
115
115
  run: |
116
116
  set -euo pipefail
@@ -136,12 +136,11 @@ jobs:
136
136
  - name: Ensure draft GitHub release exists
137
137
  shell: bash
138
138
  env:
139
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
139
+ GH_TOKEN: ${{ secrets.CICY_RELEASE_GH_TOKEN }}
140
140
  run: |
141
141
  gh release create "${{ steps.meta.outputs.tag }}" \
142
- --repo "$GITHUB_REPOSITORY" \
142
+ --repo "cicy-ai/cicy-desktop" \
143
143
  --title "CiCy Desktop v${{ steps.meta.outputs.version }}" \
144
- --target "$GITHUB_SHA" \
145
144
  --generate-notes \
146
145
  --draft \
147
146
  2>/dev/null || echo "release already exists, continuing"
@@ -149,7 +148,7 @@ jobs:
149
148
  - name: Build and publish PKG installers
150
149
  shell: bash
151
150
  env:
152
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
151
+ GH_TOKEN: ${{ secrets.CICY_RELEASE_GH_TOKEN }}
153
152
  run: |
154
153
  set -euo pipefail
155
154
  VER="${{ steps.meta.outputs.version }}"
@@ -184,7 +183,7 @@ jobs:
184
183
  pkgbuild --root "$ROOT" --component-plist "$PLIST" --scripts "$SCR" \
185
184
  --identifier com.cicy.desktop --version "$VER" --install-location / "$PKG"
186
185
  gh release upload "${{ steps.meta.outputs.tag }}" "$PKG" \
187
- --repo "$GITHUB_REPOSITORY" --clobber
186
+ --repo "cicy-ai/cicy-desktop" --clobber
188
187
  echo "uploaded $PKG ($(du -h "$PKG" | cut -f1))"
189
188
  done
190
189
 
@@ -72,12 +72,11 @@ jobs:
72
72
  # uploaded. Promoted to non-draft + latest in the final step.
73
73
  shell: bash
74
74
  env:
75
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75
+ GH_TOKEN: ${{ secrets.CICY_RELEASE_GH_TOKEN }}
76
76
  run: |
77
77
  gh release create "${{ steps.meta.outputs.tag }}" \
78
- --repo "$GITHUB_REPOSITORY" \
78
+ --repo "cicy-ai/cicy-desktop" \
79
79
  --title "CiCy Desktop v${{ steps.meta.outputs.version }}" \
80
- --target "$GITHUB_SHA" \
81
80
  --generate-notes \
82
81
  --draft \
83
82
  2>/dev/null || echo "release already exists, continuing"
@@ -86,7 +85,7 @@ jobs:
86
85
  # --publish always uploads .exe + latest.yml to the release created above.
87
86
  shell: powershell
88
87
  env:
89
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
88
+ GH_TOKEN: ${{ secrets.CICY_RELEASE_GH_TOKEN }}
90
89
  CICY_OBFUSCATE: "1" # 发版混淆 JS(prebuild 主进程 + Vite 渲染层),仅 release CI
91
90
  run: |
92
91
  if (Test-Path dist) { Remove-Item -Recurse -Force dist }
@@ -177,9 +176,9 @@ jobs:
177
176
  # existing clients will pick up the new version on its next poll.
178
177
  shell: bash
179
178
  env:
180
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
179
+ GH_TOKEN: ${{ secrets.CICY_RELEASE_GH_TOKEN }}
181
180
  run: |
182
181
  gh release edit "${{ steps.meta.outputs.tag }}" \
183
- --repo "$GITHUB_REPOSITORY" \
182
+ --repo "cicy-ai/cicy-desktop" \
184
183
  --draft=false \
185
184
  --latest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.271",
3
+ "version": "2.1.273",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -143,10 +143,10 @@
143
143
  "//optionalDependencies": "(2026-06 回调): mac/linux 改回 native cicy-code(:8008,colima 在 16G mac 上压垮内存)→ 重新内置 cicy-code-<plat> / cicy-mihomo-<plat>,localbin.fromBundle 零网络 seed(npm 仅作更新通道,带 npmmirror→npmjs 回退)。这些由 scripts/sync-runtime-deps.cjs 在 tag-push 时同步到最新版。npm 的 os/cpu 字段保证每个平台只装自己那份。Windows 仍走 docker(WSL :8009),它那份 cicy-code-windows 用不到但 bundle 着无害。",
144
144
  "optionalDependencies": {
145
145
  "electron": "41.0.3",
146
- "cicy-code-darwin-x64": "2.3.546",
147
- "cicy-code-darwin-arm64": "2.3.546",
148
- "cicy-code-linux-x64": "2.3.546",
149
- "cicy-code-linux-arm64": "2.3.546",
146
+ "cicy-code-darwin-x64": "2.3.556",
147
+ "cicy-code-darwin-arm64": "2.3.556",
148
+ "cicy-code-linux-x64": "2.3.556",
149
+ "cicy-code-linux-arm64": "2.3.556",
150
150
  "cicy-code-windows-x64": "2.3.193",
151
151
  "cicy-mihomo-darwin-x64": "1.10.4",
152
152
  "cicy-mihomo-darwin-arm64": "1.10.4",
@@ -24,6 +24,48 @@
24
24
  // webview gets the awaited promise
25
25
 
26
26
  const { contextBridge, ipcRenderer } = require("electron");
27
+ const { resolveReportedCicyTheme } = require("../tabbrowser/tab-theme");
28
+
29
+ // cicy-code owns the theme preference (`cicy_theme`) and publishes the applied
30
+ // value on <html data-theme>. Report that contract to the owning tab window so
31
+ // its native tab strip can use the same light/dark palette. The main process
32
+ // accepts this signal only from a registered team tab, so other pages carrying
33
+ // this preload cannot recolor Desktop chrome.
34
+ let lastReportedTheme = "";
35
+ function reportCicyTheme() {
36
+ let documentTheme = "";
37
+ let savedTheme = "";
38
+ try { documentTheme = document.documentElement?.dataset?.theme || ""; } catch (_) {}
39
+ try { savedTheme = localStorage.getItem("cicy_theme") || ""; } catch (_) {}
40
+ const theme = resolveReportedCicyTheme(documentTheme, savedTheme);
41
+ if (theme === lastReportedTheme) return;
42
+ lastReportedTheme = theme;
43
+ try { ipcRenderer.send("tab-content:cicy-theme", { theme }); } catch (_) {}
44
+ }
45
+
46
+ function watchCicyTheme() {
47
+ reportCicyTheme();
48
+ try {
49
+ const root = document.documentElement;
50
+ if (root) {
51
+ new MutationObserver(reportCicyTheme).observe(root, {
52
+ attributes: true,
53
+ attributeFilter: ["data-theme"],
54
+ });
55
+ }
56
+ } catch (_) {}
57
+ try { window.addEventListener("cicy-theme-change", reportCicyTheme); } catch (_) {}
58
+ }
59
+
60
+ // The saved preference is already available at preload time, which prevents a
61
+ // dark-strip flash while a light cicy-code page mounts. DOMContentLoaded then
62
+ // installs the live observer for changes made from Settings.
63
+ reportCicyTheme();
64
+ if (document.readyState === "loading") {
65
+ window.addEventListener("DOMContentLoaded", watchCicyTheme, { once: true });
66
+ } else {
67
+ watchCicyTheme();
68
+ }
27
69
 
28
70
  const relay = (type, payload) =>
29
71
  ipcRenderer.invoke("webview:relay", { type, ...(payload || {}) });
@@ -160,7 +160,14 @@ function resolveChromeBinary(binaryPath) {
160
160
  );
161
161
  }
162
162
 
163
- function buildChromeArgs({ userDataDirRoot, profileDirectory, debuggerPort, proxy, url }) {
163
+ function buildChromeArgs({
164
+ userDataDirRoot,
165
+ profileDirectory,
166
+ debuggerPort,
167
+ proxy,
168
+ url,
169
+ restoreLastSession = false,
170
+ }) {
164
171
  const args = [
165
172
  `--user-data-dir=${userDataDirRoot}`,
166
173
  `--profile-directory=${profileDirectory}`,
@@ -170,6 +177,10 @@ function buildChromeArgs({ userDataDirRoot, profileDirectory, debuggerPort, prox
170
177
  "--no-default-browser-check",
171
178
  ];
172
179
 
180
+ if (restoreLastSession) {
181
+ args.push("--restore-last-session");
182
+ }
183
+
173
184
  if (proxy) {
174
185
  args.push(`--proxy-server=${proxy}`);
175
186
  }
@@ -181,6 +192,18 @@ function buildChromeArgs({ userDataDirRoot, profileDirectory, debuggerPort, prox
181
192
  return args;
182
193
  }
183
194
 
195
+ function resolveLiveProfileAction({
196
+ platform = process.platform,
197
+ liveStatus,
198
+ runtime,
199
+ windowless = false,
200
+ }) {
201
+ if (!liveStatus?.isRunning) return "launch";
202
+ if (windowless) return "restart-windowless";
203
+ if (platform === "darwin" && !runtime?.pid) return "restart-unmanaged";
204
+ return "reuse";
205
+ }
206
+
184
207
  async function ensurePortAvailable(debuggerPort) {
185
208
  const open = await isPortOpen(debuggerPort, "127.0.0.1", 500);
186
209
  if (open) {
@@ -206,6 +229,7 @@ async function launchChrome({
206
229
  url,
207
230
  userDataDirRoot,
208
231
  userDataBaseRoot = DEFAULT_USER_DATA_BASE_ROOT,
232
+ restoreLastSession = false,
209
233
  }) {
210
234
  const profileDirectory = getProfileDirectory(accountIdx);
211
235
  const effectiveUserDataDirRoot = userDataDirRoot || getDefaultUserDataDirRoot(accountIdx, userDataBaseRoot);
@@ -221,6 +245,7 @@ async function launchChrome({
221
245
  debuggerPort,
222
246
  proxy,
223
247
  url,
248
+ restoreLastSession,
224
249
  });
225
250
  const child = spawn(binaryPath, args, {
226
251
  detached: process.platform !== "win32",
@@ -231,7 +256,7 @@ async function launchChrome({
231
256
  child.unref();
232
257
 
233
258
  const version = await waitForDebugger(debuggerPort, "127.0.0.1", 15000);
234
- bringChromeAppToForeground(binaryPath);
259
+ bringChromeAppToForeground(binaryPath, { pid: child.pid, debuggerPort });
235
260
 
236
261
  return {
237
262
  pid: child.pid,
@@ -256,12 +281,49 @@ function closeChromeProcess(pid) {
256
281
  // macOS / Windows focus-stealing prevention keeps the spawning Electron
257
282
  // app on top after we launch Chrome, so the user's keystrokes go into
258
283
  // Electron. Nudge the OS to bring Chrome forward without opening it again.
259
- function bringChromeAppToForeground(binaryPath) {
284
+ function getListeningPid(port) {
285
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null;
286
+ const candidates = process.platform === "darwin" ? ["/usr/sbin/lsof", "lsof"] : ["lsof"];
287
+ for (const command of candidates) {
288
+ try {
289
+ const out = execFileSync(command, ["-nP", `-tiTCP:${port}`, "-sTCP:LISTEN"], {
290
+ encoding: "utf8",
291
+ timeout: 2000,
292
+ });
293
+ const pid = Number(
294
+ String(out || "")
295
+ .trim()
296
+ .split(/\s+/)[0]
297
+ );
298
+ if (Number.isInteger(pid) && pid > 0) return pid;
299
+ } catch (_) {}
300
+ }
301
+ return null;
302
+ }
303
+
304
+ function bringChromeAppToForeground(binaryPath, { pid, debuggerPort } = {}) {
260
305
  try {
261
306
  if (process.platform === "darwin") {
307
+ const targetPid = Number.isInteger(pid) && pid > 0 ? pid : getListeningPid(debuggerPort);
308
+ if (targetPid) {
309
+ const script =
310
+ "ObjC.import('AppKit'); " +
311
+ `const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${targetPid}); ` +
312
+ "if (app) app.activateWithOptions($.NSApplicationActivateIgnoringOtherApps);";
313
+ spawn("/usr/bin/osascript", ["-l", "JavaScript", "-e", script], {
314
+ stdio: "ignore",
315
+ detached: true,
316
+ }).unref();
317
+ return;
318
+ }
319
+
262
320
  let resolved = binaryPath;
263
321
  if (!resolved || !fs.existsSync(resolved)) {
264
- try { resolved = resolveChromeBinary(); } catch (_) { return; }
322
+ try {
323
+ resolved = resolveChromeBinary();
324
+ } catch (_) {
325
+ return;
326
+ }
265
327
  }
266
328
  const match = resolved && resolved.match(/^(.+\.app)\//);
267
329
  if (!match) return;
@@ -301,6 +363,7 @@ module.exports = {
301
363
  getDefaultDebuggerPort,
302
364
  resolveChromeBinary,
303
365
  buildChromeArgs,
366
+ resolveLiveProfileAction,
304
367
  launchChrome,
305
368
  closeChromeProcess,
306
369
  bringChromeAppToForeground,
@@ -12,9 +12,21 @@
12
12
  the same color as the toolbar/content (#0A0A0A) so it merges into the
13
13
  page, carved out of the lighter strip with outward bottom curves.
14
14
  (content is near-black, so a darker strip would be invisible → go lighter.) */
15
+ color-scheme:dark;
15
16
  --bg:#0A0A0A; --strip:#1c1c20; --toolbar:#0A0A0A;
16
17
  --fg:#e6e6e9; --muted:#8b8b92; --line:rgba(255,255,255,.06);
17
18
  --accent:#3b82f6; --omni:#1a1a1c;
19
+ --hover:rgba(255,255,255,.06); --hover-strong:rgba(255,255,255,.16);
20
+ --active-fg:#fff; --separator:rgba(255,255,255,.12);
21
+ --edit-bg:#111113; --focus-bg:#111113; --spinner-track:rgba(255,255,255,.25);
22
+ }
23
+ html[data-theme="light"]{
24
+ color-scheme:light;
25
+ --bg:#f4f4f5; --strip:#e4e4e7; --toolbar:#f4f4f5;
26
+ --fg:#27272a; --muted:#71717a; --line:rgba(39,39,42,.14);
27
+ --omni:#fff; --hover:rgba(39,39,42,.07); --hover-strong:rgba(39,39,42,.13);
28
+ --active-fg:#18181b; --separator:rgba(39,39,42,.16);
29
+ --edit-bg:#fff; --focus-bg:#fff; --spinner-track:rgba(39,39,42,.22);
18
30
  }
19
31
  *{box-sizing:border-box;margin:0;padding:0}
20
32
  html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--fg);overflow:hidden;-webkit-user-select:none;user-select:none}
@@ -39,7 +51,7 @@
39
51
  .tab,#newtab{-webkit-app-region:no-drag}
40
52
  #tabs::-webkit-scrollbar{height:0}
41
53
  .tab{position:relative;display:flex;align-items:center;gap:9px;height:32px;min-width:60px;max-width:240px;padding:0 16px;border-radius:10px 10px 0 0;font-size:12.5px;color:var(--muted);background:transparent;cursor:default;flex:1 1 0;transition:background .12s,color .12s}
42
- .tab:hover{background:rgba(255,255,255,.06);color:var(--fg)}
54
+ .tab:hover{background:var(--hover);color:var(--fg)}
43
55
  /* home = the resident homepage tab: pinned first, fixed width, user icon, no
44
56
  title text, no close — like Chrome's profile/avatar button. */
45
57
  /* start-page tab: pinned, sizes to its "我的团队" label (not a flex-grow tab) */
@@ -52,7 +64,7 @@
52
64
  so drop the outward ears — they'd otherwise meet the homepage top with a seam. */
53
65
  .tab.home.active::before,.tab.home.active::after{display:none}
54
66
  /* active tab = toolbar/content color, merged into the page, with outward bottom curves */
55
- .tab.active{background:var(--toolbar);color:#fff;z-index:1}
67
+ .tab.active{background:var(--toolbar);color:var(--active-fg);z-index:1}
56
68
  /* outward bottom "ears" carved from the strip into the toolbar. gradient goes
57
69
  strip→toolbar (NOT transparent→toolbar): a transparent→opaque ramp passes
58
70
  through semi-transparent black and leaves a dark fringe on the lighter strip;
@@ -67,21 +79,21 @@
67
79
  first tab and around the active/hovered tab (Chrome hides the seam that would
68
80
  touch an active or hovered tab on either side). Inactive tabs don't use
69
81
  ::before (that's the active tab's ear), so it's free here. */
70
- .tab:not(.active)::before{content:"";position:absolute;left:-3.5px;top:50%;transform:translateY(-50%);width:1px;height:16px;background:rgba(255,255,255,.12);pointer-events:none}
82
+ .tab:not(.active)::before{content:"";position:absolute;left:-3.5px;top:50%;transform:translateY(-50%);width:1px;height:16px;background:var(--separator);pointer-events:none}
71
83
  #tabs>.tab:first-child::before{display:none} /* nothing left of the first tab */
72
84
  .tab:hover::before{display:none} /* no line on the hovered tab's left */
73
85
  .tab.active+.tab::before,.tab:hover+.tab::before{display:none} /* none on the active/hovered tab's right */
74
86
  .fav{width:16px;height:16px;flex:0 0 16px;border-radius:4px;display:flex;align-items:center;justify-content:center;overflow:hidden}
75
87
  .fav img{width:16px;height:16px;object-fit:contain}
76
- .spin{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:var(--accent);border-radius:50%;animation:sp .7s linear infinite}
88
+ .spin{width:14px;height:14px;border:2px solid var(--spinner-track);border-top-color:var(--accent);border-radius:50%;animation:sp .7s linear infinite}
77
89
  @keyframes sp{to{transform:rotate(360deg)}}
78
90
  .ttl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
79
- .ttl-edit{flex:1;min-width:0;height:22px;padding:0 5px;border:1px solid var(--accent);border-radius:5px;outline:none;background:#111113;color:#fff;font-size:12.5px;-webkit-user-select:text;user-select:text}
91
+ .ttl-edit{flex:1;min-width:0;height:22px;padding:0 5px;border:1px solid var(--accent);border-radius:5px;outline:none;background:var(--edit-bg);color:var(--fg);font-size:12.5px;-webkit-user-select:text;user-select:text}
80
92
  .cls{flex:0 0 16px;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;opacity:0;color:var(--muted);transition:opacity .1s,background .1s}
81
93
  .tab:hover .cls,.tab.active .cls{opacity:.85}
82
- .cls:hover{background:rgba(255,255,255,.16);color:#fff;opacity:1}
94
+ .cls:hover{background:var(--hover-strong);color:var(--fg);opacity:1}
83
95
  #newtab{flex:0 0 30px;width:30px;height:30px;margin:2px 0 2px 2px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--muted);cursor:default}
84
- #newtab:hover{background:rgba(255,255,255,.08);color:#fff}
96
+ #newtab:hover{background:var(--hover);color:var(--fg)}
85
97
  /* ── drag-to-reorder (Chrome-style) ── */
86
98
  .tab.dragging{opacity:.4}
87
99
  /* vertical insertion bar shown between tabs while dragging */
@@ -93,7 +105,7 @@
93
105
  button sits left of it (fullscreen hides the overlay → reclaim). */
94
106
  #panelbtn{position:fixed;top:5px;right:8px;z-index:30;width:30px;height:30px;border-radius:8px;display:none;align-items:center;justify-content:center;color:var(--muted);-webkit-app-region:no-drag;cursor:default}
95
107
  body.has-panel #panelbtn{display:flex}
96
- #panelbtn:hover{background:rgba(255,255,255,.08);color:#fff}
108
+ #panelbtn:hover{background:var(--hover);color:var(--fg)}
97
109
  body.is-win #panelbtn{right:148px}
98
110
  body.is-win.is-fullscreen #panelbtn{right:8px}
99
111
  /* keep tabs from sliding under the pinned button */
@@ -107,10 +119,10 @@
107
119
  The main process also moves its BrowserView content up to STRIP_H. */
108
120
  body.profile-0 #bar{display:none}
109
121
  .nav{width:32px;height:32px;flex:0 0 32px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--fg);cursor:default}
110
- .nav:hover{background:rgba(255,255,255,.10)}
122
+ .nav:hover{background:var(--hover)}
111
123
  .nav.off{color:var(--muted);opacity:.35;pointer-events:none}
112
124
  #omni{flex:1;height:30px;display:flex;align-items:center;gap:8px;margin-left:6px;padding:0 12px;background:var(--omni);border:1px solid var(--line);border-radius:16px;transition:border-color .12s,background .12s}
113
- #omni.focus{background:#111113;border-color:var(--accent)}
125
+ #omni.focus{background:var(--focus-bg);border-color:var(--accent)}
114
126
  #lead{flex:0 0 16px;width:16px;height:16px;color:var(--muted);display:flex;align-items:center;justify-content:center}
115
127
  #url{flex:1;height:100%;background:transparent;border:none;outline:none;color:var(--fg);font-size:13px;-webkit-user-select:text;user-select:text}
116
128
  #url::placeholder{color:var(--muted)}
@@ -149,7 +161,7 @@
149
161
  window.addEventListener('contextmenu', (e) => {
150
162
  if (!(e.target instanceof HTMLInputElement && e.target.id === 'url')) e.preventDefault();
151
163
  });
152
- const ICON_GLOBE ='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9aa0a6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
164
+ const ICON_GLOBE ='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
153
165
  // 「我的团队」标签的品牌标识 = 与首页 brand-mark 同款 BrandGlyph(白色六芒星)在蓝→紫渐变方块上。
154
166
  const CICY_LOGO = '<span class="cicy-logo"><svg width="12" height="12" viewBox="0 0 96 96" fill="none"><path d="M48 11L39.5 33.3L16 29.5L31 48L16 66.5L39.5 62.7L48 85L56.5 62.7L80 66.5L65 48L80 29.5L56.5 33.3Z" fill="white" stroke="white" stroke-width="8" stroke-linejoin="round" stroke-linecap="round"/></svg></span>';
155
167
  const ICON_LOCK = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
@@ -347,7 +359,7 @@
347
359
  const isHome = !!(activeTab && activeTab.home);
348
360
  const u = nav.url || '';
349
361
  const secure = /^https:\/\//i.test(u);
350
- leadEl.style.color = secure ? '#81c995' : '#9aa0a6';
362
+ leadEl.style.color = secure ? '#3f8f5b' : 'var(--muted)';
351
363
  leadEl.innerHTML = secure ? ICON_LOCK : ICON_GLOBE;
352
364
  // homepage tab → empty omnibox (like Chrome's new-tab page), don't expose the file:// path
353
365
  if (!urlFocused) urlEl.value = isHome ? '' : displayUrl(u);
@@ -362,7 +374,12 @@
362
374
  // Skip re-render while a drag is in flight — rebuilding tabsEl.innerHTML mid-drag
363
375
  // would destroy the dragged element and abort the gesture. State is still stored;
364
376
  // dragend triggers a render to sync.
365
- window.tabAPI.onState((s) => { state = s || { tabs: [], nav: {} }; if (dragId != null) return; render(); });
377
+ window.tabAPI.onState((s) => {
378
+ state = s || { tabs: [], nav: {} };
379
+ document.documentElement.dataset.theme = state.theme === 'light' ? 'light' : 'dark';
380
+ if (dragId != null) return;
381
+ render();
382
+ });
366
383
 
367
384
  function go() {
368
385
  let v = urlEl.value.trim(); if (!v) return;
@@ -0,0 +1,18 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ function normalizeCicyTheme(theme) {
5
+ return theme === "light" || theme === "dark" ? theme : null;
6
+ }
7
+
8
+ function resolveReportedCicyTheme(documentTheme, savedTheme) {
9
+ return normalizeCicyTheme(documentTheme) || normalizeCicyTheme(savedTheme) || "light";
10
+ }
11
+
12
+ function resolveTabChromeTheme(tabs) {
13
+ const active = Array.isArray(tabs) ? tabs.find((tab) => tab && tab.active) : null;
14
+ if (!active || !active.team) return "dark";
15
+ return normalizeCicyTheme(active.cicyTheme) || "dark";
16
+ }
17
+
18
+ module.exports = { normalizeCicyTheme, resolveReportedCicyTheme, resolveTabChromeTheme };
@@ -16,6 +16,7 @@ const {
16
16
  launchChrome,
17
17
  closeChromeProcess,
18
18
  bringChromeAppToForeground,
19
+ resolveLiveProfileAction,
19
20
  } = require("../chrome/chrome-launcher");
20
21
  const { isPortOpen } = require("../utils/process-utils");
21
22
  const { getVersion, getTargets, createTarget, activateTarget, callCdp } = require("../chrome/chrome-cdp-client");
@@ -404,6 +405,7 @@ async function launchOrActivateProfile({
404
405
 
405
406
  // Script parity: if /json/version reachable => activate first page target and return reused
406
407
  const liveStatus = await probeChromeDebugger(effectivePort);
408
+ let restoreLastSession = false;
407
409
  if (liveStatus.isRunning) {
408
410
  // Detect a windowless Chrome — debugger up but 0 page targets. It lingers on
409
411
  // macOS after its last window is closed; reusing it is unreliable because a
@@ -419,13 +421,19 @@ async function launchOrActivateProfile({
419
421
  windowless = false;
420
422
  }
421
423
 
422
- if (!windowless) {
424
+ const liveAction = resolveLiveProfileAction({
425
+ liveStatus,
426
+ runtime: registry.get(accountIdx),
427
+ windowless,
428
+ });
429
+
430
+ if (liveAction === "reuse") {
423
431
  const { targets, activatedTargetId } = await ensurePageTargets({
424
432
  debuggerPort: effectivePort,
425
433
  url,
426
434
  activateIfRunning,
427
435
  });
428
- if (activateIfRunning) bringChromeAppToForeground();
436
+ if (activateIfRunning) bringChromeAppToForeground(undefined, { debuggerPort: effectivePort });
429
437
 
430
438
  const nextRuntime = registry.upsert(accountIdx, {
431
439
  status: "running",
@@ -454,13 +462,8 @@ async function launchOrActivateProfile({
454
462
  };
455
463
  }
456
464
 
457
- // Windowless: tear the lingering process down (release its SingletonLock on
458
- // the user-data-dir) before falling through to the fresh-launch path, which
459
- // spawns Chrome with a real, persistent window.
460
- const lingering = registry.get(accountIdx);
461
- if (lingering?.pid) {
462
- closeChromeProcess(lingering.pid);
463
- } else if (liveStatus.webSocketDebuggerUrl) {
465
+ if (liveAction === "restart-unmanaged") {
466
+ restoreLastSession = true;
464
467
  try {
465
468
  await callCdp({
466
469
  debuggerPort: effectivePort,
@@ -469,8 +472,28 @@ async function launchOrActivateProfile({
469
472
  params: {},
470
473
  });
471
474
  } catch (_) {}
475
+ await new Promise((resolve) => setTimeout(resolve, 1000));
476
+ }
477
+
478
+ // Windowless: tear the lingering process down (release its SingletonLock on
479
+ // the user-data-dir) before falling through to the fresh-launch path, which
480
+ // spawns Chrome with a real, persistent window.
481
+ if (liveAction === "restart-windowless") {
482
+ const lingering = registry.get(accountIdx);
483
+ if (lingering?.pid) {
484
+ closeChromeProcess(lingering.pid);
485
+ } else if (liveStatus.webSocketDebuggerUrl) {
486
+ try {
487
+ await callCdp({
488
+ debuggerPort: effectivePort,
489
+ target: liveStatus.webSocketDebuggerUrl,
490
+ method: "Browser.close",
491
+ params: {},
492
+ });
493
+ } catch (_) {}
494
+ }
495
+ await new Promise((resolve) => setTimeout(resolve, 1000));
472
496
  }
473
- await new Promise((resolve) => setTimeout(resolve, 1000));
474
497
  }
475
498
 
476
499
  ensureRpaProfileInitialized({
@@ -503,6 +526,7 @@ async function launchOrActivateProfile({
503
526
  chromeBinary: config.chromeBinary,
504
527
  url,
505
528
  userDataDirRoot: effectiveUserDataDirRoot,
529
+ restoreLastSession,
506
530
  });
507
531
 
508
532
  const nextRuntime = registry.upsert(accountIdx, {
@@ -52,13 +52,11 @@ module.exports = (registerTool) => {
52
52
  return { content: [{ type: "text", text: fresh.b64 }] };
53
53
  }
54
54
 
55
- // Stale/missing. mac/linux can capture live in-process; win32 cannot (needs
56
- // the --disable-gpu daemon), so there we fall back to whatever file exists.
57
- // BUT honor snapshotEnabled(): on macOS capture is off by default, and a live
58
- // `screencapture` here is exactly what pops the Screen-Recording prompt (just
59
- // less often than the daemon did) — so when disabled we must NOT live-capture.
60
- // mac/linux:进程内即时抓屏(honor snapshotEnabled — mac 默认关避免授权弹窗)。
61
- if (process.platform !== "win32" && snap.snapshotEnabled()) {
55
+ // Stale/missing. A desktop_snapshot RPC is an explicit one-shot request,
56
+ // so mac/linux capture immediately even when the periodic daemon is off.
57
+ // CICY_DESKTOP_SNAPSHOT only controls the background capture loop in
58
+ // main.js; it must not make the user's “立即截图” button unusable.
59
+ if (process.platform !== "win32") {
62
60
  try {
63
61
  const r = await snap.captureB64(maxWidth);
64
62
  return { content: [{ type: "text", text: r.b64 }] };
@@ -81,10 +79,6 @@ module.exports = (registerTool) => {
81
79
  }
82
80
 
83
81
  if (fresh) return { content: [{ type: "text", text: fresh.b64 }] }; // stale is better than nothing
84
- // macOS 默认关(避免反复弹屏幕录制授权)。
85
- if (process.platform === "darwin" && !snap.snapshotEnabled()) {
86
- throw new Error("桌面截图在 macOS 默认关闭(避免反复弹屏幕录制授权)。需要 agent 看屏幕时,启动 app 前设环境变量 CICY_DESKTOP_SNAPSHOT=1。");
87
- }
88
82
  throw new Error("no desktop snapshot yet");
89
83
  } catch (error) {
90
84
  return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
@@ -27,13 +27,30 @@ const SHELL_HTML = path.join(__dirname, "..", "tabbrowser", "tab-shell.html");
27
27
  const SHELL_PRELOAD = path.join(__dirname, "..", "tabbrowser", "tab-shell-preload.js");
28
28
  const PANEL_PRELOAD = path.join(__dirname, "..", "tabbrowser", "panel-preload.js");
29
29
  const panelCells = require("../tabbrowser/panel-cells");
30
+ const { normalizeCicyTheme, resolveTabChromeTheme } = require("../tabbrowser/tab-theme");
30
31
  const HOMEPAGE_PRELOAD = path.join(__dirname, "..", "backends", "homepage-preload.js");
31
32
  const WEBVIEW_PRELOAD = path.join(__dirname, "..", "backends", "webview-preload.js");
32
33
  const CHROME_H = 80; // tab strip (40) + toolbar (40) — must match tab-shell.html
33
34
  const STRIP_H = 40; // tab strip only; the homepage tab hides the toolbar (no address bar)
35
+ const TAB_CHROME_COLORS = {
36
+ dark: { background: "#1c1c20", symbols: "#e8eaed" },
37
+ light: { background: "#e4e4e7", symbols: "#27272a" },
38
+ };
34
39
 
35
- const managers = new Map(); // accountIdx -> TabManager
36
- const managerByHost = new Map(); // shell webContents.id -> TabManager
40
+ // `r-reset` deliberately evicts tool modules from require.cache. BrowserWindows
41
+ // and BrowserViews outlive that reload, so their manager registry must outlive
42
+ // it too. A module-local Map made ensureManager(0) think no profile-0 window
43
+ // existed after a tool refresh; the next homepage "Open" click then created a
44
+ // second BrowserWindow instead of adding a tab to the current one.
45
+ const sharedState = globalThis.__cicyTabBrowserState || (globalThis.__cicyTabBrowserState = {
46
+ managers: new Map(),
47
+ managerByHost: new Map(),
48
+ openedWc: new Map(),
49
+ ipcInstalled: false,
50
+ themeIpcInstalled: false,
51
+ });
52
+ const managers = sharedState.managers; // accountIdx -> TabManager
53
+ const managerByHost = sharedState.managerByHost; // shell webContents.id -> TabManager
37
54
 
38
55
  function stripVol(u) { try { const x = new URL(u); return x.origin + x.pathname; } catch (e) { return u || ""; } }
39
56
 
@@ -68,7 +85,7 @@ function isInstanceAuthNavigation(currentUrl, targetUrl) {
68
85
  // (打开/关闭都更新)。刷新窗口据此按 wcId 强制 reload(reloadIgnoringCache),避开
69
86
  // "tab 打开后 URL 漂移(登录跳转 / token / 重定向)→ 按 URL 匹配失败"的 bug。
70
87
  // 键 = stripVol(打开时传入的 url);open 与 reload 两端 stripVol 一致(token 被剥掉)。
71
- const openedWc = new Map(); // stripVol(url) -> webContentsId
88
+ const openedWc = sharedState.openedWc; // stripVol(url) -> webContentsId
72
89
 
73
90
  const { NEWTAB_URL, PANEL_URL_BASE, ensureForPartition } = require("../tabbrowser/newtab-protocol");
74
91
  const PANEL_STATE_PATH = path.join(os.homedir(), "cicy-ai", "db", "last-panel.json");
@@ -206,7 +223,7 @@ class TabManager {
206
223
  this.activeId = null;
207
224
  // profile 0 is the primary window (its first tab is the resident homepage),
208
225
  // so give it the app title/icon; other profiles are sandbox browsers.
209
- const STRIP_BG = "#1c1c20"; // matches tab-shell.html --strip so the titlebar merges in
226
+ const STRIP_BG = TAB_CHROME_COLORS.dark.background; // matches tab-shell.html --strip
210
227
  const winOpts = {
211
228
  width: 1180,
212
229
  height: 820,
@@ -285,21 +302,25 @@ class TabManager {
285
302
  const active = this.tabs.find((t) => t.id === this.activeId);
286
303
  let wc = null;
287
304
  try { wc = active ? active.view.webContents : null; } catch (e) {}
305
+ const tabs = this.tabs.map((t) => ({
306
+ id: t.id,
307
+ // fixedTitle (team title / homepage) wins over the page's own document.title
308
+ title: t.fixedTitle || t.title || "新标签页",
309
+ url: t.url || "",
310
+ active: t.id === this.activeId,
311
+ loading: !!t.loading,
312
+ favicon: t.favicon || "",
313
+ avatar: t.avatar || "", // team 自定义头像 → tab icon 用它(优先于页面 favicon)
314
+ team: !!t.team, // 团队 tab:icon 只用 avatar,禁用页面 favicon
315
+ colorKey: t.colorKey || "", // 无头像时首字母色块的底色种子 = teamId(和卡片一致)
316
+ home: !!t.home,
317
+ panel: typeof t.url === "string" && t.url.startsWith(PANEL_URL_BASE),
318
+ cicyTheme: t.cicyTheme || "",
319
+ }));
320
+ const chromeTheme = resolveTabChromeTheme(tabs);
288
321
  const s = {
289
- tabs: this.tabs.map((t) => ({
290
- id: t.id,
291
- // fixedTitle (team title / homepage) wins over the page's own document.title
292
- title: t.fixedTitle || t.title || "新标签页",
293
- url: t.url || "",
294
- active: t.id === this.activeId,
295
- loading: !!t.loading,
296
- favicon: t.favicon || "",
297
- avatar: t.avatar || "", // team 自定义头像 → tab icon 用它(优先于页面 favicon)
298
- team: !!t.team, // 团队 tab:icon 只用 avatar,禁用页面 favicon
299
- colorKey: t.colorKey || "", // 无头像时首字母色块的底色种子 = teamId(和卡片一致)
300
- home: !!t.home,
301
- panel: typeof t.url === "string" && t.url.startsWith(PANEL_URL_BASE),
302
- })),
322
+ tabs,
323
+ theme: chromeTheme,
303
324
  nav: {
304
325
  canBack: wc ? wc.canGoBack() : false,
305
326
  canFwd: wc ? wc.canGoForward() : false,
@@ -307,6 +328,20 @@ class TabManager {
307
328
  url: active ? active.url || "" : "",
308
329
  },
309
330
  };
331
+ if (this.chromeTheme !== chromeTheme) {
332
+ this.chromeTheme = chromeTheme;
333
+ const colors = TAB_CHROME_COLORS[chromeTheme];
334
+ try { this.win.setBackgroundColor(colors.background); } catch (e) {}
335
+ if (process.platform === "win32") {
336
+ try {
337
+ this.win.setTitleBarOverlay({
338
+ color: colors.background,
339
+ symbolColor: colors.symbols,
340
+ height: STRIP_H,
341
+ });
342
+ } catch (e) {}
343
+ }
344
+ }
310
345
  try { this.win.webContents.send("tabwin:state", s); } catch (e) {}
311
346
  }
312
347
 
@@ -387,7 +422,7 @@ class TabManager {
387
422
  // home = the resident homepage tab (pinned, first, user-icon, no close).
388
423
  // fixedTitle = a caller-supplied tab name (e.g. the team title) that the
389
424
  // page's own document.title must NOT override.
390
- const tab = { id, view, title: "", url: target, home: !!opts.home, fixedTitle: opts.title || "", avatar: opts.avatar || "", team: !!opts.team, colorKey: opts.colorKey || "" };
425
+ const tab = { id, view, title: "", url: target, home: !!opts.home, fixedTitle: opts.title || "", avatar: opts.avatar || "", team: !!opts.team, colorKey: opts.colorKey || "", cicyTheme: "" };
391
426
  // team tab 用团队自定义头像做 icon:调用方没给就按 URL 反查 teams.json(覆盖所有打开路径)。
392
427
  if (!tab.avatar && !opts.home) { try { tab.avatar = require("../backends/local-teams").avatarForUrl(target) || ""; } catch (e) {} }
393
428
  if (opts.home) this.tabs.unshift(tab); else this.tabs.push(tab);
@@ -651,10 +686,9 @@ function openHomeWindow(accountIdx, homeUrl, opts = {}) {
651
686
  }
652
687
 
653
688
  // ── shell IPC (registered once) ──────────────────────────────────────────────
654
- let ipcInstalled = false;
655
689
  function installIpc() {
656
- if (ipcInstalled) return;
657
- ipcInstalled = true;
690
+ if (sharedState.ipcInstalled) return;
691
+ sharedState.ipcInstalled = true;
658
692
  const mgr = (e) => managerByHost.get(e.sender.id);
659
693
  ipcMain.on("tabwin:ready", (e) => {
660
694
  const m = mgr(e);
@@ -693,7 +727,25 @@ function installIpc() {
693
727
  ipcMain.on("tabwin:fwd", (e) => { const m = mgr(e); const wc = m && m.activeWc(); if (wc && wc.canGoForward()) wc.goForward(); });
694
728
  ipcMain.on("tabwin:reload", (e) => { const m = mgr(e); const wc = m && m.activeWc(); if (wc) wc.reload(); });
695
729
  }
730
+
731
+ function installThemeIpc() {
732
+ if (sharedState.themeIpcInstalled) return;
733
+ sharedState.themeIpcInstalled = true;
734
+ ipcMain.on("tab-content:cicy-theme", (e, payload) => {
735
+ const theme = normalizeCicyTheme(payload && payload.theme);
736
+ if (!theme) return;
737
+ const m = findManagerByTab(e.sender.id);
738
+ if (!m) return;
739
+ const tab = m.tabs.find((candidate) => candidate.id === e.sender.id);
740
+ // Only cicy-code/team tabs own the Desktop chrome theme. A normal page can
741
+ // carry the guarded preload, but it must never be able to repaint the shell.
742
+ if (!tab || !tab.team || tab.cicyTheme === theme) return;
743
+ tab.cicyTheme = theme;
744
+ m.pushState();
745
+ });
746
+ }
696
747
  installIpc();
748
+ installThemeIpc();
697
749
 
698
750
  // CDP captureScreenshot — works for background tabs (capturePage blanks when the
699
751
  // BrowserView isn't the attached/visible one).
@@ -37,14 +37,15 @@ function snapDir() {
37
37
  }
38
38
 
39
39
  // Is desktop screen capture allowed here? Single source of truth shared by the
40
- // periodic daemon (main.js) AND the on-demand `desktop_snapshot` tool's live fallback.
40
+ // periodic daemon (main.js). One-shot `desktop_snapshot` RPC requests bypass this
41
+ // switch: clicking “立即截图” must work without enabling background capture.
41
42
  // OFF by default on macOS AND Windows (opt in with CICY_DESKTOP_SNAPSHOT=1):
42
43
  // - macOS: any capture trips the Screen-Recording prompt that won't persist for a
43
44
  // non-Apple-Team-ID signature (re-prompts forever).
44
45
  // - Windows: the periodic whole-desktop capture spawns an always-on --disable-gpu
45
46
  // child + writes every 30s — pure overhead for users who only manage docker/teams
46
47
  // and aren't being driven by a screen-watching cloud agent (资源占用). The on-demand
47
- // `desktop_snapshot` tool still has its own live fallback when actually requested.
48
+ // `desktop_snapshot` still captures once when explicitly requested.
48
49
  // linux stays ON by default (no prompt, cheap scrot); opt out with =0.
49
50
  function snapshotEnabled() {
50
51
  return (process.platform === "darwin" || process.platform === "win32")
@@ -0,0 +1,71 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ const assert = require("node:assert/strict");
5
+ const childProcess = require("node:child_process");
6
+ const net = require("node:net");
7
+ const test = require("node:test");
8
+
9
+ test("macOS restarts a live Chrome profile not launched by the current desktop runtime", () => {
10
+ const { resolveLiveProfileAction } = require("../src/chrome/chrome-launcher");
11
+
12
+ assert.equal(
13
+ resolveLiveProfileAction({
14
+ platform: "darwin",
15
+ liveStatus: { isRunning: true },
16
+ runtime: null,
17
+ windowless: false,
18
+ }),
19
+ "restart-unmanaged"
20
+ );
21
+ });
22
+
23
+ test("an unmanaged Chrome restart restores the previous browser session", () => {
24
+ const { buildChromeArgs } = require("../src/chrome/chrome-launcher");
25
+ const args = buildChromeArgs({
26
+ userDataDirRoot: "/tmp/cicy-profile-1",
27
+ profileDirectory: "Default",
28
+ debuggerPort: 11001,
29
+ restoreLastSession: true,
30
+ });
31
+
32
+ assert.ok(args.includes("--restore-last-session"));
33
+ });
34
+
35
+ test("macOS activates a reused Chrome profile by the debugger listener PID", async (t) => {
36
+ if (process.platform !== "darwin") {
37
+ t.skip("macOS-specific foreground behavior");
38
+ return;
39
+ }
40
+
41
+ const server = net.createServer();
42
+ await new Promise((resolve, reject) => {
43
+ server.once("error", reject);
44
+ server.listen(0, "127.0.0.1", resolve);
45
+ });
46
+ t.after(() => new Promise((resolve) => server.close(resolve)));
47
+
48
+ const calls = [];
49
+ const originalSpawn = childProcess.spawn;
50
+ childProcess.spawn = (command, args, options) => {
51
+ calls.push({ command, args, options });
52
+ return { unref() {} };
53
+ };
54
+
55
+ const launcherPath = require.resolve("../src/chrome/chrome-launcher");
56
+ delete require.cache[launcherPath];
57
+ const { bringChromeAppToForeground } = require(launcherPath);
58
+ childProcess.spawn = originalSpawn;
59
+
60
+ bringChromeAppToForeground("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", {
61
+ debuggerPort: server.address().port,
62
+ });
63
+
64
+ assert.equal(calls.length, 1);
65
+ assert.equal(calls[0].command, "/usr/bin/osascript");
66
+ assert.deepEqual(calls[0].args.slice(0, 3), ["-l", "JavaScript", "-e"]);
67
+ assert.match(
68
+ calls[0].args[3],
69
+ new RegExp(`runningApplicationWithProcessIdentifier\\(${process.pid}\\)`)
70
+ );
71
+ });
@@ -0,0 +1,44 @@
1
+ // Copyright 2026 CiCy AI
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ const assert = require("node:assert/strict");
5
+ const test = require("node:test");
6
+
7
+ let resolveReportedCicyTheme;
8
+ let resolveTabChromeTheme;
9
+ try {
10
+ ({ resolveReportedCicyTheme, resolveTabChromeTheme } = require("../src/tabbrowser/tab-theme"));
11
+ } catch (_) {}
12
+
13
+ test("the active cicy-code team tab controls the desktop tab chrome theme", () => {
14
+ assert.equal(typeof resolveTabChromeTheme, "function");
15
+ assert.equal(
16
+ resolveTabChromeTheme([
17
+ { id: 1, active: false, team: true, cicyTheme: "dark" },
18
+ { id: 2, active: true, team: true, cicyTheme: "light" },
19
+ ]),
20
+ "light"
21
+ );
22
+ assert.equal(
23
+ resolveTabChromeTheme([{ id: 2, active: true, team: true, cicyTheme: "dark" }]),
24
+ "dark"
25
+ );
26
+ });
27
+
28
+ test("inactive cicy-code tabs and ordinary pages cannot recolor the desktop chrome", () => {
29
+ assert.equal(
30
+ resolveTabChromeTheme([
31
+ { id: 1, active: false, team: true, cicyTheme: "light" },
32
+ { id: 2, active: true, team: false, cicyTheme: "light" },
33
+ ]),
34
+ "dark"
35
+ );
36
+ assert.equal(resolveTabChromeTheme([]), "dark");
37
+ });
38
+
39
+ test("cicy-code reports its document theme, then its saved preference, then light by default", () => {
40
+ assert.equal(typeof resolveReportedCicyTheme, "function");
41
+ assert.equal(resolveReportedCicyTheme("light", "dark"), "light");
42
+ assert.equal(resolveReportedCicyTheme("", "dark"), "dark");
43
+ assert.equal(resolveReportedCicyTheme("system", "sepia"), "light");
44
+ });