@swimmingliu/autovpn 2.2.2 → 2.2.3

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)
@@ -2010,6 +2016,38 @@ export function classifyMembershipRefresh(facts, today) {
2010
2016
  * active rows deliberately, reusing the normal credential flow (cached
2011
2017
  * fingerprint, one node request) so the login path is the same one a run uses.
2012
2018
  */
2019
+ /**
2020
+ * Where a membership sweep should log in for each brand.
2021
+ *
2022
+ * The sweep must reach the same host a run reaches, or its verdicts describe a
2023
+ * different account than the one the pool rotates: on a redirected row the run
2024
+ * rewrites `official_site` to the backend before any request, and the sweep
2025
+ * used to probe the storefront the run never touches. Read from the profile so
2026
+ * a redirected row is known here exactly as the run knows it.
2027
+ */
2028
+ export function resolveExpiryProbeTargets(env, readSources) {
2029
+ const targets = new Map();
2030
+ let sources = {};
2031
+ try {
2032
+ // The profile path comes from the environment, so the project root argument
2033
+ // is not consulted; `profilePayload` reads the same file a run does.
2034
+ sources = readSources ? readSources() : (profilePayload(process.cwd(), env).sources ?? {});
2035
+ }
2036
+ catch {
2037
+ // No readable profile: every brand falls back to its own storefront.
2038
+ }
2039
+ for (const [name, config] of Object.entries(sources)) {
2040
+ const brand = normalizeBrand(name);
2041
+ if (!brand)
2042
+ continue;
2043
+ const backend = normalizeBrand(String(config?.backend_brand ?? '').trim());
2044
+ targets.set(brand, {
2045
+ officialSite: String(config?.official_site ?? '').trim() || brandSite(brand),
2046
+ ...(backend && backend !== brand ? { backendBrand: backend } : {})
2047
+ });
2048
+ }
2049
+ return targets;
2050
+ }
2013
2051
  export async function refreshAccountExpiry(input = {}) {
2014
2052
  const env = input.env ?? process.env;
2015
2053
  const now = input.now ?? (() => new Date());
@@ -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') {
@@ -219,7 +219,18 @@ function artifactEventLog(artifactDir) {
219
219
  try {
220
220
  const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
221
221
  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');
222
+ // Only the LAST run in the file decides whether it is still going. A resume
223
+ // appends to the same artifact log, so an earlier run's terminal line would
224
+ // otherwise mark the live resume finished and the page would never follow it.
225
+ let finished = false;
226
+ for (const event of events) {
227
+ if (event.type === 'run_started' || event.type === 'resume_latest_state' || event.type === 'resume_pipeline_state') {
228
+ finished = false;
229
+ }
230
+ else if (event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished') {
231
+ finished = true;
232
+ }
233
+ }
223
234
  let fresh = false;
224
235
  try {
225
236
  fresh = Date.now() - fs.statSync(filePath).mtimeMs < 2 * 60 * 1000;
@@ -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.3',
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {