Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Tauri 2 桌面工具:在多个 ZCode 账号之间一键切换,自动显示

## 功能

- **保存 / 切换账号**:一键切换登录身份;切换前自动保全当前登录,绝不丢号;设备身份跟账号走,远程控制中继密钥跨切换保活
- **保存 / 切换账号**:一键切换登录身份;切换前自动保全当前登录,绝不丢号;沿用 ZCode 本机设备标识和监控会话,远程控制中继密钥跨切换保活
- **添加账号**:工具内 OAuth 登录新号(BigModel / z.ai 双入口),全程不动当前登录
- **额度展示**:账号行内联显示套餐额度与重置时间,多套餐分组
- **活动领取**:可领套餐一键领取;「自动领取」开关(默认关)定时自动检测并领取,手动操作优先
Expand Down
66 changes: 66 additions & 0 deletions docs/login-session.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
245 changes: 245 additions & 0 deletions scripts/test-auto-claim-scheduler.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
56 changes: 56 additions & 0 deletions scripts/test-auto-controls.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading