cicy-desktop 2.1.153 → 2.1.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.153",
3
+ "version": "2.1.155",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -7,47 +7,10 @@ APP="/Applications/CiCy Desktop.app"
7
7
  [ -d "$APP" ] || exit 0
8
8
  /usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
9
9
  /usr/bin/xattr -cr "$APP" 2>/dev/null || true
10
-
11
- # ── Stable LOCAL code-signing identity (主人令: 用自签证书) ────────────────────
12
- # ad-hoc has no stable identity, so macOS never persists the Screen-Recording TCC
13
- # grant it re-prompts forever. A self-signed cert gives the app a STABLE designated
14
- # requirement, so the grant sticks after the user allows it once. We run as root here,
15
- # so we can write the System keychain + trust the cert with NO password prompt.
16
- SIGN_CN="CiCy Desktop Local Signing"
17
- SYS_KC="/Library/Keychains/System.keychain"
18
- if ! /usr/bin/security find-identity -p codesigning "$SYS_KC" 2>/dev/null | grep -qF "$SIGN_CN"; then
19
- TMPD="$(/usr/bin/mktemp -d)"
20
- # Config file (portable across the LibreSSL that ships in /usr/bin/openssl, which
21
- # lacks `-addext`). codeSigning EKU is what makes it a valid signing identity.
22
- cat > "$TMPD/c.cnf" <<CNF
23
- [req]
24
- distinguished_name = dn
25
- x509_extensions = v3
26
- prompt = no
27
- [dn]
28
- CN = $SIGN_CN
29
- [v3]
30
- basicConstraints = critical,CA:false
31
- keyUsage = critical,digitalSignature
32
- extendedKeyUsage = critical,codeSigning
33
- CNF
34
- /usr/bin/openssl req -x509 -newkey rsa:2048 -keyout "$TMPD/k.pem" -out "$TMPD/c.pem" \
35
- -days 3650 -nodes -config "$TMPD/c.cnf" >/dev/null 2>&1
36
- /usr/bin/openssl pkcs12 -export -inkey "$TMPD/k.pem" -in "$TMPD/c.pem" \
37
- -out "$TMPD/id.p12" -passout pass:cicy -name "$SIGN_CN" >/dev/null 2>&1
38
- # -A = no ACL on the key → codesign can use it without a partition-list/password dance.
39
- /usr/bin/security import "$TMPD/id.p12" -k "$SYS_KC" -P cicy -A >/dev/null 2>&1 || true
40
- /usr/bin/security add-trusted-cert -d -r trustRoot -p codeSign -k "$SYS_KC" "$TMPD/c.pem" >/dev/null 2>&1 || true
41
- /bin/rm -rf "$TMPD"
42
- fi
43
- if /usr/bin/security find-identity -p codesigning "$SYS_KC" 2>/dev/null | grep -qF "$SIGN_CN"; then
44
- /usr/bin/codesign --force --deep --sign "$SIGN_CN" --keychain "$SYS_KC" "$APP" 2>/dev/null \
45
- && echo "signed with local identity: $SIGN_CN" \
46
- || /usr/bin/codesign --force --deep --sign - "$APP" 2>/dev/null || true
47
- else
48
- echo "local identity unavailable — falling back to ad-hoc"
49
- /usr/bin/codesign --force --deep --sign - "$APP" 2>/dev/null || true
50
- fi
10
+ # Re-ad-hoc-sign so the app opens with no Apple cert. (A self-signed cert was tried to
11
+ # make the Screen-Recording grant persist it did sign, but macOS 15 still re-prompts
12
+ # without an Apple Team ID, so it was dropped; desktop capture is OFF by default on mac.)
13
+ /usr/bin/codesign --force --deep --sign - "$APP" 2>/dev/null || true
51
14
 
52
15
  # Desktop shortcut for the logged-in user (postinstall runs as root, so resolve the
53
16
  # console user + their home and chown the symlink to them). Launchpad/Spotlight pick
