@swimmingliu/autovpn 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import { AccountStore, maskAccount } from '../pool/account-store.js';
8
8
  import { BRANDS, brandFromSite, brandSite, normalizeBrand } from '../pool/brands.js';
9
9
  import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
10
10
  import { resolveUserRuntimeRoot } from '../runtime/paths.js';
11
+ import { profilePayload } from '../config/profile.js';
11
12
  import { redactText } from '../runtime/redaction.js';
12
13
  /**
13
14
  * How many pooled accounts one source may try before it reports failure.
@@ -1986,12 +1987,17 @@ export function classifyMembershipRefresh(facts, today) {
1986
1987
  if (expires && expires < today)
1987
1988
  return { status: 'expired', reason: 'membership expired' };
1988
1989
  const level = facts.membership_level;
1989
- const produced = Number(facts.produced_links ?? 0) > 0 || Number(facts.route_count ?? 0) > 0;
1990
+ // Links, not routes: the route table is filtered by flags, not by the
1991
+ // account's grade, so a free row can list paid-looking areas and still get
1992
+ // nothing but placeholders. The run path uses links for the same reason.
1993
+ const produced = Number(facts.produced_links ?? 0) > 0;
1990
1994
  // A free-tier row (level 0/1) only ever yields placeholders. Retire it only
1991
1995
  // when it produced nothing, exactly as the run path does, so a paid row the
1992
- // vendor misreports as level 1 keeps its place.
1993
- if (level !== undefined && level < 2 && !produced)
1996
+ // vendor misreports as level 1 keeps its place. A redirected row is exempt
1997
+ // for the run path's reason: the level is the storefront's grading.
1998
+ if (level !== undefined && level < 2 && !produced && !facts.redirected) {
1994
1999
  return { status: 'retired', reason: 'no paid membership' };
2000
+ }
1995
2001
  return { status: 'updated', reason: '' };
1996
2002
  }
1997
2003
  if (facts.membership_expired)
@@ -2001,6 +2007,38 @@ export function classifyMembershipRefresh(facts, today) {
2001
2007
  return { status: 'retired', reason: 'login rejected' };
2002
2008
  return { status: 'failed', reason: '' };
2003
2009
  }
2010
+ /**
2011
+ * Where a membership sweep should log in for each brand.
2012
+ *
2013
+ * The sweep must reach the same host a run reaches, or its verdicts describe a
2014
+ * different account than the one the pool rotates: on a redirected row the run
2015
+ * rewrites `official_site` to the backend before any request, and the sweep
2016
+ * used to probe the storefront the run never touches. Read from the profile so
2017
+ * a redirected row is known here exactly as the run knows it.
2018
+ */
2019
+ export function resolveExpiryProbeTargets(env, readSources) {
2020
+ const targets = new Map();
2021
+ let sources = {};
2022
+ try {
2023
+ // The profile path comes from the environment, so the project root argument
2024
+ // is not consulted; `profilePayload` reads the same file a run does.
2025
+ sources = readSources ? readSources() : (profilePayload(process.cwd(), env).sources ?? {});
2026
+ }
2027
+ catch {
2028
+ // No readable profile: every brand falls back to its own storefront.
2029
+ }
2030
+ for (const [name, config] of Object.entries(sources)) {
2031
+ const brand = normalizeBrand(name);
2032
+ if (!brand)
2033
+ continue;
2034
+ const backend = normalizeBrand(String(config?.backend_brand ?? '').trim());
2035
+ targets.set(brand, {
2036
+ officialSite: String(config?.official_site ?? '').trim() || brandSite(brand),
2037
+ ...(backend && backend !== brand ? { backendBrand: backend } : {})
2038
+ });
2039
+ }
2040
+ return targets;
2041
+ }
2004
2042
  /**
2005
2043
  * Learn every pooled account's real membership expiry by logging in.
2006
2044
  *
@@ -2026,6 +2064,15 @@ export async function refreshAccountExpiry(input = {}) {
2026
2064
  const requested = input.brand ? normalizeBrand(input.brand) : null;
2027
2065
  // Every brand the pool knows, from the registry rather than a second list.
2028
2066
  const brands = requested ? [requested] : BRANDS.map((brand) => brand.key);
2067
+ // How a run would reach each brand. A redirected row is graded by its own
2068
+ // storefront but logs in — and is graded again — against the backend it
2069
+ // reads, with the backend's APK. Probing the storefront instead put the sweep
2070
+ // on a host the run never touches (the deployment host cannot even resolve
2071
+ // 黑洞's storefront) and graded the account by a backend it never uses, so its
2072
+ // verdicts described a different account than the one the pool rotates.
2073
+ const probeFor = resolveExpiryProbeTargets(env);
2074
+ const probeSite = (brand) => probeFor.get(brand)?.officialSite ?? brandSite(brand);
2075
+ const probeBackend = (brand) => probeFor.get(brand)?.backendBrand;
2029
2076
  try {
2030
2077
  for (const brand of brands) {
2031
2078
  let accounts = store.leaseAll(brand);
@@ -2034,10 +2081,13 @@ export async function refreshAccountExpiry(input = {}) {
2034
2081
  if (accounts.length === 0)
2035
2082
  continue;
2036
2083
  // One memo per brand: the APK is inspected at most once for the whole
2037
- // sweep, not once per account. The cache key is the short brand key the
2038
- // normal run writes (`mf`, not `mifeng`); using the long name missed the
2039
- // cache on every sweep and re-downloaded the APK per account.
2040
- const brandKey = credentialBrandKey(brand, { official_site: brandSite(brand) });
2084
+ // sweep, not once per account. The key comes from the same function a run
2085
+ // uses, against the same probe source, so a redirected row is inspected
2086
+ // under the backend's prefix and shares the entry the run just cached.
2087
+ const brandKey = fingerprintKeyFor(brand, {
2088
+ official_site: probeSite(brand),
2089
+ ...(probeBackend(brand) ? { brand_override: probeBackend(brand) } : {})
2090
+ });
2041
2091
  const memo = createFingerprintMemo(fingerprintStore, brandKey);
2042
2092
  await mapWithConcurrency(accounts, concurrency, async (leased) => {
2043
2093
  const entry = {
@@ -2052,7 +2102,8 @@ export async function refreshAccountExpiry(input = {}) {
2052
2102
  source_name: brand,
2053
2103
  source: {
2054
2104
  mode: 'credential',
2055
- official_site: brandSite(brand),
2105
+ official_site: probeSite(brand),
2106
+ ...(probeBackend(brand) ? { backend_brand: probeBackend(brand) } : {}),
2056
2107
  account: leased.account,
2057
2108
  password: leased.password,
2058
2109
  platforms: ['android'],
@@ -2065,7 +2116,8 @@ export async function refreshAccountExpiry(input = {}) {
2065
2116
  entry.expires_at = expires;
2066
2117
  const verdict = classifyMembershipRefresh({
2067
2118
  ...result.diagnostics,
2068
- produced_links: result.links.length
2119
+ produced_links: result.links.length,
2120
+ redirected: Boolean(probeBackend(brand))
2069
2121
  }, todayText);
2070
2122
  entry.status = verdict.status;
2071
2123
  if (verdict.status === 'updated' || verdict.status === 'expired') {
@@ -61,6 +61,22 @@ function preserveRedactedSecrets(incoming, current) {
61
61
  }
62
62
  return merged;
63
63
  }
64
+ /**
65
+ * Merge the live database totals over the report's per-source counts.
66
+ *
67
+ * The database is authoritative for link counts, but it knows nothing about
68
+ * what only the pipeline reports — `area_count`, the length of the frozen route
69
+ * list that the drawer's lap estimate reads. Spreading its map whole dropped
70
+ * those keys, so a finished run's estimate fell back to the reference area count
71
+ * (26 real areas shown as the 31-area default).
72
+ */
73
+ function mergeSourceCounts(reported, progress) {
74
+ const merged = { ...(reported ?? {}) };
75
+ for (const [source, counts] of Object.entries(progress)) {
76
+ merged[source] = { ...(merged[source] ?? {}), ...counts };
77
+ }
78
+ return merged;
79
+ }
64
80
  function normalizeLatestArtifact(projectRoot, env) {
65
81
  const latest = artifactLatest(projectRoot, env);
66
82
  if (!latest.ok || !latest.artifact_dir) {
@@ -68,12 +84,20 @@ function normalizeLatestArtifact(projectRoot, env) {
68
84
  }
69
85
  const preview = previewArtifact(String(latest.artifact_dir), { includeNodeRows: true });
70
86
  const progress = readRunProgress(path.join(String(latest.artifact_dir), 'run.db'));
87
+ // A live run's own mirror is newer than the report it replaced at the last
88
+ // stage boundary, and it is what the page was just following. Replaying the
89
+ // report's `failed` summary over it is how a resumed run read as failed on
90
+ // every reload. History stays the report's when the log is finished.
91
+ const artifactLog = artifactEventLog(String(latest.artifact_dir));
92
+ const logEvents = artifactLog.events;
93
+ const liveLog = artifactLog.active && logEvents.length > 0;
71
94
  return {
72
95
  ...latest,
73
96
  ...preview,
97
+ ...(liveLog ? { run_status: 'running' } : {}),
74
98
  // The report can still be empty mid-stage. Its display preview must not
75
99
  // replace the authoritative totals that already exist in the run database.
76
- ...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: progress.source_counts, progress_snapshot: progress } : {}),
100
+ ...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: mergeSourceCounts(preview.source_counts, progress.source_counts), progress_snapshot: progress } : {}),
77
101
  outputFiles: (preview.files ?? []),
