From 8df8ea1fea854de8e6b4e61489b1ef1bc4aa9fce Mon Sep 17 00:00:00 2001 From: wjsqqj Date: Sat, 26 Sep 2026 08:45:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=20z.ai=20?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E7=99=BB=E5=BD=95=E6=94=AF=E6=8C=81=E4=B8=8E?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8A=9B=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/i18n.rs | 8 ++ src-tauri/src/lib.rs | 162 ++++++++++++++++++++++++++++++++--- src-tauri/src/oauth.rs | 187 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 345 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs index 9870728..a1f199e 100644 --- a/src-tauri/src/i18n.rs +++ b/src-tauri/src/i18n.rs @@ -128,6 +128,10 @@ const ZH: &[(&str, &str)] = &[ ("tray.unsaved", "未保存的登录"), ("tray.logged_out", "未登录"), ("title.login", "登录 ZCode 账号"), + ("login.assist.email", "邮箱登录"), + ("login.assist.signup", "邮箱注册"), + ("login.assist.hint", "已在邮箱登录页"), + ("login.assist.hint_signup", "已在邮箱注册页"), ("title.captcha", "安全验证"), ("title.settings", "Z·SWITCH 设置"), ("dialog.zsb", "ZSwitch 加密捆绑包(.zsb)"), @@ -285,6 +289,10 @@ const EN: &[(&str, &str)] = &[ ("tray.unsaved", "Unsaved login"), ("tray.logged_out", "Not logged in"), ("title.login", "Sign in to ZCode"), + ("login.assist.email", "Email sign-in"), + ("login.assist.signup", "Email sign-up"), + ("login.assist.hint", "Already on the email sign-in page"), + ("login.assist.hint_signup", "Already on the email sign-up page"), ("title.captcha", "Security verification"), ("title.settings", "Z·SWITCH Settings"), ("dialog.zsb", "ZSwitch encrypted bundle (.zsb)"), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7b0fb5..fcff22f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -418,13 +418,23 @@ async fn oauth_begin(app: AppHandle, provider: String) -> Result Result().map_err(|e| i18n::trf("err.oauth.bad_authorize_url", &[("e", &e.to_string())]))?), + tauri::WebviewUrl::External(entry_url.parse::().map_err(|e| i18n::trf("err.oauth.bad_authorize_url", &[("e", &e.to_string())]))?), ) .title(i18n::tr("title.login")) .theme(Some(tauri::Theme::Dark)) @@ -463,17 +482,81 @@ async fn oauth_begin(app: AppHandle, provider: String) -> Result authorize", url.host_str().unwrap_or(""), url.path()), + ); + let target = relay_to.clone(); + let app4 = app2.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1500)); + if let Some(w) = app4.get_webview_window("login") { + if let Ok(t) = target.parse::() { + let _ = w.navigate(t); + } + } + }); + log_after_relay(app2.clone(), flow_relay.clone(), 5000); + } + true }) .build() .map_err(|e| { @@ -496,10 +579,67 @@ async fn oauth_begin(app: AppHandle, provider: String) -> Result authorize", u.path())); + if let Ok(t) = target.parse::() { + let _ = w.navigate(t); + } + log_after_relay(app_relay.clone(), fl.clone(), 5000); + return; + } + }); + } spawn_poll_loop(app.clone(), provider.clone(), flow.clone(), mid, poll_cfg); Ok(json!({ "opened": true, "provider": provider })) } +/// 接力后留痕:几秒后把窗口实际落点写进日志,便于判断授权是否成功 +fn log_after_relay(app: AppHandle, flow: String, delay_ms: u64) { + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + if let Some(w) = app.get_webview_window("login") { + if let Ok(u) = w.url() { + flowlog::log(&flow, "zai-after", &format!("{}{}", u.host_str().unwrap_or(""), u.path())); + } + } + }); +} + +/// 登录 / 注册相关路径:说明还没登录完成,zai 接力不能在这些页面触发 +fn is_login_page(path: &str) -> bool { + if path.starts_with("/login/callback") { + return false; // 登录回跳页:登录已完成 + } + ["/auth", "/login", "/signin", "/sign-in", "/signup", "/sign-up", "/register", "/reset", "/forgot"] + .iter() + .any(|p| path.starts_with(p)) +} + fn sweep_login_profiles(root: &std::path::Path) { let Ok(entries) = std::fs::read_dir(root) else { return }; let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(7 * 24 * 3600); diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs index c96b5e3..feda43c 100644 --- a/src-tauri/src/oauth.rs +++ b/src-tauri/src/oauth.rs @@ -21,6 +21,144 @@ pub const OAUTH_PROVIDERS: &[OAuthProvider] = &[ const REDIRECT_ENC: &str = "zcode%3A%2F%2Foauth%2Fcallback"; +/// z.ai 官网登录入口(官网 OAuth client:邮箱 / 第三方登录方式齐全)。 +/// zcode 的 API 授权 client 只放手机号登录,所以 zai 入口先用这个页面建立 chat.z.ai 会话, +/// 登录成功后(回调 z.ai/login/callback)再接力到本次 flow 的 authorize_url 取码。 +pub const ZAI_LOGIN_ENTRY_URL: &str = "https://chat.z.ai/auth?response_type=code&client_id=client_lS94_Ka2ycE9IwCNYisudg&redirect_uri=https%3A%2F%2Fz.ai%2Flogin%2Fcallback%3Fredirect%3D%2525252Fmodel-api&state=1790259680634"; + +/// z.ai 登录窗口内的辅助按钮条:一键切到官网「邮箱登录 / 邮箱注册」。 +/// zcode 的 API 授权 client 只放手机号登录,邮箱入口只在官网 client 登录页(ZAI_LOGIN_ENTRY_URL)才有; +/// 被接力带到手机号页后点这两个按钮即可回到邮箱表单,找不到入口时先回官网页再自动点一次。 +const ZAI_EMAIL_ASSIST_JS: &str = r##"(function () { + var ENTRY = __ZSW_ENTRY__; + var LBL = { + login: __ZSW_LABEL_EMAIL_LOGIN__, + signup: __ZSW_LABEL_EMAIL_SIGNUP__, + already: __ZSW_LABEL_ALREADY__, + alreadySignup: __ZSW_LABEL_ALREADY_SIGNUP__ + }; + // 登录表单和注册表单都有邮箱输入框,只能靠表单特征文案区分,不能只看 input[type=email] + var PAGE = { + email: ['邮箱登录', 'Email Login', 'Email login', 'Sign in with Email', 'Sign in with email'], + signup: ['注册', 'Sign up', 'Sign Up', 'Register'], + signupForm: ['创建账号', '已经拥有账号了?', 'Create account', 'Create Account', 'Already have an account'], + loginForm: ['忘记密码?', 'Forgot password', 'Forgot password?'], + toLogin: ['登录', 'Sign in', 'Sign In'] + }; + var INTENT_KEY = 'zsw_email_assist_intent'; + var BAR_ID = 'zsw-email-assist'; + var BTN_STYLE = 'cursor:pointer;border:1px solid rgba(255,255,255,.3);background:rgba(20,20,24,.88);color:#fff;border-radius:999px;padding:6px 12px;font:12px/1 system-ui,sans-serif;white-space:nowrap'; + var findText = function (txt) { + var bar = document.getElementById(BAR_ID); + var all = document.querySelectorAll('button,a'); + for (var i = 0; i < all.length; i++) { + var el = all[i]; + if (bar && bar.contains(el)) continue; + if ((el.innerText || '').trim() === txt && el.offsetParent !== null) return el; + } + return null; + }; + var findAny = function (list) { + for (var i = 0; i < list.length; i++) { + var el = findText(list[i]); + if (el) return el; + } + return null; + }; + var hint = function (msg) { + var bar = document.getElementById(BAR_ID); + var t = bar && bar.querySelector('[data-hint]'); + if (!t) return; + t.textContent = msg; + t.style.opacity = '1'; + setTimeout(function () { t.style.opacity = '0'; }, 1800); + }; + var openEmail = function (signup) { + var onSignup = findAny(PAGE.signupForm); + var onLogin = findAny(PAGE.loginForm); + if (signup) { + if (onSignup) { hint(LBL.alreadySignup); return; } + var toSignup = onLogin ? findAny(PAGE.signup) : null; + if (toSignup) { toSignup.click(); return; } + } else { + if (onLogin) { hint(LBL.already); return; } + var toLogin = onSignup ? findAny(PAGE.toLogin) : null; + if (toLogin) { toLogin.click(); return; } + } + var btn = findAny(PAGE.email); + if (btn) { + btn.click(); + if (signup) setTimeout(function () { var r = findAny(PAGE.signup); if (r) r.click(); }, 800); + return; + } + try { sessionStorage.setItem(INTENT_KEY, signup ? 'signup' : 'login'); } catch (e) {} + location.href = ENTRY; + }; + var consume = function () { + var intent; + try { intent = sessionStorage.getItem(INTENT_KEY); } catch (e) { return; } + if (!intent) return; + var tries = 0; + var timer = setInterval(function () { + if (++tries > 40) { clearInterval(timer); return; } + var btn = findAny(PAGE.email); + if (!btn) return; + clearInterval(timer); + try { sessionStorage.removeItem(INTENT_KEY); } catch (e) {} + btn.click(); + if (intent === 'signup') { + var n = 0; + var t2 = setInterval(function () { + if (++n > 20) { clearInterval(t2); return; } + var r = findAny(PAGE.signup); + if (r) { clearInterval(t2); r.click(); } + }, 300); + } + }, 300); + }; + var render = function () { + if (document.getElementById(BAR_ID) || !document.body) return; + var bar = document.createElement('div'); + bar.id = BAR_ID; + bar.setAttribute('style', 'position:fixed;top:10px;right:10px;z-index:2147483647;display:flex;flex-direction:column;align-items:flex-end;gap:6px'); + var mk = function (label, signup) { + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + b.setAttribute('style', BTN_STYLE); + b.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); openEmail(signup); }); + return b; + }; + var wrap = document.createElement('div'); + wrap.setAttribute('style', 'display:flex;gap:6px'); + wrap.appendChild(mk(LBL.login, false)); + wrap.appendChild(mk(LBL.signup, true)); + var t = document.createElement('div'); + t.setAttribute('data-hint', '1'); + t.setAttribute('style', 'opacity:0;transition:opacity .2s;background:rgba(20,20,24,.9);color:#fff;border-radius:6px;padding:5px 9px;font:11px/1.2 system-ui,sans-serif'); + bar.appendChild(wrap); + bar.appendChild(t); + document.body.appendChild(bar); + }; + var bootTries = 0; + var boot = function () { + if (document.body) { render(); consume(); return; } + if (++bootTries > 50) return; + setTimeout(boot, 100); + }; + boot(); +})();"##; + +pub fn zai_email_assist_script() -> String { + let lit = |s: String| serde_json::Value::String(s).to_string(); + ZAI_EMAIL_ASSIST_JS + .replace("__ZSW_ENTRY__", &lit(ZAI_LOGIN_ENTRY_URL.to_string())) + .replace("__ZSW_LABEL_EMAIL_LOGIN__", &lit(crate::i18n::tr("login.assist.email"))) + .replace("__ZSW_LABEL_EMAIL_SIGNUP__", &lit(crate::i18n::tr("login.assist.signup"))) + .replace("__ZSW_LABEL_ALREADY__", &lit(crate::i18n::tr("login.assist.hint"))) + .replace("__ZSW_LABEL_ALREADY_SIGNUP__", &lit(crate::i18n::tr("login.assist.hint_signup"))) +} + pub fn bridge_redirect_uri() -> String { format!("https://zcode.z.ai/app/oauth/login?redirect={REDIRECT_ENC}&app_version={}", quota::CLIENT_APP_VERSION) } @@ -36,6 +174,8 @@ fn urlencode(s: &str) -> String { pub struct FlowInit { pub authorize_url: String, + /// 服务端原始 authorize_url(未经入口形态规范化):已登录时直接发码,用于 zai 第二段接力 + pub raw_authorize_url: String, pub state: String, pub poll_url: String, pub poll_token: String, @@ -77,8 +217,12 @@ fn init_flow_at(url: &str, provider: &str, mid: &str) -> Result u, + None => authorize_raw.to_string(), + }; let poll_token = data .get("poll_token") .and_then(|x| x.as_str()) @@ -114,6 +258,7 @@ fn init_flow_at(url: &str, provider: &str, mid: &str) -> Result Result Option { + if provider != "zai" { + return None; + } + let u: tauri::Url = raw.parse().ok()?; + if u.host_str() != Some("chat.z.ai") || u.path() != "/api/oauth/authorize" { + return None; + } + let pick = |key: &str| { + u.query_pairs() + .find(|(k, _)| k.as_ref() == key) + .map(|(_, v)| v.into_owned()) + }; + let response_type = pick("response_type").unwrap_or_else(|| "code".to_string()); + let client_id = pick("client_id")?; + let redirect_uri = pick("redirect_uri")?; + let state = pick("state")?; + let mut out = format!( + "https://chat.z.ai/auth?response_type={}&client_id={}&redirect_uri={}&state={}", + urlencode(&response_type), + urlencode(&client_id), + urlencode(&redirect_uri), + urlencode(&state), + ); + for (k, v) in u + .query_pairs() + .filter(|(k, _)| !matches!(k.as_ref(), "response_type" | "client_id" | "redirect_uri" | "state")) + { + out.push('&'); + out.push_str(&urlencode(&k)); + out.push('='); + out.push_str(&urlencode(&v)); + } + Some(out) +} + #[derive(Debug)] pub enum PollOutcome { Pending,