cicy-desktop 2.1.241 → 2.1.243

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.
@@ -6,7 +6,7 @@
6
6
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
7
7
  <link rel="icon" type="image/png" sizes="256x256" href="./favicon-256.png" />
8
8
  <title>CiCy Desktop</title>
9
- <script type="module" crossorigin src="./assets/index-CCx0HFAk.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-DAGqtb2n.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-C6Zrdgc0.css">
11
11
  </head>
12
12
  <body>
@@ -518,6 +518,8 @@
518
518
  info: null,
519
519
  progress: null,
520
520
  error: null,
521
+ tunnelLimit: 0, // zero-trust tunnel tier cap: 0 = free (hide 开启隧道), >0 = paid
522
+ gwEnabled: false, // whether a tunnel is currently on for this machine
521
523
 
522
524
  async refresh() {
523
525
  this.state = "checking";
@@ -538,6 +540,13 @@
538
540
  this.state = "error";
539
541
  this.error = e.message;
540
542
  }
543
+ // Zero-trust tunnel: tier cap (gates the "开启隧道" item) + current on/off.
544
+ try {
545
+ const ts = await window.cicy.sidecar.tunnelStatus();
546
+ this.tunnelLimit = (ts && typeof ts.tunnelLimit === "number") ? ts.tunnelLimit : 0;
547
+ const gw = await window.cicy.sidecar.getGateway();
548
+ this.gwEnabled = !!(gw && gw.gateway && gw.gateway.enabled);
549
+ } catch { /* keep prior tier */ }
541
550
  this.render();
542
551
  },
543
552
 
@@ -793,6 +802,10 @@
793
802
  <div class="more-item" data-act="check-update">
794
803
  ${iconUse("i-refresh","sm")} 检查更新
795
804
  </div>
805
+ ${sidecarUI.tunnelLimit > 0 ? `<div class="more-divider"></div>
806
+ <div class="more-item" data-act="tunnel">
807
+ ${iconUse("i-open","sm")} ${sidecarUI.gwEnabled ? "隧道运行中" : "开启隧道"}
808
+ </div>` : ""}
796
809
  </div>
797
810
  </div>
798
811
  <button class="open local" data-act="open" ${openDisabled ? `disabled title="${esc(disabledReason)}"` : ""}>${iconUse("i-open","sm")} Open</button>
@@ -946,6 +959,26 @@
946
959
  // Re-render local rows so the more menu reflects the new state.
947
960
  if (typeof refreshLocalRows === "function") refreshLocalRows();
948
961
  };
962
+ } else if (act === "tunnel") {
963
+ // Zero-trust tunnel: expose this machine's :8008 via the gateway. The
964
+ // cloud returns 402 for the free tier (which uses Cloudflare directly).
965
+ btn.onclick = async (e) => {
966
+ e.stopPropagation();
967
+ btn.closest(".more-menu").hidden = true;
968
+ document.querySelectorAll(".btn-more").forEach(b => b.classList.remove("active"));
969
+ if (sidecarUI.gwEnabled) { showToast("隧道已在运行"); return; }
970
+ showToast("正在开启隧道…");
971
+ const r = await window.cicy.sidecar.enrollTunnel(null); // null → this machine's local team
972
+ if (r && r.ok) {
973
+ sidecarUI.gwEnabled = true;
974
+ showToast(`隧道已开启: ${r.slug ? r.slug + ".gw.cicy-ai.com" : "已连接"}`);
975
+ if (typeof refreshLocalRows === "function") refreshLocalRows();
976
+ } else if (r && r.status === 402) {
977
+ showToast("隧道是付费功能,请升级到团队档", true);
978
+ } else {
979
+ showToast(`开启失败: ${(r && (r.reason || r.error)) || "unknown"}`, true);
980
+ }
981
+ };
949
982
  } else if (act === "update") {
950
983
  btn.onclick = (e) => {
951
984
  e.stopPropagation();
@@ -584,7 +584,13 @@ async function pullCustomTeams() {
584
584
  // 本地 base_url 更新过来 —— 这才是「改 URL 跨设备同步」的下行环节(原来只增不改 = 白改)。
585
585
  // 本地改动会经 Fix A(updateTeam 改址即同步)先上行到云端,所以云端总是最新,不会误回滚。
586
586
  const cur = nodes[localId] || {};
587
- if (norm(cur.base_url) !== norm(host)) {
587
+ // 本机 sidecar/docker 团队的 base_url 永远是本机地址(127.0.0.1:8008)——桌面访问的是
588
+ // 它自己的 :8008;gateway_url/host_url 只是「别人怎么访问它」,绝不能拿来覆盖本机 base_url
589
+ // (否则本机团队变成远程 auth_error 卡、Local 计数掉 0 —— 开隧道后就撞过这个 bug)。
590
+ // base_url 可能已被污染成网关 URL,所以用稳定的 node key(127-0-0-1-<port> / localhost-)
591
+ // + is_docker 兜底识别本机。只有真正的远程 custom 团队才下行 reconcile base_url。
592
+ const isLocalNode = !!cur.is_docker || isLocalOrigin(cur.base_url) || /^(127-0-0-1|localhost)[-.]/.test(String(localId));
593
+ if (!isLocalNode && norm(cur.base_url) !== norm(host)) {
588
594
  await writeNodes((nds) => { if (nds[localId]) { nds[localId].base_url = host; nds[localId].updated_at = new Date().toISOString(); } return nds; });
589
595
  updated++;
590
596
  log.info(`[local-teams] custom team ${localId} url ← cloud (teamId=${tid})`);
@@ -632,6 +632,36 @@ function register({ sidecarLogPath } = {}) {
632
632
  ipcMain.handle("sidecar:get-cft", () => {
633
633
  try { return { ok: true, cft: sidecar.getCft() }; } catch (e) { return { ok: false, cft: { enabled: false, token: "", host: "" }, error: e.message }; }
634
634
  });
635
+
636
+ // ── 自托管隧道 cicy-tunnel(url+token 自填面板,替代原云端 enroll)—— 流程同 cft ──────
637
+ ipcMain.handle("sidecar:get-tunnel", () => {
638
+ try { return { ok: true, tunnel: sidecar.getTunnel() }; } catch (e) { return { ok: false, tunnel: { enabled: false, url: "", token: "" }, error: e.message }; }
639
+ });
640
+ // 账号套餐等级(徽章用)—— 从云端取 tier,忽略 tunnels 列表;与自填隧道解耦。
641
+ ipcMain.handle("tunnel:status", async () => {
642
+ try { return cc ? await cc.listTunnels() : { ok: false, tunnelLimit: 0 }; } catch (e) { return { ok: false, tunnelLimit: 0, error: e.message }; }
643
+ });
644
+ // 存 url+token(desktop-flags.json)→ 重启 cicy-code(mac)/重建容器(win)让新的
645
+ // CICY_TUNNEL_URL/TOKEN 生效,节点据此主动拨出到 <slug>.gw.<域名>。透明转发,不鉴权。
646
+ ipcMain.handle("sidecar:set-tunnel", async (e, cfg) => {
647
+ const emit = (ev) => { try { e.sender.send("sidecar:op-progress", { op: "restart", ...ev }); } catch {} };
648
+ try {
649
+ const saved = sidecar.setTunnel(cfg || {});
650
+ emit({ phase: "swap", status: "running", message: `隧道已${saved.enabled ? "开启" : "关闭"},正在重启 cicy-code…` });
651
+ if (process.platform === "win32") {
652
+ try { await appDocker.recreate({ ...(await appOpts()), onProgress: emit }); } catch { try { await appDocker.dockerRestart?.({ onProgress: emit }); } catch {} }
653
+ } else {
654
+ await sidecar.restart({ logPath: sidecarLogPath });
655
+ }
656
+ let up = false;
657
+ for (let i = 0; i < 240; i++) { if ((await sidecar.probeExisting(PORT)) || (await appDocker.probeHealth?.(PORT))) { up = true; break; } await new Promise((r) => setTimeout(r, 500)); }
658
+ emit({ phase: "done", status: up ? "done" : "error", message: up ? `隧道已${saved.enabled ? "开启" : "关闭"}` : "重启后 :8008 未就绪——稍等或重试" });
659
+ return { ok: up, tunnel: saved };
660
+ } catch (err) {
661
+ emit({ phase: "done", status: "error", message: `设置失败:${err.message}` });
662
+ return { ok: false, error: err.message };
663
+ }
664
+ });
635
665
  ipcMain.handle("sidecar:set-cft", async (e, cfg) => {
636
666
  const emit = (ev) => { try { e.sender.send("sidecar:op-progress", { op: "restart", ...ev }); } catch {} };
637
667
  try {
@@ -506,10 +506,39 @@ async function registerTeamAndInjectKey({ teamId = null, title = "", globalJsonP
506
506
  return { ...reg, injected: true };
507
507
  }
508
508
 
509
+ // ── zero-trust tunnels (v2.2.71+): expose a team's :8008 via the gateway ──────
510
+ // enrollTunnel binds a tunnel to a team — PAID tiers only (the cloud returns 402
511
+ // for the free tier, which uses Cloudflare directly). Returns the gateway_url +
512
+ // node-token to write into gateway.json so this machine dials the gateway in.
513
+ async function enrollTunnel(teamId) {
514
+ const token = loginToken();
515
+ if (!token) return { ok: false, reason: "not_logged_in" };
516
+ if (teamId == null) return { ok: false, reason: "no_team_id" };
517
+ const res = await cloudFetch(`/api/gateway/enroll?team=${encodeURIComponent(teamId)}`, { method: "POST" });
518
+ if (res.ok && res.json && res.json.token) {
519
+ return { ok: true, gatewayUrl: res.json.gateway_url, token: res.json.token, slug: res.json.slug };
520
+ }
521
+ return { ok: false, status: res.status, reason: (res.json && res.json.error) || res.reason || "enroll_failed" };
522
+ }
523
+
524
+ // listTunnels reports the caller's tunnels + the tier cap/usage (tunnel_limit).
525
+ // tunnelLimit === 0 → free tier: the "开启隧道" action must be hidden/disabled.
526
+ async function listTunnels() {
527
+ const token = loginToken();
528
+ if (!token) return { ok: false, reason: "not_logged_in", tunnels: [], tunnelLimit: 0 };
529
+ const res = await cloudFetch("/api/gateway/tunnels", { method: "GET" });
530
+ if (res.ok && res.json) {
531
+ return { ok: true, tunnels: res.json.tunnels || [], tier: res.json.tier || "", tunnelLimit: res.json.tunnel_limit || 0, tunnelCount: res.json.tunnel_count || 0, tunnelRemaining: res.json.tunnel_remaining };
532
+ }
533
+ return { ok: false, status: res.status, reason: res.reason, tunnels: [], tunnelLimit: 0 };
534
+ }
535
+
509
536
  module.exports = {
510
537
  CLOUD_BASE,
511
538
  GATEWAY_URL,
512
539
  GLOBAL_JSON,
540
+ enrollTunnel,
541
+ listTunnels,
513
542
  loginToken,
514
543
  getDeviceId,
515
544
  getPublicIp,
@@ -424,7 +424,14 @@
424
424
  "cftRestartHint": "Restart the tunnel with the current token/host",
425
425
  "cftNeedHost": "Public host is required",
426
426
  "cftTunnelDown": "Tunnel not connected: the token may be invalid/expired, or the hostname has no route in Cloudflare",
427
- "cftConnected": "Tunnel connected"
427
+ "cftConnected": "Tunnel connected",
428
+ "tunnelMenu": "Enable tunnel",
429
+ "tunnelRunning": "Tunnel running",
430
+ "tunnelHint": "Expose this machine's cicy-code over a zero-trust tunnel at a fixed public address (paid tier); cicy-code dials in automatically once enabled",
431
+ "tunnelEnabling": "Enabling tunnel, restarting cicy-code…",
432
+ "tunnelOn": "Tunnel enabled",
433
+ "tunnelNeedUpgrade": "Free tier — upgrade your team plan to use a fixed tunnel",
434
+ "tunnelFailed": "Failed to enable tunnel"
428
435
  },
429
436
  "teamCard": {
430
437
  "noHost": "No address set",
@@ -582,5 +589,27 @@
582
589
  "onDone": "Container Docker access enabled",
583
590
  "offDone": "Container Docker access disabled",
584
591
  "failed": "Failed to apply"
592
+ },
593
+ "plan": {
594
+ "personal": "Personal",
595
+ "team": "Team",
596
+ "enterprise": "Enterprise",
597
+ "hint": "Current account plan"
598
+ },
599
+ "tunnel": {
600
+ "menu": "Self-hosted tunnel",
601
+ "hint": "Expose this cicy-code via your own cicy-tunnel at <slug>.gw.<domain> (paste url+token, transparent forwarding, no --public); toggling restarts cicy-code",
602
+ "title": "Self-hosted tunnel · cicy-tunnel",
603
+ "subtitle": "Expose this cicy-code via your own cicy-tunnel at <slug>.gw.<domain>, transparent forwarding. Get url+token from the tunnel's enroll.",
604
+ "enable": "Enable tunnel",
605
+ "url": "Tunnel URL",
606
+ "token": "Node token",
607
+ "tokenPh": "Paste the token from enroll",
608
+ "needUrl": "URL required to enable (wss://…/_tunnel/connect)",
609
+ "needToken": "Node token required to enable",
610
+ "applying": "Applying tunnel config, restarting cicy-code…",
611
+ "on": "Tunnel enabled",
612
+ "off": "Tunnel disabled",
613
+ "failed": "Failed to apply"
585
614
  }
586
615
  }
@@ -424,7 +424,14 @@
424
424
  "cftRestartHint": "Redémarrer le tunnel avec le token/hôte actuel",
425
425
  "cftNeedHost": "L’hôte public est requis",
426
426
  "cftTunnelDown": "Tunnel non connecté : le token est peut-être invalide/expiré, ou le nom d’hôte n’a pas de route dans Cloudflare",
427
- "cftConnected": "Tunnel connecté"
427
+ "cftConnected": "Tunnel connecté",
428
+ "tunnelMenu": "Activer le tunnel",
429
+ "tunnelRunning": "Tunnel actif",
430
+ "tunnelHint": "Expose le cicy-code de cette machine via un tunnel zero-trust à une adresse publique fixe (offre payante) ; cicy-code se connecte automatiquement une fois activé",
431
+ "tunnelEnabling": "Activation du tunnel, redémarrage de cicy-code…",
432
+ "tunnelOn": "Tunnel activé",
433
+ "tunnelNeedUpgrade": "Offre gratuite — passez à une offre d'équipe pour un tunnel fixe",
434
+ "tunnelFailed": "Échec de l'activation du tunnel"
428
435
  },
429
436
  "teamCard": {
430
437
  "noHost": "No address set",
@@ -582,5 +589,27 @@
582
589
  "onDone": "Accès Docker du conteneur activé",
583
590
  "offDone": "Accès Docker du conteneur désactivé",
584
591
  "failed": "Échec de l'application"
592
+ },
593
+ "plan": {
594
+ "personal": "Personnel",
595
+ "team": "Équipe",
596
+ "enterprise": "Entreprise",
597
+ "hint": "Offre du compte actuel"
598
+ },
599
+ "tunnel": {
600
+ "menu": "Tunnel auto-hébergé",
601
+ "hint": "Expose ce cicy-code via votre propre cicy-tunnel sur <slug>.gw.<domaine> (collez url+token, transfert transparent, sans --public) ; le basculement redémarre cicy-code",
602
+ "title": "Tunnel auto-hébergé · cicy-tunnel",
603
+ "subtitle": "Expose ce cicy-code via votre propre cicy-tunnel sur <slug>.gw.<domaine>, transfert transparent. url+token via enroll.",
604
+ "enable": "Activer le tunnel",
605
+ "url": "URL du tunnel",
606
+ "token": "Token du nœud",
607
+ "tokenPh": "Collez le token d'enroll",
608
+ "needUrl": "URL requise pour activer (wss://…/_tunnel/connect)",
609
+ "needToken": "Token du nœud requis pour activer",
610
+ "applying": "Application de la config, redémarrage de cicy-code…",
611
+ "on": "Tunnel activé",
612
+ "off": "Tunnel désactivé",
613
+ "failed": "Échec de l'application"
585
614
  }
586
615
  }
@@ -424,7 +424,14 @@
424
424
  "cftRestartHint": "現在のトークン/ホストでトンネルを再起動",
425
425
  "cftNeedHost": "公開ホストは必須です",
426
426
  "cftTunnelDown": "トンネル未接続:トークンが無効/期限切れ、またはホスト名が Cloudflare でルーティングされていません",
427
- "cftConnected": "トンネル接続済み"
427
+ "cftConnected": "トンネル接続済み",
428
+ "tunnelMenu": "トンネルを有効化",
429
+ "tunnelRunning": "トンネル稼働中",
430
+ "tunnelHint": "このマシンの cicy-code をゼロトラストトンネルで固定の公開アドレスに公開します(有料プラン)。有効化すると cicy-code が自動接続します",
431
+ "tunnelEnabling": "トンネルを有効化、cicy-code を再起動中…",
432
+ "tunnelOn": "トンネルを有効化しました",
433
+ "tunnelNeedUpgrade": "無料プランです。チームプランをアップグレードすると固定トンネルを利用できます",
434
+ "tunnelFailed": "トンネルの有効化に失敗しました"
428
435
  },
429
436
  "teamCard": {
430
437
  "noHost": "No address set",
@@ -582,5 +589,27 @@
582
589
  "onDone": "コンテナ Docker アクセスを有効化しました",
583
590
  "offDone": "コンテナ Docker アクセスを無効化しました",
584
591
  "failed": "設定に失敗しました"
592
+ },
593
+ "plan": {
594
+ "personal": "パーソナル",
595
+ "team": "チーム",
596
+ "enterprise": "エンタープライズ",
597
+ "hint": "現在のアカウントプラン"
598
+ },
599
+ "tunnel": {
600
+ "menu": "セルフホストトンネル",
601
+ "hint": "自前の cicy-tunnel 経由でこの cicy-code を <slug>.gw.<ドメイン> に公開(url+token を貼付、透過転送、--public 不要)。切り替えで cicy-code を再起動",
602
+ "title": "セルフホストトンネル · cicy-tunnel",
603
+ "subtitle": "自前の cicy-tunnel 経由でこの cicy-code を <slug>.gw.<ドメイン> に公開、透過転送。url+token は enroll から取得。",
604
+ "enable": "トンネルを有効化",
605
+ "url": "トンネル URL",
606
+ "token": "ノードトークン",
607
+ "tokenPh": "enroll のトークンを貼付",
608
+ "needUrl": "有効化には URL が必要(wss://…/_tunnel/connect)",
609
+ "needToken": "有効化にはノードトークンが必要",
610
+ "applying": "トンネル設定を適用、cicy-code を再起動中…",
611
+ "on": "トンネルを有効化しました",
612
+ "off": "トンネルを無効化しました",
613
+ "failed": "設定に失敗しました"
585
614
  }
586
615
  }
@@ -424,7 +424,14 @@
424
424
  "cftRestartHint": "用当前 Token/Host 重新启动隧道",
425
425
  "cftNeedHost": "公网域名 Host 不能为空",
426
426
  "cftTunnelDown": "隧道未连上:Token 可能无效/过期,或域名未在 Cloudflare 配好路由",
427
- "cftConnected": "隧道已连接"
427
+ "cftConnected": "隧道已连接",
428
+ "tunnelMenu": "开启隧道",
429
+ "tunnelRunning": "隧道运行中",
430
+ "tunnelHint": "把本机 cicy-code 通过零信任隧道暴露到公网固定地址(付费档);开启后 cicy-code 自动拨号",
431
+ "tunnelEnabling": "开启隧道,重启 cicy-code 中…",
432
+ "tunnelOn": "隧道已开启",
433
+ "tunnelNeedUpgrade": "当前为免费档,升级团队档位后可用固定隧道",
434
+ "tunnelFailed": "开启隧道失败"
428
435
  },
429
436
  "teamCard": {
430
437
  "noHost": "未填访问地址",
@@ -582,5 +589,27 @@
582
589
  "onDone": "容器 Docker 访问已开启",
583
590
  "offDone": "容器 Docker 访问已关闭",
584
591
  "failed": "设置失败"
592
+ },
593
+ "plan": {
594
+ "personal": "个人版",
595
+ "team": "团队版",
596
+ "enterprise": "企业版",
597
+ "hint": "当前账号版本"
598
+ },
599
+ "tunnel": {
600
+ "menu": "自托管隧道",
601
+ "hint": "把本机 cicy-code 通过你自建的 cicy-tunnel 暴露到 <slug>.gw.<域名>(填 url+token,透明转发、不需 --public);切换会重启 cicy-code",
602
+ "title": "自托管隧道 · cicy-tunnel",
603
+ "subtitle": "把本机 cicy-code 通过你自建的 cicy-tunnel 暴露到 <slug>.gw.<域名>,透明转发。url+token 从隧道 enroll 拿。",
604
+ "enable": "开启隧道",
605
+ "url": "隧道地址 URL",
606
+ "token": "节点 Token",
607
+ "tokenPh": "粘贴 enroll 输出的 token",
608
+ "needUrl": "开启需要填隧道地址(wss://…/_tunnel/connect)",
609
+ "needToken": "开启需要填节点 token",
610
+ "applying": "应用隧道配置,重启 cicy-code 中…",
611
+ "on": "隧道已开启",
612
+ "off": "隧道已关闭",
613
+ "failed": "设置失败"
585
614
  }
586
615
  }
package/src/main.js CHANGED
@@ -1136,6 +1136,19 @@ electronApp.whenReady().then(async () => {
1136
1136
  .catch((e) => log.warn(`[cloud] device-info/register (on launch) failed: ${e.message}`));
1137
1137
  } catch (e) { log.warn(`[cloud] device-info launch hook failed: ${e.message}`); }
1138
1138
 
1139
+ // Periodic device heartbeat → lets the ops backend show per-device liveness.
1140
+ // Re-POST /api/device/register every 60s (idempotent upsert by owner+deviceId;
1141
+ // cloud records last_seen on each call → "active if now - last_seen < N min").
1142
+ // Runs in MAIN, not the renderer, so it keeps beating even when the homepage
1143
+ // window is hidden/minimized. Best-effort, no-op when not logged in;
1144
+ // registerDevice reads cached deviceInfo (no network detection per beat).
1145
+ try {
1146
+ const cc = require("./cloud/cloud-client");
1147
+ const HEARTBEAT_MS = Math.max(15000, Number(process.env.CICY_DEVICE_HEARTBEAT_MS || 60000));
1148
+ const beat = setInterval(() => { try { cc.registerDevice().catch(() => {}); } catch (_) {} }, HEARTBEAT_MS);
1149
+ if (beat && typeof beat.unref === "function") beat.unref();
1150
+ } catch (e) { log.warn(`[cloud] device heartbeat setup failed: ${e.message}`); }
1151
+
1139
1152
  // Cloud↔desktop title reconcile. The homepage drives the FAST cadence by window
1140
1153
  // visibility (聚焦 ~3s / 切回立即,见 App.jsx + localTeams:syncCloud IPC). This
1141
1154
  // 30s timer is just the SLOW fallback for when no homepage window is open / it's
@@ -300,6 +300,30 @@ function cftEnv() {
300
300
  return { CICY_CFT_TOKEN: c.token, ...(c.host ? { CICY_CFT_HOST: c.host } : {}) };
301
301
  }
302
302
 
303
+ // 自托管隧道 cicy-tunnel(替代原 cloud enroll gateway)—— 面板同 cft,就 { enabled, url, token }。
304
+ // 用户从自己的 cicy-tunnel `enroll` 拿到 wss url + node token,填进来;enabled 且 url+token 非空
305
+ // 时,start()/容器注入 CICY_TUNNEL_URL/TOKEN(cicy-code 据此主动拨出到隧道)。同时注入旧名
306
+ // CICY_GATEWAY_* 作兼容,老 cicy-code 也能拨。Mirrors getCft/setCft/cftEnv。
307
+ // 读取时兼容旧 f.gateway(老配置无缝迁移)。
308
+ function getTunnel() {
309
+ const c = readFlags().tunnel || readFlags().gateway || {};
310
+ return { enabled: !!c.enabled, url: c.url || "", token: c.token || "" };
311
+ }
312
+ function setTunnel(cfg = {}) {
313
+ const f = readFlags();
314
+ f.tunnel = { enabled: !!cfg.enabled, url: String(cfg.url || "").trim(), token: String(cfg.token || "").trim() };
315
+ delete f.gateway; // 迁移到 tunnel,清掉旧键
316
+ try { fs.mkdirSync(path.dirname(FLAGS_FILE), { recursive: true }); fs.writeFileSync(FLAGS_FILE, JSON.stringify(f, null, 2)); } catch (e) { console.warn(`[cicy-code-sidecar] setTunnel write failed: ${e.message}`); }
317
+ return getTunnel();
318
+ }
319
+ function tunnelEnv() {
320
+ const c = getTunnel();
321
+ if (!c.enabled || !c.url || !c.token) return {};
322
+ return { CICY_TUNNEL_URL: c.url, CICY_TUNNEL_TOKEN: c.token, CICY_GATEWAY_URL: c.url, CICY_GATEWAY_TOKEN: c.token };
323
+ }
324
+ // 兼容别名(旧调用方)。
325
+ const getGateway = getTunnel, setGateway = setTunnel, gatewayEnv = tunnelEnv;
326
+
303
327
 
304
328
  async function start({ logPath, port = DEFAULT_PORT, force = false, version = null, emit = null } = {}) {
305
329
  // **永不重复 spawn 活着的实例**(bug 修复): cicy-code 首次启动要 `brew install tmux`
@@ -364,6 +388,10 @@ async function start({ logPath, port = DEFAULT_PORT, force = false, version = nu
364
388
  // 「Cloudflare Tunnel」开启时注入 connector token(+ 可选公网 host)→ cicy-code
365
389
  // 起一条命名隧道。关闭时不注入,cicy-code 不起隧道。
366
390
  ...cftEnv(),
391
+ ...tunnelEnv(),
392
+ // Zero-trust gateway (paid tiers): when a tunnel is enrolled, cicy-code dials
393
+ // OUT to <slug>.gw.cicy-ai.com. Off → not injected, no gateway dial.
394
+ ...gatewayEnv(),
367
395
  };
368
396
  // 「局域网访问」开关: 开 → 加 --public,cicy-code 绑 0.0.0.0(同局域网设备可访问,
369
397
  // api_token 仍把关);关 → 默认只绑 127.0.0.1。flag 存 runtime/desktop-flags.json。
@@ -575,4 +603,4 @@ async function update({ logPath, port = DEFAULT_PORT, emit } = {}) {
575
603
  }
576
604
  }
577
605
 
578
- module.exports = { start, stop, restart, update, probeExisting, clearNpxCache, isUpdating, isBusy, ensureEnv, ensureNode, isPublic, setPublicFlag, isDood, setDood, getCft, setCft, cftEnv };
606
+ module.exports = { start, stop, restart, update, probeExisting, clearNpxCache, isUpdating, isBusy, ensureEnv, ensureNode, isPublic, setPublicFlag, isDood, setDood, getCft, setCft, cftEnv, getTunnel, setTunnel, tunnelEnv, getGateway, setGateway, gatewayEnv };
@@ -609,7 +609,10 @@ async function runContainer({ port = 8008, container = "cicy-code-docker", volum
609
609
  // 「Cloudflare Tunnel」开启时,把 connector token(+ 可选 host)作为容器环境变量注入
610
610
  // → 容器内 cicy-code 起命名隧道。配置存在宿主 desktop-flags.json,与 mac native 同源。
611
611
  let cftE = {}; try { cftE = require("./cicy-code").cftEnv(); } catch {}
612
- env = { ...(env || {}), ...cftE };
612
+ // 自托管隧道 cicy-tunnel:开启时注入 CICY_TUNNEL_URL/TOKEN(+旧 CICY_GATEWAY_* 兼容),
613
+ // 容器内 cicy-code 主动拨出到隧道。配置同源 desktop-flags.json。
614
+ let tunE = {}; try { tunE = require("./cicy-code").tunnelEnv(); } catch {}
615
+ env = { ...(env || {}), ...cftE, ...tunE };
613
616
  const envArgs = Object.entries(env || {})
614
617
  .filter(([, v]) => v != null && v !== "")
615
618
  .map(([k, v]) => `-e ${k}='${String(v).replace(/'/g, "'\\''")}'`)
@@ -1366,6 +1366,27 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
1366
1366
  const name = me?.display_name || me?.username || "…";
1367
1367
  const initials = (name || "?").slice(0, 1).toUpperCase();
1368
1368
  const [open, setOpen] = useState(false);
1369
+ // 账号版本档位(个人版/团队版/企业版)—— 账号级,来自 tunnelStatus()=GET /api/gateway/tunnels
1370
+ // 的 tier 字段(personal|team|enterprise)。~分钟级刷新以反映升/降档。
1371
+ const [tier, setTier] = useState("");
1372
+ useEffect(() => {
1373
+ if (!window.cicy?.sidecar?.tunnelStatus) return;
1374
+ let stop = false;
1375
+ const load = () => window.cicy.sidecar.tunnelStatus().then((r) => {
1376
+ if (stop || !r?.ok) return;
1377
+ // 优先用云端 tier;老后端(v2.2.72 前)没这字段 → 按 w-10122 规则从 tunnel_limit 推:
1378
+ // 0→personal / N>0→team / -1→enterprise。
1379
+ let tv = r.tier;
1380
+ if (!tv) {
1381
+ const lim = Number(r.tunnelLimit ?? r.tunnel_limit);
1382
+ if (Number.isFinite(lim)) tv = lim < 0 ? "enterprise" : lim > 0 ? "team" : "personal";
1383
+ }
1384
+ if (tv) setTier(String(tv));
1385
+ }).catch(() => {});
1386
+ load();
1387
+ const id = setInterval(load, 60000);
1388
+ return () => { stop = true; clearInterval(id); };
1389
+ }, []);
1369
1390
  const [trustOpen, setTrustOpen] = useState(false);
1370
1391
  const [auditOpen, setAuditOpen] = useState(false);
1371
1392
  const [termsOpen, setTermsOpen] = useState(false);
@@ -1405,12 +1426,27 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
1405
1426
  } catch (e) { toast.show({ id: "app-update", message: e.message, status: "error", ttl: 5000 }); }
1406
1427
  finally { setCheckingUpd(false); }
1407
1428
  };
1429
+ // 用户版本徽章(个人版/团队版/企业版)—— tier 来自 tunnelStatus()。规范值映射,未知原样,空不渲染。
1430
+ const planTxt = (() => {
1431
+ const raw = String(tier || "").toLowerCase().trim();
1432
+ if (!raw) return "";
1433
+ const map = { personal: tr("plan.personal", "个人版"), team: tr("plan.team", "团队版"), enterprise: tr("plan.enterprise", "企业版") };
1434
+ return map[raw] || raw;
1435
+ })();
1408
1436
  return (
1409
1437
  <>
1410
1438
  <header className="topbar">
1411
1439
  {/* logo 移到 tab-shell 的「我的团队」标签(CICY_LOGO),topbar 不再重复显示品牌 */}
1412
1440
  <div className="user-chip" data-id="UserChip" ref={wrap}>
1413
1441
  {welcome && <span className="welcome">{welcome}</span>}
1442
+ {/* 账号版本徽标 —— user-chip 行内的独立元素(不写进 avatar 按钮,点它不展开菜单),
1443
+ 靠 user-chip 的 inline-flex 排在头像左边 */}
1444
+ {planTxt && (
1445
+ <span data-id="UserChip-plan" className="plan-badge" title={tr("plan.hint", "当前账号版本")}
1446
+ style={{ fontSize: 11, fontWeight: 600, lineHeight: 1.6, padding: "2px 9px", borderRadius: 999, whiteSpace: "nowrap", background: "var(--accent-soft, rgba(120,140,255,.16))", color: "var(--accent, #9db0ff)" }}>
1447
+ {planTxt}
1448
+ </span>
1449
+ )}
1414
1450
  {!me ? (
1415
1451
  // 首次打开:profile 还没拉到 → avatar/名字用 skeleton 占位
1416
1452
  <div className="user-chip__trigger user-chip__trigger--skel" data-id="UserChip-skeleton" aria-hidden>
@@ -2017,6 +2053,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
2017
2053
  const [menuOpen, setMenuOpen] = useState(false);
2018
2054
  const [cftOpen, setCftOpen] = useState(false); // Cloudflare 隧道 modal(卡片层,菜单外)
2019
2055
  const [doodOpen, setDoodOpen] = useState(false); // 容器内使用 Docker(DooD)modal(卡片层,菜单外)
2056
+ const [tunnelOpen, setTunnelOpen] = useState(false); // 自托管隧道(cicy-tunnel)配置 modal(卡片层,菜单外)
2020
2057
  const [confirmRecreate, setConfirmRecreate] = useState(false); // 重建容器 in-app 确认弹窗(不用 native confirm)
2021
2058
  const [portsOpen, setPortsOpen] = useState(false); // 端口设置 modal
2022
2059
  const [portList, setPortList] = useState([]); // 编辑中的额外端口(字符串数组,便于输入)
@@ -2404,6 +2441,11 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
2404
2441
  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setCftOpen(true); }}>
2405
2442
  {tr("sidecar.cftMenu", "Cloudflare 隧道")}
2406
2443
  </button>
2444
+ <button type="button" data-id="DockerCard-tunnel-cfg" className="bcard__menu-item"
2445
+ title={tr("tunnel.hint", "把本机 cicy-code 通过你自建的 cicy-tunnel 暴露到 <slug>.gw.<域名>(填 url+token,透明转发、不需 --public);切换会重启 cicy-code")}
2446
+ onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setTunnelOpen(true); }}>
2447
+ {tr("tunnel.menu", "自托管隧道")}
2448
+ </button>
2407
2449
  <button type="button" data-id="DockerCard-open-dir" className="bcard__menu-item"
2408
2450
  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.(); }}>
2409
2451
  {tr("docker.openWslDir", "打开 WSL 目录")}
@@ -2493,6 +2535,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
2493
2535
  <span>{ctaLabel}</span>
2494
2536
  </button>
2495
2537
  <CftModal open={cftOpen} onClose={() => setCftOpen(false)} toastId="docker-op" />
2538
+ <TunnelModal open={tunnelOpen} onClose={() => setTunnelOpen(false)} toastId="docker-op" />
2496
2539
  <DoodModal open={doodOpen} onClose={() => setDoodOpen(false)} toastId="docker-op" />
2497
2540
  {confirmRecreate && createPortal(
2498
2541
  <div data-id="DockerCard-recreate-modal"
@@ -2624,6 +2667,7 @@ function LocalTeamCard({ team, cloudCode, onOpen, onRename, onRefresh }) {
2624
2667
  if (!local || !window.cicy?.sidecar?.getPublic) return;
2625
2668
  window.cicy.sidecar.getPublic().then((r) => setLanOn(!!r?.public)).catch(() => {});
2626
2669
  }, [local]);
2670
+ const [tunnelOpen, setTunnelOpen] = useState(false); // 自托管隧道(cicy-tunnel)配置 modal(卡片层,菜单外)
2627
2671
  // cicy-code 版本统一从 sidecar.versions() 一处拿("拿版本就一个方法")。
2628
2672
  // running===undefined = 还没查到(用于区分"加载中" vs "停了/拿不到");区别于
2629
2673
  // running===null(查过了但 daemon 没报版本)。latest/installed 同源。
@@ -2941,6 +2985,11 @@ function LocalTeamCard({ team, cloudCode, onOpen, onRename, onRefresh }) {
2941
2985
  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setCftOpen(true); }}>
2942
2986
  {tr("sidecar.cftMenu", "Cloudflare 隧道")}
2943
2987
  </button>
2988
+ <button type="button" data-id="LocalTeamCard-tunnel-cfg" className="bcard__menu-item"
2989
+ title={tr("tunnel.hint", "把本机 cicy-code 通过你自建的 cicy-tunnel 暴露到 <slug>.gw.<域名>(填 url+token,透明转发、不需 --public);切换会重启 cicy-code")}
2990
+ onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setTunnelOpen(true); }}>
2991
+ {tr("tunnel.menu", "自托管隧道")}
2992
+ </button>
2944
2993
  </>
2945
2994
  )}
2946
2995
  {team.cloud_team_id && (
@@ -3064,6 +3113,7 @@ function LocalTeamCard({ team, cloudCode, onOpen, onRename, onRefresh }) {
3064
3113
  document.body,
3065
3114
  )}
3066
3115
  <CftModal open={cftOpen} onClose={() => setCftOpen(false)} toastId={opToastId} />
3116
+ <TunnelModal open={tunnelOpen} onClose={() => setTunnelOpen(false)} toastId={opToastId} />
3067
3117
  <ConfirmModal open={confirmDel}
3068
3118
  title={tr("localTeams.deleteTitle", "删除团队")}
3069
3119
  message={tr("localTeams.deleteMsg", "确定删除「{{name}}」?此操作不可撤销。", { name: team.name })}
@@ -3539,6 +3589,72 @@ function CftModal({ open, onClose, toastId = "cft-op" }) {
3539
3589
  // 容器内使用 Docker(Docker-outside-of-Docker)modal —— 单 checkbox,像 CftModal 一样渲染在
3540
3590
  // 卡片层(菜单外)。开启 → sidecar.setDood → 重建容器挂 docker.sock + 把 docker CLI 装进容器
3541
3591
  // 持久卷,下载进度实时显示在弹窗里。DockerCard(Windows 容器)用。
3592
+ // 自托管隧道 cicy-tunnel modal —— url + token + 开关,和 CftModal 一模一样(渲染在卡片层)。
3593
+ // 保存 → sidecar.setTunnel → cicy-code 带 CICY_TUNNEL_URL/TOKEN 重启,主动拨出到隧道。透明转发。
3594
+ function TunnelModal({ open, onClose, toastId = "tunnel-op" }) {
3595
+ const [busy, setBusy] = useState(false);
3596
+ const [err, setErr] = useState("");
3597
+ const [cfg, setCfg] = useState({ enabled: false, url: "", token: "" });
3598
+ useEffect(() => {
3599
+ if (!open || !window.cicy?.sidecar?.getTunnel) return;
3600
+ setErr("");
3601
+ window.cicy.sidecar.getTunnel().then((r) => { if (r?.tunnel) setCfg({ enabled: !!r.tunnel.enabled, url: r.tunnel.url || "", token: r.tunnel.token || "" }); }).catch(() => {});
3602
+ }, [open]);
3603
+ const save = async () => {
3604
+ if (busy) return;
3605
+ if (cfg.enabled) {
3606
+ if (!String(cfg.url).trim()) { setErr(tr("tunnel.needUrl", "开启需要填隧道地址(wss://…/_tunnel/connect)")); return; }
3607
+ if (!String(cfg.token).trim()) { setErr(tr("tunnel.needToken", "开启需要填节点 token")); return; }
3608
+ }
3609
+ setBusy(true); setErr("");
3610
+ toast.show({ id: toastId, message: tr("tunnel.applying", "应用隧道配置,重启 cicy-code 中…"), status: "running", progress: undefined });
3611
+ try {
3612
+ const r = await window.cicy.sidecar.setTunnel(cfg);
3613
+ if (r?.ok) {
3614
+ if (r.tunnel) setCfg(r.tunnel);
3615
+ onClose && onClose();
3616
+ toast.show({ id: toastId, message: cfg.enabled ? tr("tunnel.on", "隧道已开启") : tr("tunnel.off", "隧道已关闭"), status: "done", ttl: 4000 });
3617
+ } else { setErr(r?.error || tr("tunnel.failed", "设置失败")); toast.show({ id: toastId, message: tr("tunnel.failed", "设置失败") + (r?.error ? `: ${r.error}` : ""), status: "error", ttl: 6000 }); }
3618
+ } catch (e) { setErr(e?.message || String(e)); toast.show({ id: toastId, message: tr("tunnel.failed", "设置失败") + `: ${e?.message || e}`, status: "error", ttl: 6000 }); }
3619
+ finally { setBusy(false); }
3620
+ };
3621
+ const setOpen = (v) => { if (!v) onClose && onClose(); };
3622
+ if (!open) return null;
3623
+ return (
3624
+ <>
3625
+ {createPortal(
3626
+ <div data-id="tunnel-modal"
3627
+ style={{ position: "fixed", inset: 0, zIndex: 66, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}
3628
+ onMouseDown={(e) => { if (!busy && e.target === e.currentTarget) setOpen(false); }}>
3629
+ <div onClick={(e) => e.stopPropagation()}
3630
+ style={{ width: 440, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
3631
+ <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{tr("tunnel.title", "自托管隧道 · cicy-tunnel")}</div>
3632
+ <div style={{ fontSize: 12, opacity: .6, marginBottom: 16 }}>{tr("tunnel.subtitle", "把本机 cicy-code 通过你自建的 cicy-tunnel 暴露到 <slug>.gw.<域名>,透明转发。url+token 从隧道 enroll 拿。")}</div>
3633
+ <label data-id="tunnel-toggle-row" style={{ display: "flex", alignItems: "center", gap: 10, cursor: busy ? "default" : "pointer", marginBottom: 16 }}>
3634
+ <input type="checkbox" data-id="tunnel-toggle" checked={cfg.enabled} disabled={busy}
3635
+ onChange={(e) => setCfg((c) => ({ ...c, enabled: e.target.checked }))} />
3636
+ <span style={{ fontSize: 14, fontWeight: 600 }}>{tr("tunnel.enable", "开启隧道")}</span>
3637
+ </label>
3638
+ <div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>{tr("tunnel.url", "隧道地址 URL")}</div>
3639
+ <input data-id="tunnel-url" className="login-email-input" style={{ width: "100%", fontFamily: "var(--mono)" }}
3640
+ value={cfg.url} placeholder="wss://my-mac.gw.example.com/_tunnel/connect" spellCheck={false} disabled={busy}
3641
+ onChange={(e) => setCfg((c) => ({ ...c, url: e.target.value.replace(/[\r\n\s]+/g, "").trim() }))} />
3642
+ <div style={{ fontSize: 12, opacity: .7, margin: "12px 0 6px" }}>{tr("tunnel.token", "节点 Token")}</div>
3643
+ <textarea data-id="tunnel-token" rows={3} className="login-email-input" style={{ width: "100%", resize: "vertical", lineHeight: 1.4, fontFamily: "var(--mono)", wordBreak: "break-all" }}
3644
+ value={cfg.token} placeholder={tr("tunnel.tokenPh", "粘贴 enroll 输出的 token")} spellCheck={false} disabled={busy}
3645
+ onChange={(e) => setCfg((c) => ({ ...c, token: e.target.value.replace(/\s+/g, "") }))} />
3646
+ {err && <div className="error" style={{ marginTop: 8, fontSize: 12 }}>{err}</div>}
3647
+ <div className="modal-actions">
3648
+ <button type="button" className="btn-ghost" disabled={busy} onClick={() => setOpen(false)}>{tr("common.cancel", "取消")}</button>
3649
+ <button type="button" className="btn-primary" data-id="tunnel-save" disabled={busy} onClick={save}>{busy ? tr("common.saving", "保存中…") : tr("common.save", "保存")}</button>
3650
+ </div>
3651
+ </div>
3652
+ </div>,
3653
+ document.body,
3654
+ )}
3655
+ </>
3656
+ );
3657
+ }
3542
3658
  function DoodModal({ open, onClose, toastId = "dood-op" }) {
3543
3659
  const [busy, setBusy] = useState(false);
3544
3660
  const [err, setErr] = useState("");