cicy-desktop 2.1.154 → 2.1.156
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 +1 -1
- package/src/backends/auth-email.js +109 -0
- package/src/backends/homepage-preload.js +6 -0
- package/src/backends/homepage-react/assets/index-CKQp3xSO.js +377 -0
- package/src/backends/homepage-react/assets/index-Cuc3w0O-.css +1 -0
- package/src/backends/homepage-react/index.html +2 -2
- package/src/main.js +44 -26
- package/workers/render/src/App.css +14 -0
- package/workers/render/src/App.jsx +71 -4
- package/src/backends/homepage-react/assets/index-CKhSx1lp.css +0 -1
- package/src/backends/homepage-react/assets/index-LZDX5apT.js +0 -377
package/package.json
CHANGED
|
@@ -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).
|