@@ -0,0 +1,109 @@
1
+ // Email magic-link DEVICE-POLL login — cross-device safe.
2
+ //
3
+ // The 127.0.0.1 loopback in auth-loopback.js only works when the magic link is
4
+ // clicked on the SAME machine: a phone can't reach the desktop's localhost, so a
5
+ // link opened on mobile leaves the desktop hanging until timeout (todo#196).
6
+ //
7
+ // This flow (cloud contract by w-10122) decouples the click from the device:
8
+ // 1. desktop generates a high-entropy `state` (it IS the retrieval credential).
9
+ // 2. POST /api/auth/email/request {email, state, flow:'desktop_poll'} → cloud
10
+ // registers state=pending and sends the magic-link email.
11
+ // 3. user clicks the link on ANY device → cloud mints the session, stores it
12
+ // under `state`, and shows a "回到桌面端" page (no loopback redirect).
13
+ // 4. desktop polls GET /api/auth/desktop/poll?state=<state> until it flips to
14
+ // `ready` (one-time consumed), then fires onResult with the SAME payload
15
+ // shape as the loopback flow so main's auth hook is unchanged.
16
+ //
17
+ // state TTL is 10 min cloud-side; the token is sk-sess- (the SOLE Bearer for all
18
+ // /api/* — no exchange, no New-Api-User header needed).
19
+
20
+ const crypto = require("crypto");
21
+ const log = require("electron-log");
22
+
23
+ const CLOUD_BASE = "https://cicy-ai.com";
24
+ const REQUEST_URL = `${CLOUD_BASE}/api/auth/email/request`;
25
+ const POLL_URL = `${CLOUD_BASE}/api/auth/desktop/poll`;
26
+ const POLL_EVERY_MS = 2500;
27
+ const TIMEOUT_MS = 600_000; // 10 min — matches the cloud state TTL.
28
+
29
+ let _state = null;
30
+ let _timer = null;
31
+ let _onResult = null;
32
+ let _resultFired = false;
33
+
34
+ function shutdown(reason) {
35
+ if (_timer) { clearTimeout(_timer); _timer = null; }
36
+ _state = null;
37
+ if (reason) log.info(`[auth-email] shutdown: ${reason}`);
38
+ }
39
+
40
+ // Deliver the result exactly once — the poll loop, the timeout, and cancel() all
41
+ // race to be first (same guard pattern as auth-loopback).
42
+ function fire(payload) {
43
+ if (_resultFired) return;
44
+ _resultFired = true;
45
+ try { _onResult && _onResult(payload); } catch {}
46
+ }
47
+
48
+ // Begin the device-poll flow. Resolves once the email has been requested (so the
49
+ // renderer can show "邮件已发送"); the actual login lands later via onResult.
50
+ async function startEmailLogin({ email, onResult } = {}) {
51
+ shutdown("new email login");
52
+ const addr = String(email || "").trim();
53
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) throw new Error("invalid email");
54
+
55
+ _state = crypto.randomBytes(24).toString("hex");
56
+ _resultFired = false;
57
+ _onResult = onResult;
58
+ const state = _state;
59
+
60
+ const res = await fetch(REQUEST_URL, {
61
+ method: "POST",
62
+ headers: { "content-type": "application/json" },
63
+ body: JSON.stringify({ email: addr, state, flow: "desktop_poll" }),
64
+ });
65
+ if (!res.ok) {
66
+ shutdown(`request HTTP ${res.status}`);
67
+ throw new Error(`发送登录邮件失败 (HTTP ${res.status})`);
68
+ }
69
+ log.info(`[auth-email] request sent for ${addr}, polling state ${state.slice(0, 8)}…`);
70
+
71
+ const startedAt = Date.now();
72
+ const tick = async () => {
73
+ if (state !== _state) return; // superseded / cancelled
74
+ if (Date.now() - startedAt > TIMEOUT_MS) { fire({ error: "timeout" }); shutdown("timeout"); return; }
75
+ try {
76
+ const r = await fetch(`${POLL_URL}?state=${encodeURIComponent(state)}`);
77
+ if (r.status === 404) { fire({ error: "expired" }); shutdown("poll 404"); return; }
78
+ const j = await r.json().catch(() => ({}));
79
+ if (j.status === "ready" && j.token) {
80
+ // Map cloud fields → the loopback payload shape main already persists.
81
+ // token = access_token = sk-sess- (SOLE Bearer); user_id for any local
82
+ // New-Api-User header (cloud itself doesn't require it).
83
+ fire({
84
+ token: j.token,
85
+ accessToken: j.access_token || j.token,
86
+ userId: j.user_id != null ? String(j.user_id) : "",
87
+ reused: !!j.reused,
88
+ email: j.email || addr,
89
+ });
90
+ shutdown("ready");
91
+ return;
92
+ }
93
+ if (j.status === "expired") { fire({ error: "expired" }); shutdown("expired"); return; }
94
+ // pending → keep polling.
95
+ } catch (e) {
96
+ // Transient network blip — keep polling until the overall timeout.
97
+ log.warn(`[auth-email] poll error (retrying): ${e.message}`);
98
+ }
99
+ if (state === _state) _timer = setTimeout(tick, POLL_EVERY_MS);
100
+ };
101
+ _timer = setTimeout(tick, POLL_EVERY_MS);
102
+ return { state, email: addr };
103
+ }
104
+
105
+ function cancel() {
106
+ if (_state) { fire({ error: "cancelled" }); shutdown("cancelled by user"); }
107
+ }
108
+
109
+ module.exports = { startEmailLogin, cancel };
@@ -241,6 +241,12 @@ contextBridge.exposeInMainWorld("cicy", {
241
241
  auth: {
242
242
  loginStart: () => logInvoke("auth:login-start"),
243
243
  loginCancel: () => logInvoke("auth:login-cancel"),
244
+ // Email magic-link device-poll login (cross-device: the link works when
245
+ // clicked on a phone). emailLoginStart resolves once the email is sent
246
+ // ({ ok, email } | { ok:false, error }); the actual login lands later on the
247
+ // same auth:complete event (onComplete below) as the loopback flow.
248
+ emailLoginStart: (email) => logInvoke("auth:email-start", email),
249
+ emailLoginCancel: () => logInvoke("auth:email-cancel"),
244
250
  // Durable, origin-independent login persisted in main (global.json).
245
251
  // getSaved() restores it when this origin's localStorage is empty;
246
252
  // logout() clears it (the only thing that should).