78
102
  // The results page and the country donut read these rows. Answering with an
79
103
  // empty array left a browser session at 0 nodes and an empty 国家分布 while
@@ -204,6 +228,82 @@ function latestJobEvents(job, maxEvents = 1000) {
204
228
  return [];
205
229
  }
206
230
  }
231
+ /**
232
+ * Events that announce a run has started.
233
+ *
234
+ * They carry no run status, so the follower reads them as `running`. A resume
235
+ * writes one of these (a pipeline resume writes it late, after re-running the
236
+ * extract it found incomplete), which is why the "is this log live" question
237
+ * cannot be answered from them alone — `artifactEventLog` asks it from the last
238
+ * line instead.
239
+ */
240
+ const RUN_START_EVENTS = new Set([
241
+ 'run_started',
242
+ 'resume_latest_state',
243
+ 'resume_pipeline_state',
244
+ 'speedtest_resume_state'
245
+ ]);
246
+ /** Same directory, even across a symlinked root (`/var` vs `/private/var`). */
247
+ function sameDir(left, right) {
248
+ if (!left || !right)
249
+ return false;
250
+ if (left === right)
251
+ return true;
252
+ try {
253
+ return fs.realpathSync(left) === fs.realpathSync(right);
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ }
259
+ /**
260
+ * Lines that end a run's event stream.
261
+ *
262
+ * A run is live while the last line in its log is something else. That rule
263
+ * lets one artifact log hold several runs: a resume appends to the log the
264
+ * interrupted run wrote, so the old `summary failed` sits in the middle of the
265
+ * file. Scanning for run boundaries instead was wrong in both directions — a
266
+ * pipeline resume re-runs extract before it writes its own boundary, so the
267
+ * file read as finished while it was working.
268
+ */
269
+ function isTerminalEvent(event) {
270
+ return event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished';
271
+ }
272
+ /**
273
+ * Byte offset just past the last terminal line in an artifact event log.
274
+ *
275
+ * Everything before it belongs to a run that already ended and must not be
276
+ * replayed to the page. Zero when the file holds no terminal line at all.
277
+ */
278
+ function artifactFollowOffset(filePath) {
279
+ let text = '';
280
+ try {
281
+ text = fs.readFileSync(filePath, 'utf8');
282
+ }
283
+ catch {
284
+ return 0;
285
+ }
286
+ // Byte offsets, not character offsets: the log carries Chinese vendor
287
+ // messages, and seeking by character count would land mid-line.
288
+ let offset = 0;
289
+ let resumeAt = 0;
290
+ for (const rawLine of text.split('\n')) {
291
+ // Both writers terminate every line, so a line without its newline is the
292
+ // trailing fragment of a write that has not landed yet. Advancing past it
293
+ // would put the next read one byte into the following event.
294
+ const width = Buffer.byteLength(rawLine, 'utf8');
295
+ const terminated = offset + width < text.length;
296
+ const line = rawLine.trim();
297
+ if (line) {
298
+ const event = parseJsonLine(line);
299
+ if (event && typeof event === 'object' && isTerminalEvent(event)) {
300
+ resumeAt = offset + width + (terminated ? 1 : 0);
301
+ }
302
+ }
303
+ offset += width + (terminated ? 1 : 0);
304
+ }
305
+ return resumeAt;
306
+ }
207
307
  /**
208
308
  * Stage/substep states and log lines for the latest artifact.
209
309
  *
@@ -219,7 +319,10 @@ function artifactEventLog(artifactDir) {
219
319
  try {
220
320
  const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
221
321
  const events = lines.map((line) => parseJsonLine(line)).filter(Boolean);
222
- const finished = events.some((event) => event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished');
322
+ // The last line decides: a run is live while it has not written a terminal
323
+ // event yet. Only the last one counts, or the interrupted run's summary
324
+ // would mark the resume that followed it finished.
325
+ const finished = events.length > 0 && isTerminalEvent(events[events.length - 1]);
223
326
  let fresh = false;
224
327
  try {
225
328
  fresh = Date.now() - fs.statSync(filePath).mtimeMs < 2 * 60 * 1000;
@@ -456,6 +559,14 @@ export function createServerRuntime(options) {
456
559
  let unsubscribeArtifactLogs;
457
560
  let followedArtifactDir = '';
458
561
  let expiryRefreshInFlight = false;
562
+ // Set by close(): the timer and the followers are torn down, and nothing may
563
+ // re-arm one afterwards. Without this a shutdown that follows a cron run can
564
+ // attach a fresh 15s poller and keep the process alive on its own work.
565
+ let closed = false;
566
+ // The artifact whose run the operator ended. Its log is still being written
567
+ // by a process the server does not own, so it stays "active" — the watcher
568
+ // must not read that as a run to pick back up.
569
+ let stoppedArtifactDir = '';
459
570
  const pruneHistory = createHistoryPruner(options);
460
571
  const subscribers = new Set();
461
572
  const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
@@ -467,6 +578,12 @@ export function createServerRuntime(options) {
467
578
  subscriber(event);
468
579
  }
469
580
  };
581
+ // The watcher only reads two files, so a cadence well under the follower's
582
+ // is cheap; it is the difference between a page that waits for a reload and
583
+ // one that shows tonight's run.
584
+ const runWatchMs = Math.max(1000, Math.trunc(Number(options.runWatchIntervalMs ?? 15_000)) || 15_000);
585
+ const watchTimer = setInterval(watchForNewRun, runWatchMs);
586
+ watchTimer.unref?.();
470
587
  /**
471
588
  * Follow a plain event log that no job directory owns.
472
589
  *
@@ -477,6 +594,8 @@ export function createServerRuntime(options) {
477
594
  * page needs sub-second freshness, not inotify precision.
478
595
  */
479
596
  function followArtifactEvents(artifactDir) {
597
+ if (closed)
598
+ return;
480
599
  if (followedArtifactDir === artifactDir && unsubscribeArtifactLogs) {
481
600
  return;
482
601
  }
@@ -484,7 +603,11 @@ export function createServerRuntime(options) {
484
603
  followedArtifactDir = artifactDir;
485
604
  const filePath = path.join(artifactDir, 'events.jsonl');
486
605
  let cancelled = false;
487
- let offset = 0;
606
+ // Start at the current run, not at the top of the file: a resumed run
607
+ // shares the artifact with the run it resumes, and replaying the earlier
608
+ // run's `summary failed` republished a stale terminal state and then stopped
609
+ // this follower — the live run's later events never reached the page.
610
+ let offset = artifactFollowOffset(filePath);
488
611
  let pending = '';
489
612
  const timer = setInterval(() => {
490
613
  if (cancelled)
@@ -514,29 +637,40 @@ export function createServerRuntime(options) {
514
637
  }
515
638
  const lines = chunk.split(/\r?\n/);
516
639
  pending = lines.pop() ?? '';
517
- let terminal = false;
640
+ let finalState;
641
+ let lastEvent;
518
642
  for (const line of lines) {
519
643
  const event = parseJsonLine(line);
520
644
  if (!event)
521
645
  continue;
522
- const nextRunState = eventRunState(event);
523
- if (nextRunState) {
524
- runState = nextRunState;
525
- terminal = true;
526
- }
646
+ const record = event;
647
+ lastEvent = record;
527
648
  // Same contract as the job follower: a result line replayed after a
528
649
  // page load must carry absolute database totals, or the renderer adds
529
650
  // it on top of the snapshot it already hydrated and doubles the count.
530
- const record = event;
531
651
  const metricEvent = ['extract_iteration', 'extract_source_completed', 'speedtest_result', 'availability_link_result'].includes(String(record.type));
532
652
  const progress = metricEvent ? readRunProgress(path.join(artifactDir, 'run.db')) : undefined;
533
653
  publish(progress ? { ...record, progress_snapshot: { ...progress, artifact_dir: artifactDir } } : event);
534
- if (nextRunState)
535
- publish({ type: 'server_state', run_state: runState });
654
+ // The state is resolved over the whole chunk and published once, at the
655
+ // end: a chunk can hold a summary and the events of the resume that
656
+ // followed it, and publishing the summary's `failed` on the way past
657
+ // flashed it on the page before the resume took it back.
658
+ const nextRunState = eventRunState(record);
659
+ if (nextRunState) {
660
+ finalState = nextRunState;
661
+ }
662
+ else if (RUN_START_EVENTS.has(String(record.type))) {
663
+ finalState = 'running';
664
+ }
665
+ }
666
+ if (finalState) {
667
+ runState = finalState;
668
+ publish({ type: 'server_state', run_state: runState });
536
669
  }
537
- // The run is over: stop polling instead of stat-ing a finished file every
538
- // 800ms for the life of the server. A later run follows its own artifact.
539
- if (terminal) {
670
+ // The run is over only when its LAST line says so. A summary followed by
671
+ // a resume in the same chunk is a live run, and stopping here is what
672
+ // left the resumed run unfollowed.
673
+ if (lastEvent && isTerminalEvent(lastEvent)) {
540
674
  unsubscribeArtifactLogs?.();
541
675
  unsubscribeArtifactLogs = undefined;
542
676
  followedArtifactDir = '';
@@ -596,6 +730,52 @@ export function createServerRuntime(options) {
596
730
  }
597
731
  });
598
732
  }
733
+ /**
734
+ * Notice a run this server is not following yet.
735
+ *
736
+ * Consecutive nightly runs are the case: the follower clears itself at one
737
+ * run's summary, and the next night's artifact appears with no state load to
738
+ * trigger `restoreRunState`. Without this heartbeat an open page keeps last
739
+ * night's finished run until someone reloads — the exact "logs stopped
740
+ * refreshing" complaint, one run later.
741
+ */
742
+ function watchForNewRun() {
743
+ if (closed)
744
+ return;
745
+ if (runState === 'running' || runState === 'stopping')
746
+ return;
747
+ if (unsubscribeLogs || activeJobId) {
748
+ // A managed job's follower leaves both guards set when its stream ends,
749
+ // and it can never see a later scheduled run. Release them once the job
750
+ // itself is no longer live, or the watcher stays dead after one click of
751
+ // 开始运行.
752
+ const job = latestJob(options.projectRoot, options.env ?? process.env);
753
+ if (job && ['running', 'stopping'].includes(String(job.status ?? '')))
754
+ return;
755
+ unsubscribeLogs?.();
756
+ unsubscribeLogs = undefined;
757
+ activeJobId = '';
758
+ }
759
+ if (unsubscribeArtifactLogs && followedArtifactDir)
760
+ return;
761
+ // This reads and folds the whole artifact log, which is the price of
762
+ // knowing whether the run is still going. It deliberately stops short of a
763
+ // state load, which would also open `run.db` (creating WAL sidecars), parse
764
+ // the final node list and run the retention sweep — every 15 seconds.
765
+ const latest = artifactLatest(options.projectRoot, options.env ?? process.env);
766
+ const artifactDir = String(latest?.artifact_dir ?? '');
767
+ if (!artifactDir || sameDir(artifactDir, stoppedArtifactDir))
768
+ return;
769
+ if (!artifactEventLog(artifactDir).active)
770
+ return;
771
+ beginFollowingArtifact(artifactDir);
772
+ }
773
+ /** Adopt an artifact log that is still being written as the live run. */
774
+ function beginFollowingArtifact(artifactDir) {
775
+ runState = 'running';
776
+ publish({ type: 'server_state', run_state: runState });
777
+ followArtifactEvents(artifactDir);
778
+ }
599
779
  function restoreRunState(artifact) {
600
780
  const env = options.env ?? process.env;
601
781
  // A terminal state is a report about the *previous* run, not a latch. A new
@@ -610,8 +790,7 @@ export function createServerRuntime(options) {
610
790
  if (liveArtifact && runState !== 'running' && runState !== 'stopping') {
611
791
  unsubscribeLogs?.();
612
792
  activeJobId = '';
613
- runState = 'running';
614
- followArtifactEvents(liveArtifact);
793
+ beginFollowingArtifact(liveArtifact);
615
794
  return;
616
795
  }
617
796
  const job = latestJob(options.projectRoot, env);
@@ -635,8 +814,7 @@ export function createServerRuntime(options) {
635
814
  // after the server restarts mid-run.
636
815
  const artifactDir = String(artifact?.artifact_dir ?? '');
637
816
  if (artifactDir && artifactEventLog(artifactDir).active) {
638
- runState = 'running';
639
- followArtifactEvents(artifactDir);
817
+ beginFollowingArtifact(artifactDir);
640
818
  }
641
819
  }
642
820
  }
@@ -846,10 +1024,18 @@ export function createServerRuntime(options) {
846
1024
  async stopRun() {
847
1025
  if (runState !== 'running' || !activeJobId) {
848
1026
  // A scheduled run has no managed job to signal; stop following its log
849
- // so the page does not keep reporting a run the operator ended.
1027
+ // so the page does not keep reporting a run the operator ended. The
1028
+ // process itself keeps writing, so the log still looks live — remember
1029
+ // which artifact was ended, or the watcher picks it straight back up.
1030
+ if (followedArtifactDir)
1031
+ stoppedArtifactDir = followedArtifactDir;
850
1032
  unsubscribeArtifactLogs?.();
851
1033
  unsubscribeArtifactLogs = undefined;
852
1034
  followedArtifactDir = '';
1035
+ if (runState === 'running') {
1036
+ runState = 'idle';
1037
+ publish({ type: 'server_state', run_state: runState });
1038
+ }
853
1039
  return { ok: true, requested: false, run_state: runState };
854
1040
  }
855
1041
  runState = 'stopping';
@@ -875,6 +1061,8 @@ export function createServerRuntime(options) {
875
1061
  return () => subscribers.delete(handler);
876
1062
  },
877
1063
  async close() {
1064
+ closed = true;
1065
+ clearInterval(watchTimer);
878
1066
  unsubscribeLogs?.();
879
1067
  unsubscribeArtifactLogs?.();
880
1068
  if (!activeJobId) {
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.2.2.2',
8
+ sidebarVersion: 'v.2.2.4',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
@@ -1252,7 +1252,10 @@ export function buildSourceIterationDraft(sources = {}, sourceCounts = {}) {
1252
1252
  const storedDry = hasStoredLapValue(source.dry_laps) ? coercePositiveInteger(source.dry_laps, SOURCE_LAP_DEFAULTS.dry_laps) : null;
1253
1253
  if (storedLaps !== null || storedDry !== null) {
1254
1254
  source.max_laps = storedLaps ?? SOURCE_LAP_DEFAULTS.max_laps;
1255
- source.dry_laps = storedDry ?? SOURCE_LAP_DEFAULTS.dry_laps;
1255
+ // Same clamp as the conversion below: a profile written by an older
1256
+ // build (or hand-edited) can already hold dry > max, and the drawer's
1257
+ // validator refuses that pair — an untouched row must stay savable.
1258
+ source.dry_laps = Math.min(source.max_laps, storedDry ?? SOURCE_LAP_DEFAULTS.dry_laps);
1256
1259
  continue;
1257
1260
  }
1258
1261
  // Only a row that actually stored a legacy budget is converted; a fresh
@@ -1599,7 +1602,11 @@ function inferLogStage(line) {
1599
1602
  // debug profile that sets them must survive a save from the drawer.
1600
1603
  const OBSOLETE_SOURCE_FIELDS = [
1601
1604
  'url', 'key', 'account', 'password', 'platform',
1602
- 'use_random_area', 'area_min', 'area_max', 'max_areas',
1605
+ 'use_random_area', 'area_min', 'area_max',
1606
+ // `max_areas` is deliberately absent: the credential flow still reads it as
1607
+ // an operator's cap on the frozen route list, and the drawer never rendered
1608
+ // it. Deleting it here made one untouched save drop a cap the run honours,
1609
+ // silently widening the crawl.
1603
1610
  'resume_from_iteration', 'failure_limit'
1604
1611
  ];
1605
1612
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {