From 023fd31c113a8bd20327d8215134bf7cccb23f90 Mon Sep 17 00:00:00 2001 From: Jinhui Zheng Date: Thu, 1 Oct 2026 20:31:06 +0200 Subject: [PATCH] fix: preserve native login identity and resume automatic renewal --- README.md | 2 +- docs/login-session.md | 66 +++++ package.json | 4 +- scripts/test-auto-claim-scheduler.mjs | 245 ++++++++++++++++ scripts/test-auto-controls.mjs | 56 ++++ scripts/test-captcha-claim.mjs | 133 +++++++++ scripts/test-claim-result.mjs | 159 +++++++++++ scripts/test-login-session.mjs | 135 +++++++++ src-tauri/src/cli.rs | 2 +- src-tauri/src/i18n.rs | 6 + src-tauri/src/lib.rs | 149 ++++++++-- src-tauri/src/quota.rs | 19 +- src-tauri/src/store.rs | 393 ++++++++------------------ src/auto-claim-scheduler.js | 61 ++++ src/captcha.js | 77 +++-- src/claim-result.js | 19 ++ src/locales/en.js | 5 +- src/locales/zh.js | 5 +- src/main.js | 125 +++++--- 19 files changed, 1280 insertions(+), 381 deletions(-) create mode 100644 docs/login-session.md create mode 100644 scripts/test-auto-claim-scheduler.mjs create mode 100644 scripts/test-auto-controls.mjs create mode 100644 scripts/test-captcha-claim.mjs create mode 100644 scripts/test-claim-result.mjs create mode 100644 scripts/test-login-session.mjs create mode 100644 src/auto-claim-scheduler.js create mode 100644 src/claim-result.js diff --git a/README.md b/README.md index f03fc44..b27d45e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Tauri 2 桌面工具:在多个 ZCode 账号之间一键切换,自动显示 ## 功能 -- **保存 / 切换账号**:一键切换登录身份;切换前自动保全当前登录,绝不丢号;设备身份跟账号走,远程控制中继密钥跨切换保活 +- **保存 / 切换账号**:一键切换登录身份;切换前自动保全当前登录,绝不丢号;沿用 ZCode 本机设备标识和监控会话,远程控制中继密钥跨切换保活 - **添加账号**:工具内 OAuth 登录新号(BigModel / z.ai 双入口),全程不动当前登录 - **额度展示**:账号行内联显示套餐额度与重置时间,多套餐分组 - **活动领取**:可领套餐一键领取;「自动领取」开关(默认关)定时自动检测并领取,手动操作优先 diff --git a/docs/login-session.md b/docs/login-session.md new file mode 100644 index 0000000..075db15 --- /dev/null +++ b/docs/login-session.md @@ -0,0 +1,66 @@ +# Native login and saved claim context + +Account switching restores the full saved credential JSON, including unknown +fields. Native ZCode owns its installation telemetry-state.json and RUM stores. +Switch must not overwrite deviceMid, _arms_uid, or _arms_session when selecting, +saving or restoring an account. Missing native device state is initialized by +ZCode, never by creating or rotating a replacement ID in Switch. + +Claim eligibility has a separate saved-login context. On 2026-10-01, a limited +read-only comparison of two expired accounts returned empty eligibility with the +current installation MID and an available plan with each existing saved MID. +Balance queries were empty in both cases. Replacing the saved claim context with +the current installation MID therefore broke renewal eligibility for those +snapshots; an empty balance was the actual expired-plan result, not a rendering +failure. + +Claim preview, activation and claim submission use the MID recorded with that +login (the historical virtual_device_mid field). It is read as-is and never +written to native device state. Snapshots without a recorded MID use the actual +native MID. Native capture and OAuth persistence record the MID actually used, +without uniqueness checks or random generation; a completed new OAuth login +updates the context along with its credentials. Encrypted account bundles retain +this optional context as claimDeviceMid while accepting older version-2 bundles. +Legacy virtual_arms_uid metadata is retained for compatibility and not applied. + +Updating an existing snapshot archives refreshed tokens/configuration and retains +its existing claim context. It does not infer a new login from a token change. + +Native OAuth and quota/model-side state use the current installation MID. +An explicitly expired target JWT is rejected before closing ZCode or writing +login files. An already active account first synchronizes native refreshed +credentials back to its snapshot. An empty successful balance is displayed as +no active quota, and an eligibility query failure is displayed separately. + +When automatic claiming is enabled, restoring the persisted setting starts a +round after one second. Busy operations retain the pending round and retry the +scheduling check after five seconds. Rounds run serially, with the next periodic +round ten minutes after completion. Closing the toggle cancels future work +before its persistence request completes. Qualification refresh can request an +earlier round without another toggle. + +Each attempt registers its result waiter before opening verification and carries +one claimId through the window, submission, cancellation and result. Late events +cannot complete or cancel a different attempt. Automatic verification that +requires interaction stops before notifying the main window, so a late SDK +success cannot race its cancellation. Manual interactive verification remains +available. SDK setup failures terminate the waiter separately from challenges. + +Verification: + +```powershell +npm run tauri -- build --no-bundle +npm run test:login -- src-tauri/target/release/zcode-switch.exe +npm run test:claims +cargo test --lib --manifest-path src-tauri/Cargo.toml +``` + +Synthetic CLI checks explicitly isolate home/data directories and disable +process control and upstream probes through existing sandbox mode. They verify +forced restore, already-active synchronization, missing native state, expired +snapshots, opaque credential preservation and automatic account preservation. +Rust checks additionally cover distinct saved/native claim contexts and bundle +round trips. Live read-only eligibility must be checked on the final executable. +These checks do not establish CAPTCHA submission, actual grants, a running +client's hot reload, or elimination of service-side 3012. Real renewal and a +same-account/model/network request remain separate acceptance steps. diff --git a/package.json b/package.json index 678e4f6..c62bdd7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "tauri": "tauri", "dist": "node scripts/dist.mjs", - "check:i18n": "node scripts/check-i18n-keys.mjs" + "check:i18n": "node scripts/check-i18n-keys.mjs", + "test:login": "node scripts/test-login-session.mjs", + "test:claims": "node --test scripts/test-auto-claim-scheduler.mjs scripts/test-claim-result.mjs scripts/test-captcha-claim.mjs scripts/test-auto-controls.mjs" }, "dependencies": { "@tauri-apps/api": "^2.1.1" diff --git a/scripts/test-auto-claim-scheduler.mjs b/scripts/test-auto-claim-scheduler.mjs new file mode 100644 index 0000000..59ab15b --- /dev/null +++ b/scripts/test-auto-claim-scheduler.mjs @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAutoClaimScheduler } from '../src/auto-claim-scheduler.js'; + +// Virtual time only: these tests never invoke Tauri, account APIs or real timers. +function fakeClock() { + let time = 0; + let nextId = 1; + const timers = new Map(); + const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; + return { + now: () => time, + setTimer(fn, delay) { + const id = nextId++; + timers.set(id, { at: time + delay, fn }); + return id; + }, + clearTimer: id => timers.delete(id), + async advance(ms) { + const target = time + ms; + await flush(); + let turns = 0; + while (true) { + const due = [...timers].filter(([, value]) => value.at <= target) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!due) break; + assert.ok(++turns < 1000, 'scheduler must not spin at the same instant'); + time = due[1].at; + timers.delete(due[0]); + due[1].fn(); + await flush(); + } + time = target; + await flush(); + }, + flush, + pending: () => timers.size, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function setup(t, overrides = {}) { + const clock = fakeClock(); + const calls = []; + const errors = []; + const scheduler = createAutoClaimScheduler({ + now: clock.now, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + runRound: async () => { calls.push(clock.now()); }, + isBusy: () => false, + onError: error => errors.push(error), + intervalMs: 100, + startDelayMs: 10, + busyDelayMs: 5, + ...overrides, + }); + t.after(() => scheduler.dispose()); + return { clock, calls, errors, scheduler }; +} + +test('restoring enabled state runs without another toggle and continues periodically', async t => { + const { clock, calls, scheduler } = setup(t); + scheduler.setEnabled(true); + assert.deepEqual(scheduler.getStatus(), { enabled: true, running: false, phase: 'waiting', nextAt: 10 }); + await clock.advance(9); + assert.equal(calls.length, 0); + await clock.advance(1); + assert.deepEqual(calls, [10]); + await clock.advance(300); + assert.deepEqual(calls, [10, 110, 210, 310]); +}); + +test('repeated observation of enabled state cannot postpone the due round', async t => { + const { clock, calls, scheduler } = setup(t); + scheduler.setEnabled(true); + for (let i = 0; i < 10; i++) { + scheduler.setEnabled(true); + await clock.advance(1); + } + assert.deepEqual(calls, [10]); + for (let i = 0; i < 10; i++) { + scheduler.setEnabled(true); + await clock.advance(10); + } + assert.deepEqual(calls, [10, 110]); +}); + +test('busy across several due windows recovers automatically after work finishes', async t => { + let busy = true; + const { clock, calls, scheduler } = setup(t, { isBusy: () => busy }); + scheduler.setEnabled(true); + await clock.advance(225); + assert.equal(calls.length, 0); + assert.equal(scheduler.getStatus().phase, 'busy'); + assert.ok(scheduler.getStatus().nextAt > clock.now()); + busy = false; + await clock.advance(5); + assert.equal(calls.length, 1); + await clock.advance(100); + assert.equal(calls.length, 2); +}); + +test('manual refresh completion can request an early round without toggling', async t => { + let busy = false; + const { clock, calls, scheduler } = setup(t, { isBusy: () => busy }); + scheduler.setEnabled(true); + await clock.advance(10); + busy = true; + await clock.advance(20); + busy = false; + scheduler.request(); + await clock.advance(10); + assert.equal(calls.length, 2, 'request must not wait for the normal periodic deadline'); +}); + +test('requests during a round coalesce and never overlap the running promise', async t => { + const first = deferred(); + let calls = 0; + let active = 0; + let maximum = 0; + const { clock, scheduler } = setup(t, { + runRound: async () => { + calls++; + active++; + maximum = Math.max(maximum, active); + if (calls === 1) await first.promise; + active--; + }, + }); + scheduler.setEnabled(true); + await clock.advance(10); + assert.equal(scheduler.getStatus().phase, 'running'); + assert.equal(scheduler.getStatus().running, true); + for (let i = 0; i < 20; i++) { scheduler.request(); scheduler.setEnabled(true); } + await clock.advance(300); + assert.equal(calls, 1); + first.resolve(); + await clock.flush(); + await clock.advance(10); + assert.equal(calls, 2); + assert.equal(maximum, 1); + await clock.advance(89); + assert.equal(calls, 2, 'coalesced requests must not leave additional immediate rounds'); +}); + +test('periodic delay begins after completion of a slow round', async t => { + const first = deferred(); + let calls = 0; + const { clock, scheduler } = setup(t, { runRound: () => ++calls === 1 ? first.promise : Promise.resolve() }); + scheduler.setEnabled(true); + await clock.advance(310); + assert.equal(calls, 1); + first.resolve(); + await clock.flush(); + await clock.advance(99); + assert.equal(calls, 1); + await clock.advance(1); + assert.equal(calls, 2); +}); + +test('disable cancels a pending round and requests cannot enable it', async t => { + const { clock, calls, scheduler } = setup(t); + scheduler.setEnabled(true); + await clock.advance(9); + scheduler.setEnabled(false); + scheduler.request(); + await clock.advance(500); + assert.equal(calls.length, 0); + assert.deepEqual(scheduler.getStatus(), { enabled: false, running: false, phase: 'disabled', nextAt: null }); + assert.equal(clock.pending(), 0); + scheduler.setEnabled(true); + await clock.advance(10); + assert.equal(calls.length, 1); +}); + +test('disable during a round lets it finish but cancels queued requests', async t => { + const first = deferred(); + let calls = 0; + const { clock, scheduler } = setup(t, { runRound: () => { calls++; return first.promise; } }); + scheduler.setEnabled(true); + await clock.advance(10); + scheduler.request(); + scheduler.setEnabled(false); + first.resolve(); + await clock.flush(); + await clock.advance(500); + assert.equal(calls, 1); + assert.equal(scheduler.getStatus().running, false); + assert.equal(clock.pending(), 0); + scheduler.setEnabled(true); + await clock.advance(10); + assert.equal(calls, 2); +}); + +test('disable and re-enable while running preserves serial execution and resumes', async t => { + const first = deferred(); + let calls = 0; + const { clock, scheduler } = setup(t, { runRound: () => ++calls === 1 ? first.promise : Promise.resolve() }); + scheduler.setEnabled(true); + await clock.advance(10); + scheduler.setEnabled(false); + scheduler.setEnabled(true); + await clock.advance(200); + assert.equal(calls, 1); + first.resolve(); + await clock.flush(); + await clock.advance(10); + assert.equal(calls, 2); +}); + +test('a rejected round reports its error, releases the lock and retries next interval', async t => { + const failure = new Error('synthetic request failure'); + let calls = 0; + const { clock, errors, scheduler } = setup(t, { + runRound: async () => { if (++calls === 1) throw failure; }, + }); + scheduler.setEnabled(true); + await clock.advance(10); + assert.deepEqual(errors, [failure]); + assert.equal(scheduler.getStatus().running, false); + await clock.advance(100); + assert.equal(calls, 2); +}); + +test('dispose clears pending work and late completion cannot restart timers', async t => { + const first = deferred(); + let calls = 0; + const { clock, scheduler } = setup(t, { runRound: () => { calls++; return first.promise; } }); + scheduler.setEnabled(true); + await clock.advance(10); + scheduler.request(); + scheduler.dispose(); + first.resolve(); + await clock.flush(); + await clock.advance(500); + assert.equal(calls, 1); + assert.equal(clock.pending(), 0); +}); diff --git a/scripts/test-auto-controls.mjs b/scripts/test-auto-controls.mjs new file mode 100644 index 0000000..5038afe --- /dev/null +++ b/scripts/test-auto-controls.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const main = readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); +const predicate = main.match(/isBusy: \(\) => ([^\r\n]+),/)[1]; +const start = main.indexOf(' async toggleAutoClaim() {'); +const end = main.indexOf('\n};', start); +assert.ok(start >= 0 && end > start); +const method = main.slice(start, end); + +test('production scheduler blocks every operation lock, including switch and toggle', () => { + for (const lock of ['busy', 'autoToggleBusy', 'claimActive', 'claimAllRunning', 'autoClaimRunning', 'refreshClaim']) { + const state = { busy: false, autoToggleBusy: false, claimActive: false, claimAllRunning: false, autoClaimRunning: false, refreshClaim: { running: false } }; + if (lock === 'refreshClaim') state.refreshClaim.running = true; + else state[lock] = true; + assert.equal(vm.runInNewContext(predicate, state), true, lock); + } +}); + +function fixture() { + let resolve, reject; + const saved = new Promise((ok, fail) => { resolve = ok; reject = fail; }); + const enabled = []; + const context = { + state: { auto_claim: true }, autoToggleBusy: false, autoAbortRequested: false, + autoClaimRunning: false, claimActive: false, claimAllRunning: false, refreshClaim: { running: false }, + autoClaimScheduler: { setEnabled: value => enabled.push(value), request() {} }, + invoke: () => saved, refresh: async () => { context.state.auto_claim = false; }, + render() {}, toast() {}, t: x => x, stripErr: String, lastAutoRound: null, + }; + const actions = vm.runInNewContext(`({${method}})`, context); + return { actions, context, enabled, resolve, reject }; +} + +test('closing auto claim stops scheduling before its persistence RPC resolves', async () => { + const f = fixture(); + const pending = f.actions.toggleAutoClaim(); + assert.deepEqual(f.enabled, [false]); + assert.equal(f.context.autoAbortRequested, true); + assert.equal(f.context.autoToggleBusy, true); + f.resolve(); + await pending; + assert.equal(f.context.state.auto_claim, false); + assert.equal(f.context.autoToggleBusy, false); +}); + +test('a failed close restores the known enabled state and releases its lock', async () => { + const f = fixture(); + const pending = f.actions.toggleAutoClaim(); + f.reject(new Error('synthetic persistence failure')); + await pending; + assert.deepEqual(f.enabled, [false, true]); + assert.equal(f.context.autoToggleBusy, false); +}); diff --git a/scripts/test-captcha-claim.mjs b/scripts/test-captcha-claim.mjs new file mode 100644 index 0000000..fc627a0 --- /dev/null +++ b/scripts/test-captcha-claim.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import { readFileSync } from 'node:fs'; + +// Run the shipped renderer with a fake SDK and bridge. No browser, network, +// account state, or real timers are involved in these lifecycle checks. +const source = readFileSync(new URL('../src/captcha.js', import.meta.url), 'utf8') + .replace(/^import .*;\r?\n/gm, ''); + +async function fixture({ auto = false, instance = true } = {}) { + const calls = []; + const events = []; + const timers = new Map(); + const elements = new Map(); + let clock = 0; + let nextTimer = 0; + let callbacks; + const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; + const element = id => { + if (!elements.has(id)) elements.set(id, { hidden: true, addEventListener() {}, focus() {} }); + return elements.get(id); + }; + const context = vm.createContext({ + URLSearchParams, + location: { search: `?claimId=claim-under-test&auto=${auto ? 1 : 0}` }, + document: { + getElementById: element, + querySelector: element, + addEventListener() {}, + }, + window: { + initAliyunCaptcha(options) { + callbacks = options; + if (instance) options.getInstance({ startTracelessVerification() {} }); + }, + }, + init() {}, + t: key => key, + lang: () => 'en', + stripErr: error => String(error), + setTimeout(fn, delay) { + const id = ++nextTimer; + timers.set(id, { fn, at: clock + delay }); + return id; + }, + clearTimeout: id => timers.delete(id), + invoke: async (command, args) => { + calls.push({ command, args }); + if (command === 'get_state') return { language: 'en' }; + if (command === 'claim_captcha_config') return { enabled: true, scene_id: 'synthetic-scene', region: 'cn' }; + if (command === 'claim_captcha_submit') return; + throw new Error(`Unexpected invoke: ${command}`); + }, + emit: async (name, value) => { events.push({ name, value }); }, + }); + vm.runInContext(source, context, { filename: 'src/captcha.js' }); + await flush(); + assert.ok(callbacks, 'renderer must initialize the fake SDK'); + return { + callbacks, events, flush, + submits: () => calls.filter(call => call.command === 'claim_captcha_submit'), + async advance(ms) { + clock += ms; + for (const [id, timer] of [...timers]) { + if (timer.at <= clock && timers.has(id)) { timers.delete(id); timer.fn(); } + } + await flush(); + }, + }; +} + +test('automatic interactive result terminates this attempt before a late SDK success', async () => { + const f = await fixture({ auto: true }); + f.callbacks.fail('requires interaction'); + f.callbacks.success('late-synthetic-token'); + await f.flush(); + assert.equal(f.events.filter(event => event.name === 'captcha://interactive').length, 1); + assert.equal(f.events.find(event => event.name === 'captcha://interactive').value.claimId, 'claim-under-test'); + assert.equal(f.submits().length, 0); +}); + +test('automatic traceless timeout also rejects a subsequent success callback', async () => { + const f = await fixture({ auto: true }); + await f.advance(8000); + f.callbacks.success({ captchaVerifyParam: 'late-synthetic-token' }); + await f.flush(); + assert.ok(f.events.some(event => event.name === 'captcha://interactive')); + assert.equal(f.submits().length, 0); +}); + +test('manual interactive challenge can complete successfully with the same request ID', async () => { + const f = await fixture(); + f.callbacks.fail('requires interaction'); + f.callbacks.success({ captchaVerifyParam: 'manual-synthetic-token' }); + await f.flush(); + assert.equal(f.submits().length, 1); + assert.equal(f.submits()[0].args.param, 'manual-synthetic-token'); + assert.equal(f.submits()[0].args.claimId, 'claim-under-test'); +}); + +test('SDK setup failure rejects late success for either automatic or manual claims', async () => { + for (const auto of [false, true]) { + const f = await fixture({ auto }); + f.callbacks.onError(); + f.callbacks.success('late-synthetic-token'); + await f.flush(); + assert.ok(f.events.some(event => event.name === 'captcha://failed')); + assert.equal(f.submits().length, 0); + } +}); + +test('SDK initialization timeout rejects late getInstance and success', async () => { + const f = await fixture({ instance: false }); + await f.advance(12000); + let started = false; + f.callbacks.getInstance({ startTracelessVerification() { started = true; } }); + f.callbacks.success('late-synthetic-token'); + await f.flush(); + assert.equal(started, false); + assert.equal(f.submits().length, 0); +}); + +test('repeated success callbacks submit exactly once in both modes', async () => { + for (const auto of [false, true]) { + const f = await fixture({ auto }); + f.callbacks.success('first-synthetic-token'); + f.callbacks.success({ captchaVerifyParam: 'second-synthetic-token' }); + await f.flush(); + assert.equal(f.submits().length, 1); + assert.equal(f.submits()[0].args.param, 'first-synthetic-token'); + } +}); diff --git a/scripts/test-claim-result.mjs b/scripts/test-claim-result.mjs new file mode 100644 index 0000000..378f2e8 --- /dev/null +++ b/scripts/test-claim-result.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { createClaimResultWaiter } from '../src/claim-result.js'; + +function fakeClock() { + let now = 0; + let id = 0; + const timers = new Map(); + return { + setTimer(fn, ms) { const handle = ++id; timers.set(handle, { at: now + ms, fn }); return handle; }, + clearTimer: handle => timers.delete(handle), + advance(ms) { + now += ms; + for (const [handle, timer] of [...timers]) { + if (timer.at <= now) { timers.delete(handle); timer.fn(); } + } + }, + pending: () => timers.size, + }; +} + +function fixture(claimId = 'claim-a', clock = fakeClock()) { + const waiter = createClaimResultWaiter({ accountId: 'account-a', claimId, timeoutMs: 50, ...clock }); + return { waiter, clock }; +} + +const payload = (claimId = 'claim-a', accountId = 'account-a') => ({ accountId, claimId, ok: true }); + +test('a result received immediately is retained until its promise is awaited', async () => { + const { waiter, clock } = fixture(); + const result = payload(); + assert.equal(waiter.accept(result), true); + assert.equal(await waiter.promise, result); + assert.equal(clock.pending(), 0); +}); + +test('another claim for the same account cannot consume the receipt', async () => { + const { waiter, clock } = fixture(); + assert.equal(waiter.accept(payload('older-claim')), false); + assert.equal(clock.pending(), 1); + const result = payload(); + assert.equal(waiter.accept(result), true); + assert.equal(await waiter.promise, result); +}); + +test('wrong account and malformed payloads are ignored', async () => { + const { waiter, clock } = fixture(); + for (const value of [payload('claim-a', 'account-b'), null, undefined, {}, { accountId: 'account-a' }]) { + assert.equal(waiter.accept(value), false); + } + assert.equal(clock.pending(), 1); + waiter.finish(null); + assert.equal(await waiter.promise, null); +}); + +test('duplicate results and a later finish cannot change the first outcome', async () => { + const { waiter } = fixture(); + const first = payload(); + assert.equal(waiter.accept(first), true); + assert.equal(waiter.accept({ ...payload(), ok: false }), false); + assert.equal(waiter.finish(null), false); + assert.equal(await waiter.promise, first); +}); + +test('timeout returns null and late results cannot resolve the next account receipt', async () => { + const { waiter: first, clock } = fixture(); + clock.advance(49); + assert.equal(clock.pending(), 1); + clock.advance(1); + assert.equal(await first.promise, null); + const { waiter: next } = fixture('claim-b', clock); + assert.equal(first.accept(payload()), false); + assert.equal(next.accept(payload()), false); + assert.equal(clock.pending(), 1); + const result = payload('claim-b'); + next.accept(result); + assert.equal(await next.promise, result); + assert.equal(clock.pending(), 0); +}); + +test('setup failure or cancellation can finish and release the timer exactly once', async () => { + const { waiter, clock } = fixture(); + assert.equal(waiter.finish(null), true); + assert.equal(waiter.finish(null), false); + assert.equal(clock.pending(), 0); + clock.advance(500); + assert.equal(await waiter.promise, null); +}); + +// Execute the actual production caller, without loading DOM/Tauri dependencies. +// The bounded extraction intentionally fails if this code moves, instead of +// silently testing a duplicated implementation of the request ordering. +const main = readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); +const start = main.indexOf('let claimWaiter = null;'); +const end = main.indexOf('async function awaitClaimPreviewFresh', start); +assert.ok(start >= 0 && end > start, 'locate production claim waiter and performClaim'); +const productionCaller = main.slice(start, end); + +function callerFixture(invokeImpl) { + const clock = fakeClock(); + const calls = []; + let sequence = 0; + let api; + const context = vm.createContext({ + crypto: { randomUUID: () => `request-${++sequence}` }, + createClaimResultWaiter: options => createClaimResultWaiter({ ...options, ...clock }), + invoke: (command, args) => { calls.push({ command, args }); return invokeImpl(command, args, api); }, + }); + api = vm.runInContext(`${productionCaller}\n({ performClaim, accept: p => claimWaiter?.accept(p), current: () => claimWaiter })`, context); + return { api, clock, calls }; +} + +test('production performClaim registers receipt before invoking a synchronously completing claim', async () => { + let releaseStart; + const startDone = new Promise(resolve => { releaseStart = resolve; }); + let delivered; + const { api, clock, calls } = callerFixture((command, args, caller) => { + assert.equal(command, 'claim_start'); + delivered = { accountId: args.id, claimId: args.claimId, ok: true }; + assert.equal(caller.accept(delivered), true); + return startDone; + }); + const outcome = api.performClaim('account-a', { plan_id: 'plan-a' }, true, 50); + assert.equal(clock.pending(), 0); + releaseStart(); + assert.equal(await outcome, delivered); + assert.equal(api.current(), null); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.planId, 'plan-a'); + assert.equal(calls[0].args.auto, true); +}); + +test('production invoke setup failure releases the waiter and propagates its error', async () => { + const failure = new Error('synthetic setup failure'); + const { api, clock } = callerFixture(() => { throw failure; }); + await assert.rejects(api.performClaim('account-a', { plan_id: 'plan-a' }), error => error === failure); + assert.equal(api.current(), null); + assert.equal(clock.pending(), 0); +}); + +test('production timeout cancels only its request ID and rejects stale receipts for the next claim', async () => { + const { api, clock, calls } = callerFixture(() => Promise.resolve()); + const first = api.performClaim('account-a', { plan_id: 'plan-a' }, true, 50); + await Promise.resolve(); + clock.advance(50); + assert.equal(await first, null); + assert.equal(api.current(), null); + const cancel = calls.find(call => call.command === 'claim_cancel'); + assert.ok(cancel); + assert.equal(cancel.args.claimId, 'request-1'); + const second = api.performClaim('account-a', { plan_id: 'plan-a' }, true, 50); + assert.equal(api.accept(payload('request-1')), false); + const result = payload('request-2'); + assert.equal(api.accept(result), true); + assert.equal(await second, result); + assert.equal(clock.pending(), 0); +}); diff --git a/scripts/test-login-session.mjs b/scripts/test-login-session.mjs new file mode 100644 index 0000000..dc3fc64 --- /dev/null +++ b/scripts/test-login-session.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +// Runs the actual CLI against synthetic state. Sandbox mode disables ZCode +// process control and upstream probes; no user account data is used. +const exe = resolve(process.argv[2] ?? 'src-tauri/target/debug/zcode-switch.exe'); +const root = mkdtempSync(join(tmpdir(), 'zsw-login-test-')); +const failures = []; +let passed = 0; +function run(name, fn) { + try { fn(); passed++; console.log(`PASS ${name}`); } + catch (error) { failures.push(name); console.error(`FAIL ${name}: ${error.message}`); } +} +function fixture(name) { + const home = join(root, name); + const live = join(home, '.zcode', 'v2'); + const accounts = join(home, '.zcode-switch', 'accounts'); + const arms = join(home, 'arms-store-sandbox', 'ZGVmYXVsdA.json'); + mkdirSync(live, { recursive: true }); + mkdirSync(accounts, { recursive: true }); + mkdirSync(join(home, 'arms-store-sandbox'), { recursive: true }); + const telemetry = join(live, 'telemetry-state.json'); + writeFileSync(telemetry, '{ "deviceMid": "native-install-mid", "lastDailyActiveDate": "2026-10-01" }\n'); + writeFileSync(arms, '{ "_arms_uid": "native-rum-uid", "_arms_session": {"id":"native-session"}, "other": true }\n'); + writeFileSync(join(live, 'provider_config.json'), '{}'); + writeFileSync(join(live, 'setting.json'), '{"providerFamilyDomain":"zai","other":true}'); + const native = { + zcodejwttoken: 'synthetic-login-token', + 'oauth:active_provider': 'zai', + 'oauth:zai:access_token': 'synthetic-access-token', + 'oauth:zai:user_info': JSON.stringify({ id: 'account-a', username: 'A', displayName: 'A' }), + 'opaque:future-session-field': 'preserve-this-field', + }; + const credentials = join(live, 'credentials.json'); + writeFileSync(credentials, JSON.stringify(native)); + const invoke = (...args) => { + const result = spawnSync(exe, ['--cli', '--lang', 'en', ...args], { + env: { ...process.env, ZCODE_SWITCH_HOME: home, ZCODE_SWITCH_DATA_ROOT: home }, + encoding: 'utf8', timeout: 60000, windowsHide: true, + }); + assert.ifError(result.error); + const output = JSON.parse(result.stdout.trim()); + return { ...output, exitCode: result.status }; + }; + const captured = invoke('capture', '--name', 'A'); + assert.equal(captured.ok, true); + const accountPath = join(accounts, `${captured.id}.json`); + // Legacy claim context must still load, and must never overwrite native IDs. + const account = JSON.parse(readFileSync(accountPath)); + assert.equal(account.virtual_device_mid, 'native-install-mid', 'capture did not retain its actual native login context'); + assert.equal(account.virtual_arms_uid, null, 'capture generated an unnecessary RUM identity'); + account.virtual_device_mid = 'legacy-account-mid'; + account.virtual_arms_uid = 'legacy-account-rum-uid'; + writeFileSync(accountPath, JSON.stringify(account)); + const before = [readFileSync(telemetry), readFileSync(arms)]; + return { home, live, arms, telemetry, credentials, native, invoke, id: captured.id, accountPath, before }; +} +function assertNativeState(f) { + assert.deepEqual(readFileSync(f.telemetry), f.before[0], 'native telemetry changed'); + assert.deepEqual(readFileSync(f.arms), f.before[1], 'native RUM session changed'); +} +run('forced restore preserves native device, RUM session and opaque credentials', () => { + const f = fixture('forced'); + writeFileSync(f.credentials, '{}'); + const result = f.invoke('switch', '--id', f.id, '--force', '--no-restart', '--no-hot'); + assert.equal(result.ok, true); + assert.equal(result.switched, true); + assert.deepEqual(JSON.parse(readFileSync(f.credentials)), f.native); + assertNativeState(f); +}); +run('already active account preserves native state and refreshed tokens', () => { + const f = fixture('already'); + const fresh = { ...f.native, zcodejwttoken: 'synthetic-refreshed-token' }; + writeFileSync(f.credentials, JSON.stringify(fresh)); + const result = f.invoke('switch', '--id', f.id, '--force', '--no-restart'); + assert.equal(result.already_active, true); + assert.deepEqual(JSON.parse(readFileSync(f.credentials)), fresh); + const saved = JSON.parse(readFileSync(f.accountPath)); + assert.deepEqual(saved.credentials, fresh); + assert.equal(saved.virtual_device_mid, 'legacy-account-mid'); + assert.equal(saved.virtual_arms_uid, 'legacy-account-rum-uid'); + assertNativeState(f); +}); +run('missing native telemetry is left for ZCode to initialize', () => { + const f = fixture('missing'); + unlinkSync(f.telemetry); + writeFileSync(f.credentials, '{}'); + assert.equal(f.invoke('switch', '--id', f.id, '--force', '--no-restart').ok, true); + assert.equal(existsSync(f.telemetry), false, 'switch created a replacement device identity'); + assert.deepEqual(readFileSync(f.arms), f.before[1]); +}); +run('expired snapshot is rejected before changing live login or telemetry', () => { + const f = fixture('expired'); + const account = JSON.parse(readFileSync(f.accountPath)); + account.credentials.zcodejwttoken = `e30.${Buffer.from(JSON.stringify({ exp: 1 })).toString('base64url')}.test`; + writeFileSync(f.accountPath, JSON.stringify(account)); + writeFileSync(f.credentials, '{}'); + const before = readFileSync(f.credentials); + const result = f.invoke('switch', '--id', f.id, '--force', '--no-restart'); + assert.equal(result.ok, false); + assert.equal(result.exitCode, 1); + assert.match(result.error, /expired/i); + assert.deepEqual(readFileSync(f.credentials), before); + assertNativeState(f); +}); +run('switch preserves an unsaved native account before restoring the target', () => { + const f = fixture('preserve'); + const other = { ...f.native, 'oauth:zai:user_info': JSON.stringify({ id: 'account-b' }), + zcodejwttoken: 'synthetic-other-account-token' }; + writeFileSync(f.credentials, JSON.stringify(other)); + const result = f.invoke('switch', '--id', f.id, '--force', '--no-restart'); + assert.equal(result.ok, true); + assert.equal(typeof result.preserved_as, 'string'); + const { accounts } = f.invoke('list'); + assert.equal(accounts.length, 2); + const saved = accounts.find(account => account.id !== f.id); + assert.deepEqual(JSON.parse(readFileSync(join(f.home, '.zcode-switch', 'accounts', `${saved.id}.json`))).credentials, other); + assert.deepEqual(JSON.parse(readFileSync(f.credentials)), f.native); + assertNativeState(f); +}); +run('token archival retains the saved claim context and native identity', () => { + const f = fixture('update'); + const fresh = { ...f.native, zcodejwttoken: 'synthetic-new-native-login' }; + writeFileSync(f.credentials, JSON.stringify(fresh)); + assert.equal(f.invoke('update', '--id', f.id).ok, true); + const saved = JSON.parse(readFileSync(f.accountPath)); + assert.deepEqual(saved.credentials, fresh); + assert.equal(saved.virtual_device_mid, 'legacy-account-mid'); + assertNativeState(f); +}); +console.log(`${passed}/6 passed; synthetic fixtures: ${root}`); +if (failures.length) process.exitCode = 1; diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index e521c30..3f595f3 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -175,7 +175,7 @@ pub fn run(args: &[String]) -> (String, i32) { let mut any = false; let mut items = vec![]; for acc in accounts.iter().filter(|a| only.as_deref().map_or(true, |id| id == a.id)) { - let res = ensure_virtual_device_mid(&paths, &acc.id).and_then(|mid| { + let res = account_claim_mid(&paths, acc).and_then(|mid| { crate::claim::preview_plans(&paths.home, &acc.credentials, acc.config.as_ref(), Some(mid)) }); match res { diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs index 9870728..bc3de37 100644 --- a/src-tauri/src/i18n.rs +++ b/src-tauri/src/i18n.rs @@ -85,6 +85,8 @@ const ZH: &[(&str, &str)] = &[ ("err.live.logged_out", "当前未登录"), ("err.live.same", "当前登录与「{name}」一致,请直接切换"), ("err.live.quota", "当前未登录,无法查询额度"), + ("err.switch.jwt_expired", "保存的登录已过期,请先在 ZCode 中正常登录并重新保存账号"), + ("err.device.native_missing", "尚无 ZCode 本机设备状态,请先启动 ZCode 并正常登录一次"), ("err.switch.running", "ZCode 正在运行,请先完全退出(含托盘),或使用强制切换(自动关闭并重启)"), ("err.switch.kill_timeout", "关闭 ZCode 超时,已取消切换(避免登录态损坏)"), ("err.write_config", "写入 config 失败:{e}"), @@ -189,6 +191,7 @@ const ZH: &[(&str, &str)] = &[ ("err.claim.config_unavailable", "验证码配置不可用"), ("err.claim.gone", "该套餐已不可领取,请刷新"), ("err.claim.none_pending", "没有待领取的套餐"), + ("err.claim.busy", "正在领取中,请等待当前安全验证完成"), ("err.claim.activate_req", "激活上报失败:{e}"), ("claim.fail.1001", "套餐不存在"), ("claim.fail.1002", "活动已结束或套餐暂不可领取"), @@ -242,6 +245,8 @@ const EN: &[(&str, &str)] = &[ ("err.live.logged_out", "Not logged in"), ("err.live.same", "Current login already matches \"{name}\" — just switch to it"), ("err.live.quota", "Not logged in — cannot query quota"), + ("err.switch.jwt_expired", "Saved login has expired. Sign in normally in ZCode and save the account again"), + ("err.device.native_missing", "Native ZCode device state is missing. Start ZCode and sign in normally first"), ("err.switch.running", "ZCode is running. Quit it fully (including the tray icon), or use Force switch (auto close & restart)"), ("err.switch.kill_timeout", "Timed out closing ZCode — switch cancelled to protect the login state"), ("err.write_config", "Failed to write config: {e}"), @@ -346,6 +351,7 @@ const EN: &[(&str, &str)] = &[ ("err.claim.config_unavailable", "Captcha config unavailable"), ("err.claim.gone", "This plan is no longer claimable — refresh the list"), ("err.claim.none_pending", "No pending plan to claim"), + ("err.claim.busy", "A claim is in progress. Wait for the current verification to finish"), ("err.claim.activate_req", "Activation report failed: {e}"), ("claim.fail.1001", "Plan does not exist"), ("claim.fail.1002", "The event has ended or the plan is not claimable yet"), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7b0fb5..f990f7b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ const TRAY_ID: &str = "main"; static STORE_LOCK: Mutex<()> = Mutex::new(()); struct PendingClaim { + claim_id: String, account_id: String, account_name: String, plan_id: String, @@ -216,8 +217,8 @@ async fn get_account_quota(id: String) -> Result { #[tauri::command] async fn claim_preview(id: String) -> Result, String> { let paths = Paths::detect(); - let mid = store::ensure_virtual_device_mid(&paths, &id)?; let acc = load_account(&paths, &id)?; + let mid = store::account_claim_mid(&paths, &acc)?; claim::preview_plans(&paths.home, &acc.credentials, acc.config.as_ref(), Some(mid)) } @@ -232,8 +233,8 @@ struct ClaimRefreshResult { #[tauri::command] async fn claim_refresh(id: String) -> Result { let paths = Paths::detect(); - let mid = store::ensure_virtual_device_mid(&paths, &id)?; let acc = load_account(&paths, &id)?; + let mid = store::account_claim_mid(&paths, &acc)?; let (activated, activation_error) = match claim::telemetry_user_id(&paths.home, &acc.credentials) { Some(uid) => match claim::report_activation_events(&uid, &mid) { @@ -251,11 +252,18 @@ async fn claim_start( app: AppHandle, id: String, plan_id: String, + claim_id: String, auto: Option, ) -> Result { + if uuid::Uuid::parse_str(&claim_id).is_err() { + return Err(i18n::tr("err.claim.none_pending")); + } + if pending_guard().is_some() { + return Err(i18n::tr("err.claim.busy")); + } let paths = Paths::detect(); - let mid = store::ensure_virtual_device_mid(&paths, &id)?; let acc = load_account(&paths, &id)?; + let mid = store::account_claim_mid(&paths, &acc)?; let plans = claim::preview_plans(&paths.home, &acc.credentials, acc.config.as_ref(), Some(mid.clone()))?; let plan = plans .iter() @@ -263,7 +271,8 @@ async fn claim_start( .ok_or_else(|| i18n::tr("err.claim.gone"))?; let display = if plan.name.is_empty() { plan.plan_id.clone() } else { plan.name.clone() }; - *pending_guard() = Some(PendingClaim { + let pending = PendingClaim { + claim_id: claim_id.clone(), account_id: acc.id.clone(), account_name: acc.name.clone(), plan_id: plan.plan_id.clone(), @@ -271,8 +280,18 @@ async fn claim_start( credentials: acc.credentials, config: acc.config, device_mid: mid, - }); - open_captcha_window(&app, auto.unwrap_or(false))?; + }; + { + let mut guard = pending_guard(); + if guard.is_some() { + return Err(i18n::tr("err.claim.busy")); + } + *guard = Some(pending); + } + if let Err(e) = open_captcha_window(&app, auto.unwrap_or(false), &claim_id) { + take_pending_claim(&claim_id); + return Err(e); + } Ok(json!({ "account": acc.name, "plan": display })) } @@ -286,8 +305,9 @@ async fn claim_captcha_submit( app: AppHandle, param: String, region: Option, + claim_id: String, ) -> Result { - let pending = pending_guard().take().ok_or_else(|| i18n::tr("err.claim.none_pending"))?; + let pending = take_pending_claim(&claim_id).ok_or_else(|| i18n::tr("err.claim.none_pending"))?; let paths = Paths::detect(); let res = claim::submit_claim( &paths.home, @@ -298,7 +318,7 @@ async fn claim_captcha_submit( region.as_deref(), Some(pending.device_mid), ); - close_captcha_window(&app); + close_captcha_window(&app, &pending.claim_id); let payload = match res { Ok(v) => { let ms = |k: &str| -> Option { @@ -318,17 +338,19 @@ async fn claim_captcha_submit( ends_at: ms("ends_at"), server_time, }; - let p = serde_json::to_value(&outcome).unwrap_or(Value::Null); + let mut p = serde_json::to_value(&outcome).unwrap_or(Value::Null); + p["claimId"] = json!(pending.claim_id); let _ = app.emit("claim://result", &p); p } Err(e) => { - let p = claim::failure_payload( + let mut p = claim::failure_payload( &pending.account_id, &pending.account_name, &pending.plan_name, &e, ); + p["claimId"] = json!(pending.claim_id); let _ = app.emit("claim://result", &p); return Ok(p); } @@ -337,15 +359,36 @@ async fn claim_captcha_submit( } #[tauri::command] -async fn claim_cancel(app: AppHandle) -> Result<(), String> { - *pending_guard() = None; - close_captcha_window(&app); +async fn claim_cancel(app: AppHandle, claim_id: String) -> Result<(), String> { + take_pending_claim(&claim_id); + close_captcha_window(&app, &claim_id); Ok(()) } -fn close_captcha_window(app: &AppHandle) { +fn take_pending_claim(claim_id: &str) -> Option { + let mut pending = pending_guard(); + take_matching_pending(&mut pending, claim_id) +} + +fn take_matching_pending( + pending: &mut Option, + claim_id: &str, +) -> Option { + if pending.as_ref().map(|p| p.claim_id.as_str()) == Some(claim_id) { + pending.take() + } else { + None + } +} + +fn close_captcha_window(app: &AppHandle, claim_id: &str) { if let Some(w) = app.get_webview_window("captcha") { - let _ = w.close(); + if w.url().ok().is_some_and(|url| { + url.query_pairs() + .any(|(key, value)| key == "claimId" && value == claim_id) + }) { + let _ = w.close(); + } } } @@ -395,7 +438,7 @@ async fn oauth_begin(app: AppHandle, provider: String) -> Result Result<(), String> { +fn open_captcha_window(app: &AppHandle, auto: bool, claim_id: &str) -> Result<(), String> { let (w, h) = (380.0, 320.0); + let auto_flag = if auto { "1" } else { "0" }; + let url = format!("captcha.html?claimId={claim_id}&auto={auto_flag}"); if let Some(win) = app.get_webview_window("captcha") { - let _ = win.eval("location.reload()"); + win.eval(&format!("location.href = {}", json!(url))) + .map_err(|e| e.to_string())?; center_over_main(app, &win, w, h); if auto { let _ = win.hide(); @@ -809,7 +853,7 @@ fn open_captcha_window(app: &AppHandle, auto: bool) -> Result<(), String> { let win = tauri::WebviewWindowBuilder::new( app, "captcha", - tauri::WebviewUrl::App("captcha.html".into()), + tauri::WebviewUrl::App(url.into()), ) .title(i18n::tr("title.captcha")) .theme(Some(tauri::Theme::Dark)) @@ -1181,7 +1225,25 @@ pub fn run() { } } if window.label() == "captcha" { - *pending_guard() = None; + let claim_id = window + .get_webview_window("captcha") + .and_then(|w| w.url().ok()) + .and_then(|url| { + url.query_pairs() + .find(|(key, _)| key == "claimId") + .map(|(_, value)| value.into_owned()) + }); + if let Some(claim_id) = claim_id { + if let Some(pending) = take_pending_claim(&claim_id) { + let _ = window.emit( + "claim://cancelled", + json!({ + "claimId": claim_id, + "accountId": pending.account_id, + }), + ); + } + } } } }) @@ -1215,3 +1277,42 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod pending_claim_tests { + use super::{take_matching_pending, PendingClaim}; + use serde_json::Value; + + fn pending(claim_id: &str) -> Option { + Some(PendingClaim { + claim_id: claim_id.into(), + account_id: "account".into(), + account_name: "Account".into(), + plan_id: "plan".into(), + plan_name: "Plan".into(), + credentials: Value::Null, + config: None, + device_mid: "device".into(), + }) + } + + #[test] + fn mismatched_claim_id_cannot_consume_pending_claim() { + let mut slot = pending("current-claim"); + + assert!(take_matching_pending(&mut slot, "stale-claim").is_none()); + assert_eq!(slot.as_ref().map(|p| p.claim_id.as_str()), Some("current-claim")); + } + + #[test] + fn matching_claim_id_consumes_pending_claim_once() { + let mut slot = pending("current-claim"); + + let taken = take_matching_pending(&mut slot, "current-claim"); + assert_eq!( + taken.map(|p| p.claim_id), + Some(String::from("current-claim")) + ); + assert!(take_matching_pending(&mut slot, "current-claim").is_none()); + } +} diff --git a/src-tauri/src/quota.rs b/src-tauri/src/quota.rs index 7f05dae..cbb40c2 100644 --- a/src-tauri/src/quota.rs +++ b/src-tauri/src/quota.rs @@ -53,24 +53,7 @@ pub(crate) const ZCODE_LANG: &str = "zh-CN"; const ZCODE_CHANNEL: &str = "stable"; pub(crate) fn device_mid() -> Option { - static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - CACHE - .get_or_init(|| { - let home = crate::store::pick_home( - std::env::var("ZCODE_SWITCH_HOME").ok().map(std::path::PathBuf::from), - std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from), - std::env::var("HOME").ok().map(std::path::PathBuf::from), - ); - let p = crate::store::resolve_data_root(&home) - .join(".zcode") - .join("v2") - .join("telemetry-state.json"); - std::fs::read_to_string(p) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v.get("deviceMid").and_then(|m| m.as_str()).map(String::from)) - }) - .clone() + crate::store::read_device_mid(&crate::store::Paths::detect()) } #[cfg(windows)] diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 108842f..5f3cdcf 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -121,6 +121,7 @@ pub struct Account { pub credentials: Value, #[serde(default)] pub config: Option, + // Saved login context is used for claims, never written into the native client. #[serde(default)] pub virtual_device_mid: Option, #[serde(default)] @@ -587,7 +588,7 @@ pub fn capture_current(paths: &Paths, name: Option) -> Result) -> Result Resu } let name = unique_name(accounts, &format!("Auto {}", Local::now().format("%m-%d %H%M"))); let ts = now_ts(); - let mut acc = Account { + let acc = Account { id: Uuid::new_v4().to_string(), name: name.clone(), created_at: ts.clone(), @@ -638,11 +638,10 @@ fn auto_preserve(paths: &Paths, accounts: &[Account], target_hash: &str) -> Resu hash, credentials: live, config: snapshot_config_from_live(paths), - virtual_device_mid: None, + virtual_device_mid: read_device_mid(paths), virtual_arms_uid: None, }; - adopt_virtual_device_mid(paths, &mut acc)?; - adopt_virtual_arms_uid(paths, &mut acc)?; + save_account(paths, &acc)?; Ok(Some(name)) } @@ -835,12 +834,6 @@ pub fn switch_to(paths: &Paths, id: &str, force: bool, restart: bool, hot: bool) eprintln!("sync-back 失败(不阻断 already 返回): {e}"); } } - let mid = ensure_virtual_device_mid_locked(paths, &target.id)?; - write_live_device_mid(paths, &mid)?; - let uid = ensure_virtual_arms_uid_locked(paths, &target.id)?; - if !zcode_running() { - let _ = write_live_arms_uid(paths, &uid); - } return Ok(SwitchResult { switched: false, already_active: true, @@ -854,6 +847,11 @@ pub fn switch_to(paths: &Paths, id: &str, force: bool, restart: bool, hot: bool) }); } + // Match native cached-session restoration before closing the client or writing. + if account_jwt_expired(&target.credentials, &paths.home) { + return Err(tr("err.switch.jwt_expired")); + } + let running = zcode_running(); if hot && running { if let Err(e) = sync_live_back_to_source(paths, &accounts) { @@ -865,10 +863,7 @@ pub fn switch_to(paths: &Paths, id: &str, force: bool, restart: bool, hot: bool) hot_swap_verified(paths, &target, &creds)?; backfill_relay_pass_hash(paths, &target.id, &creds); reset_live_plan_cache(paths); - let mid = ensure_virtual_device_mid_locked(paths, &target.id)?; - write_live_device_mid(paths, &mid)?; - ensure_virtual_arms_uid_locked(paths, &target.id)?; - let probe = run_switch_probe(paths, &target, Some(mid)); + let probe = run_switch_probe(paths, &target, read_device_mid(paths)); return Ok(SwitchResult { switched: true, already_active: false, @@ -920,10 +915,6 @@ pub fn switch_to(paths: &Paths, id: &str, force: bool, restart: bool, hot: bool) if !new_gen { rematerialize_wiped_builtins(paths, &target); } - let mid = ensure_virtual_device_mid_locked(paths, &target.id)?; - write_live_device_mid(paths, &mid)?; - let uid = ensure_virtual_arms_uid_locked(paths, &target.id)?; - let _ = write_live_arms_uid(paths, &uid); let mut launched = false; if restart { @@ -933,7 +924,7 @@ pub fn switch_to(paths: &Paths, id: &str, force: bool, restart: bool, hot: bool) } } - let probe = run_switch_probe(paths, &target, Some(mid)); + let probe = run_switch_probe(paths, &target, read_device_mid(paths)); Ok(SwitchResult { switched: true, @@ -1118,273 +1109,120 @@ pub fn account_quota(paths: &Paths, id: &str) -> Result Result { - { - let acc = load_account(paths, id)?; - if let Some(m) = acc.virtual_device_mid.clone() { - if !m.trim().is_empty() { - return Ok(m); - } - } - } - let mut acc = load_account(paths, id)?; - if let Some(m) = acc.virtual_device_mid.clone() { - if !m.trim().is_empty() { - return Ok(m); - } - } - let m = Uuid::new_v4().to_string(); - acc.virtual_device_mid = Some(m.clone()); - acc.updated_at = now_ts(); - save_account(paths, &acc)?; - Ok(m) -} - -pub fn ensure_virtual_device_mid(paths: &Paths, id: &str) -> Result { - if let Ok(acc) = load_account(paths, id) { - if let Some(m) = acc.virtual_device_mid.clone() { - if !m.trim().is_empty() { - return Ok(m); - } - } - } - let _guard = crate::store_guard(); - ensure_virtual_device_mid_locked(paths, id) -} - -pub fn write_live_device_mid(paths: &Paths, mid: &str) -> Result<(), String> { - let mut v: Value = fs::read_to_string(paths.live_telemetry()) +/// Device identity belongs to the ZCode installation, not the saved account. +/// Never mint or write it here: the native client owns initialization and locking. +pub fn read_device_mid(paths: &Paths) -> Option { + fs::read_to_string(paths.live_telemetry()) .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| json!({})); - if !v.is_object() { - v = json!({}); - } - if v.get("deviceMid").and_then(|m| m.as_str()) == Some(mid) { - return Ok(()); - } - v["deviceMid"] = json!(mid); - atomic_write(&paths.live_telemetry(), &(serde_json::to_string(&v).unwrap_or_default() + "\n")) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|v| v.get("deviceMid").and_then(Value::as_str).map(str::to_string)) + .filter(|mid| !mid.trim().is_empty()) } -fn adopt_virtual_device_mid(paths: &Paths, acc: &mut Account) -> Result<(), String> { - if acc.virtual_device_mid.as_deref().map_or(false, |m| !m.trim().is_empty()) { - return Ok(()); - } - let live_mid: Option = fs::read_to_string(paths.live_telemetry()) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v.get("deviceMid").and_then(|m| m.as_str()).map(String::from)) - .filter(|m| !m.trim().is_empty()); - let taken = |m: &str| { - list_accounts(paths) - .map(|accs| accs.iter().any(|a| a.virtual_device_mid.as_deref() == Some(m))) - .unwrap_or(false) - }; - let mid = match live_mid { - Some(m) if !taken(&m) => m, - _ => Uuid::new_v4().to_string(), - }; - acc.virtual_device_mid = Some(mid); - acc.updated_at = now_ts(); - save_account(paths, acc) +pub fn require_device_mid(paths: &Paths) -> Result { + read_device_mid(paths).ok_or_else(|| tr("err.device.native_missing")) } -const ARMS_DEFAULT_STORE_FILE: &str = "ZGVmYXVsdA.json"; - -pub fn new_arms_uid() -> String { - const ALPHABET: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; - let uuid = Uuid::new_v4(); - let suffix: String = uuid.as_bytes() - .iter() - .take(16) - .map(|b| ALPHABET[(*b as usize) % 36] as char) - .collect(); - format!("uid_{suffix}") -} - -fn arms_store_dirs_from( - win_appdata: Option, - mac_appsupport: Option, - unix_base: Option, -) -> Vec { - let mut out: Vec = Vec::new(); - let mut push_if_new = |dir: PathBuf| { - let lowered = dir.to_string_lossy().to_lowercase(); - if !out.iter().any(|d| d.to_string_lossy().to_lowercase() == lowered) { - out.push(dir); - } - }; - let mut candidates: Vec = Vec::new(); - let groups: [(Option, &[&str]); 3] = [ - (win_appdata, &["ZCode", "zcode", "ZCode Preview", "ZCode Dev"]), - (mac_appsupport, &["ZCode", "zcode", "ZCode Preview", "ZCode Dev"]), - (unix_base, &["zcode", "ZCode"]), - ]; - for (base, names) in groups { - let Some(base) = base else { continue }; - for name in names { - candidates.push(base.join(name).join("rum-electron-store")); - } - if let Ok(rd) = fs::read_dir(&base) { - let extras: Vec = rd - .flatten() - .filter(|e| { - let n = e.file_name().to_string_lossy().to_lowercase(); - n.starts_with("zcode") && e.path().join("rum-electron-store").is_dir() - }) - .map(|e| e.path().join("rum-electron-store")) - .collect(); - candidates.extend(extras); - } - } - let existing: Vec = candidates.iter().filter(|c| c.is_dir()).cloned().collect(); - if existing.is_empty() { - candidates.truncate(1); - candidates - } else { - for c in existing { - push_if_new(c); - } - out +/// Claim eligibility must retain the context used by this saved login. Old +/// snapshots already carry it; native captures and OAuth store the actual MID. +/// Reading this context must never generate an ID or alter the native client. +pub fn account_claim_mid(paths: &Paths, account: &Account) -> Result { + match account.virtual_device_mid.as_deref().map(str::trim).filter(|mid| !mid.is_empty()) { + Some(mid) => Ok(mid.to_string()), + None => require_device_mid(paths), } } -fn arms_store_dirs_for(paths: &Paths) -> Vec { - if in_sandbox() { - return vec![paths.home.join("arms-store-sandbox")]; - } - #[cfg(windows)] - let (appdata, mac_base, unix_base) = - (std::env::var("APPDATA").ok().map(PathBuf::from), None, None); - #[cfg(target_os = "macos")] - let (appdata, mac_base, unix_base) = ( - None, - std::env::var("HOME") - .ok() - .map(|h| PathBuf::from(h).join("Library").join("Application Support")), - None, - ); - #[cfg(all(unix, not(target_os = "macos")))] - let (appdata, mac_base, unix_base) = ( - None, - None, - std::env::var("XDG_CONFIG_HOME") - .ok() - .map(PathBuf::from) - .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config"))), - ); - arms_store_dirs_from(appdata, mac_base, unix_base) -} - -fn ensure_virtual_arms_uid_locked(paths: &Paths, id: &str) -> Result { - { - let acc = load_account(paths, id)?; - if let Some(u) = acc.virtual_arms_uid.clone() { - if !u.trim().is_empty() { - return Ok(u); - } +#[cfg(test)] +mod native_device_tests { + use super::*; + + struct Fixture(Paths); + impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("zsw-native-device-{}", Uuid::new_v4())); + Self(Paths { home: root.join("home"), data_root: root.join("data") }) } } - let mut acc = load_account(paths, id)?; - if let Some(u) = acc.virtual_arms_uid.clone() { - if !u.trim().is_empty() { - return Ok(u); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(self.0.data_root.parent().unwrap()); } } - let u = new_arms_uid(); - acc.virtual_arms_uid = Some(u.clone()); - acc.updated_at = now_ts(); - save_account(paths, &acc)?; - Ok(u) -} -fn read_live_arms_uid_from(dirs: &[PathBuf]) -> Option { - let read_uid = |f: &Path| -> Option { - fs::read_to_string(f) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v.get("_arms_uid").and_then(|u| u.as_str()).map(String::from)) - .filter(|u| !u.trim().is_empty()) - }; - let mut files: Vec = Vec::new(); - for d in dirs { - if let Ok(rd) = fs::read_dir(d) { - let mut jsons: Vec = rd - .flatten() - .map(|e| e.path()) - .filter(|p| p.extension().map(|x| x == "json").unwrap_or(false)) - .collect(); - jsons.sort_by_key(|p| !p.file_name().map(|n| n == ARMS_DEFAULT_STORE_FILE).unwrap_or(false)); - files.extend(jsons); + #[test] + fn reads_current_native_identity_from_selected_data_root_without_caching() { + let fixture = Fixture::new(); + let paths = &fixture.0; + fs::create_dir_all(paths.live_telemetry().parent().unwrap()).unwrap(); + fs::write(paths.live_telemetry(), r#"{"deviceMid":"native-first"}"#).unwrap(); + assert_eq!(require_device_mid(paths).unwrap(), "native-first"); + fs::write(paths.live_telemetry(), r#"{"deviceMid":"native-current"}"#).unwrap(); + assert_eq!(require_device_mid(paths).unwrap(), "native-current"); + assert!(!paths.home.join(".zcode/v2/telemetry-state.json").exists()); + } + + #[test] + fn missing_or_invalid_native_state_is_never_replaced() { + let fixture = Fixture::new(); + let paths = &fixture.0; + assert!(require_device_mid(paths).is_err()); + assert!(!paths.live_telemetry().exists()); + fs::create_dir_all(paths.live_telemetry().parent().unwrap()).unwrap(); + for raw in ["broken-json", "{}", r#"{"deviceMid":" "}"#] { + fs::write(paths.live_telemetry(), raw).unwrap(); + assert!(require_device_mid(paths).is_err()); + assert_eq!(fs::read_to_string(paths.live_telemetry()).unwrap(), raw); } } - files.iter().find_map(|f| read_uid(f)) -} - -pub fn write_live_arms_uid_to(dirs: &[PathBuf], uid: &str) -> Result<(), String> { - for d in dirs { - let mut jsons: Vec = fs::read_dir(d) - .map(|rd| { - rd.flatten() - .map(|e| e.path()) - .filter(|p| p.extension().map(|x| x == "json").unwrap_or(false)) - .collect() - }) - .unwrap_or_default(); - if jsons.is_empty() { - fs::create_dir_all(d).map_err(|e| format!("mkdir {}: {e}", d.display()))?; - jsons.push(d.join(ARMS_DEFAULT_STORE_FILE)); - } - let mut first_err: Option = None; - for f in jsons { - let mut v: Value = fs::read_to_string(&f) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| json!({})); - if !v.is_object() { - v = json!({}); - } - if v.get("_arms_uid").and_then(|u| u.as_str()) == Some(uid) - && v.get("_arms_session").is_none() - { - continue; - } - let obj = v.as_object_mut().unwrap(); - obj.insert("_arms_uid".into(), json!(uid)); - obj.remove("_arms_session"); - if let Err(e) = atomic_write(&f, &(serde_json::to_string(&v).unwrap_or_default() + "\n")) { - first_err.get_or_insert(e); - } - } - if let Some(e) = first_err { - return Err(e); - } - } - Ok(()) -} -fn write_live_arms_uid(paths: &Paths, uid: &str) -> Result<(), String> { - write_live_arms_uid_to(&arms_store_dirs_for(paths), uid) -} - -fn adopt_virtual_arms_uid(paths: &Paths, acc: &mut Account) -> Result<(), String> { - if acc.virtual_arms_uid.as_deref().is_some_and(|u| !u.trim().is_empty()) { - return Ok(()); + #[test] + fn claim_context_preserves_saved_login_identity_without_touching_native_state() { + let fixture = Fixture::new(); + let paths = &fixture.0; + fs::create_dir_all(paths.live_telemetry().parent().unwrap()).unwrap(); + let raw = r#"{"deviceMid":"native-current","other":true}"#; + fs::write(paths.live_telemetry(), raw).unwrap(); + let mut account = Account { + id: "account-a".into(), name: "A".into(), created_at: String::new(), + updated_at: String::new(), hash: String::new(), credentials: json!({}), + config: None, virtual_device_mid: Some("saved-login-mid".into()), + virtual_arms_uid: Some("saved-rum-uid".into()), + }; + assert_eq!(account_claim_mid(paths, &account).unwrap(), "saved-login-mid"); + assert_eq!(fs::read_to_string(paths.live_telemetry()).unwrap(), raw); + assert_eq!(require_device_mid(paths).unwrap(), "native-current"); + account.virtual_device_mid = None; + assert_eq!(account_claim_mid(paths, &account).unwrap(), "native-current"); + account.virtual_device_mid = Some(" ".into()); + assert_eq!(account_claim_mid(paths, &account).unwrap(), "native-current"); + fs::remove_file(paths.live_telemetry()).unwrap(); + assert!(account_claim_mid(paths, &account).is_err()); + account.virtual_device_mid = Some("saved-login-mid".into()); + assert_eq!(account_claim_mid(paths, &account).unwrap(), "saved-login-mid"); + assert!(!paths.live_telemetry().exists()); + } + + #[test] + fn account_bundle_round_trip_preserves_credentials_and_claim_context() { + let fixture = Fixture::new(); + let paths = &fixture.0; + let account = Account { + id: "account-a".into(), name: "A".into(), created_at: String::new(), + updated_at: String::new(), hash: String::new(), + credentials: json!({"zcodejwttoken":"test-token", "opaque":"keep"}), + config: None, virtual_device_mid: Some("saved-login-mid".into()), + virtual_arms_uid: None, + }; + let bundle = export_bundle_value(&[account.clone()]); + let report = import_values(paths, &[("test.zsb".into(), bundle)]).unwrap(); + assert!(report.errors.is_empty()); + let restored = list_accounts(paths).unwrap(); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].credentials, account.credentials); + assert_eq!(account_claim_mid(paths, &restored[0]).unwrap(), "saved-login-mid"); + assert!(!paths.live_telemetry().exists()); } - let live_uid = read_live_arms_uid_from(&arms_store_dirs_for(paths)); - let taken = |u: &str| { - list_accounts(paths) - .map(|accs| accs.iter().any(|a| a.virtual_arms_uid.as_deref() == Some(u))) - .unwrap_or(false) - }; - let uid = match live_uid { - Some(u) if !taken(&u) => u, - _ => new_arms_uid(), - }; - acc.virtual_arms_uid = Some(uid); - acc.updated_at = now_ts(); - save_account(paths, acc) } pub fn export_bundle_value(accounts: &[Account]) -> Value { @@ -1397,11 +1235,12 @@ pub fn export_bundle_value(accounts: &[Account]) -> Value { "createdAt": a.created_at, "credentials": a.credentials, "config": a.config, + "claimDeviceMid": a.virtual_device_mid, })).collect::>(), }) } -type ImportCandidate = (Option, Value, Option); +type ImportCandidate = (Option, Value, Option, Option); fn import_candidates(v: &Value) -> Result, String> { if v.get("format").and_then(|f| f.as_str()) == Some("zcode-accounts-bundle") { @@ -1416,6 +1255,8 @@ fn import_candidates(v: &Value) -> Result, String> { item.get("name").and_then(|n| n.as_str()).map(String::from), creds, item.get("config").cloned(), + item.get("claimDeviceMid").and_then(Value::as_str) + .map(str::trim).filter(|mid| !mid.is_empty()).map(str::to_string), )); } return Ok(out); @@ -1437,7 +1278,7 @@ pub fn import_values(paths: &Paths, files: &[(String, Value)]) -> Result Result false, now = Date.now, + setTimer = setTimeout, clearTimer = clearTimeout, onError = () => {}, + intervalMs = 10 * 60 * 1000, startDelayMs = 1000, busyDelayMs = 5000, +}) { + let enabled = false, running = false, timer = null, due = null; + let phase = 'disabled', nextAt = null; + + function clear() { + if (timer !== null) clearTimer(timer); + timer = null; + nextAt = null; + } + function arm(delay, nextPhase = 'waiting') { + clear(); + if (!enabled) return; + phase = nextPhase; + nextAt = now() + Math.max(0, delay); + timer = setTimer(() => { timer = null; nextAt = null; void tick(); }, Math.max(0, delay)); + } + async function tick() { + if (!enabled) return; + if (running || isBusy()) { arm(busyDelayMs, 'busy'); return; } + running = true; + due = null; + phase = 'running'; + try { await runRound(); } + catch (error) { onError(error); } + finally { + running = false; + if (enabled) { + due ??= now() + intervalMs; + arm(Math.max(0, due - now())); + } else { + clear(); + phase = 'disabled'; + } + } + } + function setEnabled(value) { + value = !!value; + if (value === enabled) return; + enabled = value; + clear(); + due = value ? now() + startDelayMs : null; + if (!value) phase = 'disabled'; + else if (!running) arm(startDelayMs); + } + function request() { + if (!enabled) return; + due = Math.min(due ?? Infinity, now() + startDelayMs); + if (!running) arm(Math.max(0, due - now())); + } + return { + setEnabled, request, + getStatus: () => ({ enabled, running, phase, nextAt }), + dispose: () => setEnabled(false), + }; +} diff --git a/src/captcha.js b/src/captcha.js index e53c7d6..4b5a0b6 100644 --- a/src/captcha.js +++ b/src/captcha.js @@ -5,6 +5,10 @@ import { init, t, lang, stripErr } from "./i18n.js"; const SDK_URL = "https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"; const TRACELESS_TIMEOUT = 8000; +const SETUP_TIMEOUT = 12000; +const query = new URLSearchParams(location.search); +const claimId = query.get("claimId"); +const autoClaim = query.get("auto") === "1"; const $text = document.getElementById("cap-text"); const $detail = document.getElementById("cap-detail"); @@ -24,15 +28,40 @@ document.addEventListener("securitypolicyviolation", (e) => { detail(t("c.cspBlocked", { directive: e.violatedDirective, uri: String(e.blockedURI).slice(0, 70) })); }); -const notifyStuck = () => { emit("captcha://interactive").catch(() => {}); }; +const notifyStuck = () => { emit("captcha://interactive", { claimId }).catch(() => {}); }; + +function setupFailed(text, message = "") { + clearTimeout(tracelessTimer); + clearTimeout(initTimer); + setupStopped = true; + status(text, "err"); + detail(message); + $btn.hidden = true; + emit("captcha://failed", { + claimId, + message: message ? `${text}: ${message}` : text, + }).catch(() => {}); +} function loadSdk() { return new Promise((resolve, reject) => { if (typeof window.initAliyunCaptcha === "function") return resolve(); const s = document.createElement("script"); s.src = SDK_URL; - s.onload = () => resolve(); - s.onerror = () => reject(new Error(t("c.sdkFail"))); + const timer = setTimeout(() => { + s.remove(); + reject(new Error(t("c.sdkFail"))); + }, SETUP_TIMEOUT); + s.onload = () => { + clearTimeout(timer); + if (typeof window.initAliyunCaptcha === "function") resolve(); + else reject(new Error(t("c.sdkFail"))); + }; + s.onerror = () => { + clearTimeout(timer); + s.remove(); + reject(new Error(t("c.sdkFail"))); + }; document.head.appendChild(s); }); } @@ -40,6 +69,8 @@ function loadSdk() { let submitted = false; let region = null; let tracelessTimer = 0; +let initTimer = 0; +let setupStopped = false; async function run() { try { @@ -55,42 +86,47 @@ async function run() { try { cfg = await invoke("claim_captcha_config"); } catch (e) { - notifyStuck(); - status(t("c.cfgFail"), "err"); - detail(stripErr(e)); + setupFailed(t("c.cfgFail"), stripErr(e)); return; } if (!cfg.enabled || !cfg.scene_id) { - notifyStuck(); - status(t("c.cfgUnavailable"), "err"); - detail(t("c.cfgUnavailableDetail")); + setupFailed(t("c.cfgUnavailable"), t("c.cfgUnavailableDetail")); return; } region = cfg.region || null; + // The SDK reads this global while its script initializes. + window.AliyunCaptchaConfig = { region: cfg.region, prefix: cfg.prefix }; try { await loadSdk(); } catch (e) { - notifyStuck(); - status(e.message || t("c.sdkFail"), "err"); + setupFailed(e.message || t("c.sdkFail")); return; } - window.AliyunCaptchaConfig = { region: cfg.region, prefix: cfg.prefix }; - status(t("c.traceless")); const submit = (param) => { - if (submitted || !param || !param.trim()) return; + if (submitted || setupStopped || typeof param !== "string" || !param.trim()) return; submitted = true; clearTimeout(tracelessTimer); + clearTimeout(initTimer); status(t("c.passed")); - invoke("claim_captcha_submit", { param, region }).catch((e) => { + invoke("claim_captcha_submit", { param, region, claimId }).catch((e) => { status(t("c.claimReqFail"), "err"); detail(stripErr(e)); + emit("captcha://failed", { + claimId, + message: stripErr(e), + terminal: true, + }).catch(() => {}); }); }; const interactive = (why) => { + if (submitted || setupStopped) return; + // Auto-claim must stop synchronously before notifying the main window. + // Otherwise an SDK success callback can race the asynchronous cancellation. + if (autoClaim) setupStopped = true; notifyStuck(); clearTimeout(tracelessTimer); status(t("c.interactive")); @@ -100,6 +136,7 @@ async function run() { }; try { + initTimer = setTimeout(() => setupFailed(t("c.initFail")), SETUP_TIMEOUT); window.initAliyunCaptcha({ SceneId: cfg.scene_id, mode: "popup", @@ -108,21 +145,21 @@ async function run() { element: "#cap-holder", button: "#cap-btn", getInstance: (instance) => { + if (setupStopped) return; + clearTimeout(initTimer); if (typeof instance.startTracelessVerification === "function") { - instance.startTracelessVerification(); tracelessTimer = setTimeout(interactive, TRACELESS_TIMEOUT); + instance.startTracelessVerification(); } else { interactive(); } }, success: (param) => submit(typeof param === "string" ? param : param?.captchaVerifyParam), fail: (p) => interactive(p), - onError: (p) => interactive(p), + onError: () => { if (!submitted) setupFailed(t("c.initFail")); }, }); } catch (e) { - notifyStuck(); - status(t("c.initFail"), "err"); - detail(String(e)); + setupFailed(t("c.initFail"), String(e)); } } diff --git a/src/claim-result.js b/src/claim-result.js new file mode 100644 index 0000000..66cbb2f --- /dev/null +++ b/src/claim-result.js @@ -0,0 +1,19 @@ +// One receipt belongs to one request, even if the next request uses the same account. +export function createClaimResultWaiter({ accountId, claimId, timeoutMs = 90000, + setTimer = setTimeout, clearTimer = clearTimeout }) { + let done = false, resolve; + const promise = new Promise(r => { resolve = r; }); + const timer = setTimer(() => finish(null), timeoutMs); + function finish(value) { + if (done) return false; + done = true; + clearTimer(timer); + resolve(value); + return true; + } + return { + accountId, claimId, promise, finish, + accept: payload => payload?.accountId === accountId && payload?.claimId === claimId + ? finish(payload) : false, + }; +} diff --git a/src/locales/en.js b/src/locales/en.js index 38576f5..cc169bd 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -63,6 +63,7 @@ export const en = { "m.loginWindowOpened": "Login window opened", "m.loginWindowDetail": "The account is saved automatically once you sign in", "m.claimBusy": "Batch claim in progress — wait for it to finish", + "m.claimCancelled": "Claim cancelled", "m.noClaimable": "No claimable plan for this account", "m.claimVerify": "Completing security verification for \"{name}\"…", "m.claimVerifyDetail": "Traceless verification usually takes 2–3 seconds", @@ -127,6 +128,8 @@ export const en = { "btn.renameMeta": "Enter to save · Esc to cancel", "q.loading": "Querying…", + "q.noActivePlan": "No active quota. Updates after a successful claim", + "q.claimPreviewFailed": "Eligibility query failed: {err}", "q.unknown": "unknown", "q.validUntil": "Valid until {date}", "q.validUntilShort": "until {date}", @@ -159,7 +162,7 @@ export const en = { "s.closeTray": "Keep running in tray on close", "s.closeTrayDesc": "Clicking × hides to the system tray instead of quitting (main window only)", "s.hotSwitch": "Hot switch (no ZCode restart)", - "s.hotSwitchDesc": "Off by default: switching changes the device identity, and the client only accepts the new device code after a restart. On = replace the login while running; open sessions keep the old account, and the client may keep using the old device code until restarted (at your own risk)", + "s.hotSwitchDesc": "Off by default: close and restart ZCode to load the account credentials; ZCode retains its device identity and monitoring session. On = replace credentials while running; open sessions may keep the previous account. Keeping this off is recommended", "s.authLabel": "AUTH · Sign-in", "s.proxyToggle": "Proxy for the sign-in window", "s.proxyToggleDesc": "The z.ai login page routes by exit IP: mainland IPs only get phone-number login; email login needs an overseas exit. When on, only the login window opened by \"Add account\" uses this proxy; quota / claim / switch traffic is unchanged", diff --git a/src/locales/zh.js b/src/locales/zh.js index 432a763..699bb93 100644 --- a/src/locales/zh.js +++ b/src/locales/zh.js @@ -63,6 +63,7 @@ export const zh = { "m.loginWindowOpened": "登录窗口已打开", "m.loginWindowDetail": "登录完成后账号会自动入库", "m.claimBusy": "批量领取进行中,请等待完成", + "m.claimCancelled": "已取消本次领取", "m.noClaimable": "该账号暂无可领取的套餐", "m.claimVerify": "正在为「{name}」完成安全验证…", "m.claimVerifyDetail": "无感验证通常 2-3 秒", @@ -127,6 +128,8 @@ export const zh = { "btn.renameMeta": "回车保存 · Esc 取消", "q.loading": "查询中…", + "q.noActivePlan": "当前没有有效额度,领取成功后会更新", + "q.claimPreviewFailed": "领取资格查询失败:{err}", "q.unknown": "未知", "q.validUntil": "有效期至 {date}", "q.validUntilShort": "至 {date}", @@ -159,7 +162,7 @@ export const zh = { "s.closeTray": "关闭窗口时驻留托盘", "s.closeTrayDesc": "点 × 不退出,最小化到系统托盘(仅主窗口)", "s.hotSwitch": "热切换(不重启 ZCode)", - "s.hotSwitchDesc": "默认关:切换要更换设备身份,客户端重启后才认新设备码。开启 = 运行中直接替换登录态,已打开的会话仍用原账号,且客户端可能继续用旧设备码直至重启(自担风险)", + "s.hotSwitchDesc": "默认关:退出并重启 ZCode 后加载账号凭据;设备标识和监控会话由 ZCode 保持。开启 = 运行中替换凭据,已打开的会话可能继续使用原账号,建议保持关闭", "s.authLabel": "AUTH · 授权登录", "s.proxyToggle": "授权登录走代理", "s.proxyToggleDesc": "z.ai 登录页按出口 IP 分流:国内 IP 只显示手机号登录,邮箱登录需海外出口。开启后仅「添加账号」弹出的登录窗口走此代理;额度查询 / 领取 / 切换等流量不变", diff --git a/src/main.js b/src/main.js index d1c2ffb..2faa532 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,8 @@ import { listen } from "@tauri-apps/api/event"; import { esc, toast, openPwModal, openConfirmModal, openProviderModal, installDelegation, dismissSplash } from "./ui.js"; import { ic } from "./icons.js"; import { init, t, has, lang, localeTag, stripErr } from "./i18n.js"; +import { createAutoClaimScheduler } from "./auto-claim-scheduler.js"; +import { createClaimResultWaiter } from "./claim-result.js"; const $app = document.getElementById("app"); let state = null; @@ -17,7 +19,6 @@ let refreshClaim = { running: false, done: 0, total: 0, cooldownUntil: 0 }; let refreshTicker = null; const AUTO_CLAIM_INTERVAL_MS = 10 * 60 * 1000; -const AUTO_CLAIM_FIRST_DELAY_MS = 2 * 60 * 1000; const AUTO_CLAIM_WAIT_MS = 45_000; const AUTO_CLAIM_PER_ACCOUNT_CAP = 5; const AUTO_CLAIM_ACCT_GAP_MS = 5_000; @@ -57,6 +58,8 @@ function idLabel(id) { async function refresh() { state = await invoke("get_state"); if (state?.language) init(state.language); + autoClaimScheduler.setEnabled(!!state?.auto_claim); + if (!state?.auto_claim) autoAbortRequested = true; } function uiLocked() { @@ -97,18 +100,30 @@ async function loadClaimPreview(id) { const plans = await invoke("claim_preview", { id }); claimable[id] = { plans: plans || [], err: null, busy: false }; } catch (e) { - claimable[id] = { plans: cur.plans || [], err: String(e), busy: false }; + claimable[id] = { plans: [], err: String(e), busy: false }; } } let claimWaiter = null; -function waitForClaimResult(accountId, timeoutMs = 90000) { - return new Promise((resolve) => { - let done = false; - const finish = (v) => { if (!done) { done = true; claimWaiter = null; clearTimeout(t); resolve(v); } }; - const t = setTimeout(() => finish(null), timeoutMs); - claimWaiter = { accountId, finish }; - }); +function waitForClaimResult(accountId, claimId, timeoutMs = 90000) { + const waiter = createClaimResultWaiter({ accountId, claimId, timeoutMs }); + claimWaiter = waiter; + waiter.promise.then(() => { if (claimWaiter === waiter) claimWaiter = null; }); + return waiter.promise; +} + +async function performClaim(id, plan, auto = false, timeoutMs = 90000) { + const claimId = crypto.randomUUID(); + const result = waitForClaimResult(id, claimId, timeoutMs); + try { + await invoke("claim_start", { id, planId: plan.plan_id, auto, claimId }); + } catch (e) { + if (claimWaiter?.claimId === claimId) claimWaiter.finish(null); + throw e; + } + const outcome = await result; + if (!outcome) await invoke("claim_cancel", { claimId }).catch(() => {}); + return outcome; } async function awaitClaimPreviewFresh(id) { @@ -117,7 +132,7 @@ async function awaitClaimPreviewFresh(id) { const plans = await invoke("claim_preview", { id }); claimable[id] = { plans: plans || [], err: null, busy: false }; } catch (e) { - claimable[id] = { plans: claimable[id]?.plans || [], err: String(e), busy: false }; + claimable[id] = { plans: [], err: String(e), busy: false }; } } @@ -317,9 +332,7 @@ const actions = { if (!plan) { toast(t("m.noClaimable"), "warn"); return; } claimActive = true; try { - await invoke("claim_start", { id, planId: plan.plan_id }); - toast(t("m.claimVerify", { name: plan.name || plan.plan_id }), "ok", t("m.claimVerifyDetail")); - const r = await waitForClaimResult(id); + const r = await performClaim(id, plan); if (!r) toast(t("m.claimTimeout"), "warn"); } catch (e) { toast(stripErr(e), "err"); @@ -341,17 +354,14 @@ const actions = { const id = ids[i]; const plan = claimable[id].plans[0]; const name = state.accounts.find((a) => a.id === id)?.name || id; + let r; try { - await invoke("claim_start", { id, planId: plan.plan_id }); + r = await performClaim(id, plan, false, 120000); } catch (e) { toast(t("m.claimAccountErr", { name, err: stripErr(e) }), "err"); continue; } - const r = await waitForClaimResult(id, 120000); - if (!r) { - toast(t("m.claimAcctTimeout", { name }), "warn"); - await invoke("claim_cancel").catch(() => {}); - } + if (!r) toast(t("m.claimAcctTimeout", { name }), "warn"); if (i < ids.length - 1) await new Promise((res) => setTimeout(res, 1200)); } } finally { @@ -399,7 +409,7 @@ const actions = { if ((r.plans || []).length) okCount++; if (r.activationError) toast(t("m.refreshClaimAcctErr", { name, err: stripErr(r.activationError) }), "warn"); } catch (e) { - claimable[id] = { plans: claimable[id]?.plans || [], err: String(e), busy: false }; + claimable[id] = { plans: [], err: String(e), busy: false }; toast(t("m.refreshClaimAcctErr", { name, err: stripErr(e) }), "err"); } scheduleNext(id); @@ -409,6 +419,7 @@ const actions = { } finally { refreshClaim.running = false; refreshClaim.cooldownUntil = Date.now() + REFRESH_CLAIM_COOLDOWN_MS; + autoClaimScheduler.request(); if (!uiLocked()) render(); setTimeout(stopRefreshTickerIfIdle, 1100); } @@ -423,18 +434,24 @@ const actions = { return; } autoToggleBusy = true; + if (!next) { + autoAbortRequested = true; + autoClaimScheduler.setEnabled(false); + } try { await invoke("set_behavior", { autoClaim: next }); await refresh(); render(); if (state.auto_claim) { toast(t("m.autoClaimOn"), "ok", t("m.autoClaimOnDetail")); - if (Date.now() - (lastAutoRound?.at ?? 0) > REFRESH_CLAIM_COOLDOWN_MS) autoClaimTick(); + autoClaimScheduler.request(); } else { lastAutoRound = null; } - } catch (e) { toast(stripErr(e), "err"); } - finally { autoToggleBusy = false; } + } catch (e) { + autoClaimScheduler.setEnabled(!!state?.auto_claim); + toast(stripErr(e), "err"); + } finally { autoToggleBusy = false; } }, }; @@ -485,7 +502,7 @@ async function autoClaimTick() { const r = await invoke("claim_refresh", { id }); claimable[id] = { plans: r.plans || [], err: null, busy: false }; } catch (e) { - claimable[id] = { plans: claimable[id]?.plans || [], err: String(e), busy: false }; + claimable[id] = { plans: [], err: String(e), busy: false }; autoClaimCooldown[id] = Date.now() + AUTO_CLAIM_INTERVAL_MS; roundSkipped++; continue; @@ -494,21 +511,19 @@ async function autoClaimTick() { let attempts = 0; let progressed = true; while (progressed && attempts < AUTO_CLAIM_PER_ACCOUNT_CAP) { - if (autoAbortRequested) break; + if (autoAbortRequested || !state?.auto_claim) break; attempts++; progressed = false; const plan = claimable[id]?.plans?.[0]; if (!plan) break; + let r; try { - await invoke("claim_start", { id, planId: plan.plan_id, auto: true }); + r = await performClaim(id, plan, true, AUTO_CLAIM_WAIT_MS); } catch (e) { - await invoke("claim_cancel").catch(() => {}); autoClaimCooldown[id] = Date.now() + AUTO_CLAIM_INTERVAL_MS; break; } - const r = await waitForClaimResult(id, AUTO_CLAIM_WAIT_MS); if (!r) { - await invoke("claim_cancel").catch(() => {}); autoClaimCooldown[id] = Date.now() + AUTO_CLAIM_INTERVAL_MS; break; } @@ -532,6 +547,13 @@ async function autoClaimTick() { } } +const autoClaimScheduler = createAutoClaimScheduler({ + runRound: autoClaimTick, + isBusy: () => busy || autoToggleBusy || claimActive || claimAllRunning || refreshClaim.running || autoClaimRunning, + intervalMs: AUTO_CLAIM_INTERVAL_MS, + onError: (e) => toast(stripErr(e), "err"), +}); + function quotaBarHtml(pct) { const used = pct == null ? null : Math.min(100, Math.max(0, pct)); const remaining = used == null ? null : 100 - used; @@ -632,6 +654,10 @@ function grantLabel(plan) { } function claimStripHtml(id) { const c = claimable[id]; + if (c?.err) { + const msg = stripErr(c.err); + return `
${esc(t("q.claimPreviewFailed", { err: msg.length > 65 ? msg.slice(0, 65) + "…" : msg }))}
`; + } const plan = c?.plans?.[0]; if (!plan) return ""; const grants = grantLabel(plan); @@ -708,6 +734,7 @@ function acctQuotaSlot(id) { inner = slotRowsHtml(items); } } + if (!inner && !plans.length) inner = `${t("q.noActivePlan")}`; } if (!strip && !inner) return `
`; return `
${strip}${inner}
`; @@ -881,7 +908,7 @@ listen("tray-action", (ev) => { listen("claim://result", (ev) => { const p = ev.payload || {}; - if (claimWaiter && claimWaiter.accountId === p.accountId) claimWaiter.finish(p); + if (!claimWaiter?.accept(p)) return; if (p.ok === false) { let msg = p.message || t("m.unknownErr"); if (p.code === 1005 && p.nextAt) { @@ -904,13 +931,30 @@ listen("claim://result", (ev) => { } }); -listen("captcha://interactive", () => { - if (!autoClaimRunning || !claimWaiter) return; - const id = claimWaiter.accountId; - invoke("claim_cancel").catch(() => {}); +listen("claim://cancelled", (ev) => { + const p = ev.payload || {}; + if (claimWaiter?.accept({ ...p, ok: false, code: "cancelled" })) { + toast(t("m.claimCancelled"), "warn"); + } +}); + +listen("captcha://failed", (ev) => { + const p = ev.payload || {}; + if (!claimWaiter || claimWaiter.claimId !== p.claimId) return; + const waiter = claimWaiter; + waiter.finish({ ok: false, code: "setup", message: p.message }); + invoke("claim_cancel", { claimId: waiter.claimId }).catch(() => {}); + toast(p.message || t("m.claimFailed", { name: accountName(waiter.accountId), msg: t("m.unknownErr") }), "err"); +}); + +listen("captcha://interactive", (ev) => { + if (!autoClaimRunning || !claimWaiter || claimWaiter.claimId !== ev.payload?.claimId) return; + const waiter = claimWaiter; + const id = waiter.accountId; autoClaimCooldown[id] = Date.now() + 60 * 60 * 1000; + waiter.finish({ ok: false, code: "interactive" }); + invoke("claim_cancel", { claimId: waiter.claimId }).catch(() => {}); toast(t("m.autoClaimInteractive", { name: accountName(id) }), "warn", t("m.autoClaimInteractiveDetail")); - claimWaiter.finish({ ok: false, code: "interactive" }); }); listen("oauth://done", (ev) => { @@ -981,11 +1025,16 @@ async function sweepTick() { enrollAccounts(); sweepTick(); setInterval(() => { - invoke("get_state").then((s) => { state = s; if (s?.language) init(s.language); enrollAccounts(); if (!uiLocked()) render(); }).catch(() => {}); + invoke("get_state").then((s) => { + state = s; + if (s?.language) init(s.language); + autoClaimScheduler.setEnabled(!!s?.auto_claim); + if (!s?.auto_claim) autoAbortRequested = true; + enrollAccounts(); + if (!uiLocked()) render(); + }).catch(() => {}); }, 5000); setInterval(sweepTick, TICK_MS); - setTimeout(autoClaimTick, AUTO_CLAIM_FIRST_DELAY_MS); - setInterval(autoClaimTick, AUTO_CLAIM_INTERVAL_MS); } catch (e) { $app.innerHTML = `
${t("common.loadFail", { e: esc(stripErr(e)) })}
`; invoke("reveal_main").catch(() => {});