@swimmingliu/autovpn 2.1.1 → 2.2.1
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.
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isIsoAlpha2CountryCode } from '../pipeline/country-codes.js';
|
|
3
4
|
import { redactText, safeDeployment } from '../runtime/redaction.js';
|
|
4
5
|
const FINAL_NODE_FILES = ['vpn_node_emoji.txt', 'vpn_node_availability.txt', 'vpn_node_speedtest.txt'];
|
|
6
|
+
// Postprocess decorates every node as `<flag> <CC> <vendor name>`. The country
|
|
7
|
+
// code is only trusted in that exact position; a bare two-letter token inside a
|
|
8
|
+
// vendor name is not a region.
|
|
9
|
+
const LEADING_FLAG_COUNTRY_PATTERN = /^[\u{1F1E6}-\u{1F1FF}]{2}\s+([A-Z]{2})(?:\s|$)/u;
|
|
5
10
|
function formatBytes(size) {
|
|
6
11
|
if (size >= 1024 * 1024) {
|
|
7
12
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
|
@@ -29,46 +34,87 @@ function fileInventory(artifactDir) {
|
|
|
29
34
|
.map((item) => ({ name: path.basename(item), size: formatBytes(fs.statSync(item).size) }))
|
|
30
35
|
.sort((left, right) => String(left.name).localeCompare(String(right.name)));
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Decode one final-output line into the row the UI renders.
|
|
39
|
+
*
|
|
40
|
+
* Country comes from the decorated name, never from the address: the pipeline
|
|
41
|
+
* writes `<flag> <CC> <vendor name>`, so the prefix is authoritative and a name
|
|
42
|
+
* without one is unknown rather than a guess.
|
|
43
|
+
*
|
|
44
|
+
* A line that is not a decodable vmess link returns null, matching the desktop
|
|
45
|
+
* preview (`electron/lib/artifact-preview.js`): counting it would report a
|
|
46
|
+
* "node" the results table cannot show.
|
|
47
|
+
*/
|
|
48
|
+
export function parseFinalNode(link) {
|
|
33
49
|
const value = link.trim();
|
|
34
50
|
if (!value.startsWith('vmess://')) {
|
|
35
|
-
return
|
|
51
|
+
return null;
|
|
36
52
|
}
|
|
37
53
|
let encoded = value.slice('vmess://'.length);
|
|
38
54
|
encoded += '='.repeat((4 - (encoded.length % 4)) % 4);
|
|
39
55
|
try {
|
|
40
56
|
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
41
|
-
const
|
|
42
|
-
|
|
57
|
+
const name = String(payload.ps ?? '');
|
|
58
|
+
const match = name.trim().toUpperCase().match(LEADING_FLAG_COUNTRY_PATTERN);
|
|
59
|
+
const regionCode = match && isIsoAlpha2CountryCode(match[1]) ? match[1] : 'OTHER';
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
address: String(payload.add ?? ''),
|
|
63
|
+
protocol: 'vmess',
|
|
64
|
+
path: String(payload.path ?? ''),
|
|
65
|
+
link: value,
|
|
66
|
+
regionCode
|
|
67
|
+
};
|
|
43
68
|
}
|
|
44
69
|
catch {
|
|
45
|
-
return
|
|
70
|
+
return null;
|
|
46
71
|
}
|
|
47
72
|
}
|
|
48
|
-
function
|
|
73
|
+
function readFinalNodes(artifactDir) {
|
|
49
74
|
const nodeSource = FINAL_NODE_FILES.find((name) => fs.existsSync(path.join(artifactDir, name))) ?? '';
|
|
50
75
|
if (!nodeSource) {
|
|
51
|
-
return {
|
|
76
|
+
return { nodeSource, rows: [] };
|
|
52
77
|
}
|
|
53
78
|
const lines = fs.readFileSync(path.join(artifactDir, nodeSource), 'utf8').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
79
|
+
const rows = lines.map((line) => parseFinalNode(line)).filter((row) => row !== null);
|
|
80
|
+
return { nodeSource, rows };
|
|
81
|
+
}
|
|
82
|
+
function regionCounts(rows) {
|
|
54
83
|
const counts = new Map();
|
|
55
|
-
for (const
|
|
56
|
-
|
|
57
|
-
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
// The UI folds an undecorated name into US when it charts (buildRegionStats),
|
|
86
|
+
// so the summary must report the same number or the two disagree.
|
|
87
|
+
const regionCode = row.regionCode === 'OTHER' ? 'US' : row.regionCode;
|
|
88
|
+
counts.set(regionCode, (counts.get(regionCode) ?? 0) + 1);
|
|
58
89
|
}
|
|
90
|
+
return [...counts.entries()]
|
|
91
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
92
|
+
.map(([region_code, count]) => ({ region_code, count }));
|
|
93
|
+
}
|
|
94
|
+
function safeNodeCounts(rows, nodeSource) {
|
|
59
95
|
return {
|
|
60
96
|
node_source: nodeSource,
|
|
61
|
-
final_node_count:
|
|
62
|
-
regions:
|
|
97
|
+
final_node_count: rows.length,
|
|
98
|
+
regions: regionCounts(rows)
|
|
63
99
|
};
|
|
64
100
|
}
|
|
65
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Preview one artifact directory.
|
|
103
|
+
*
|
|
104
|
+
* `includeNodeRows` is what the serve runtime asks for: the results page and
|
|
105
|
+
* the 国家分布 donut read those rows, and answering `nodeRows: []` left a
|
|
106
|
+
* browser session at 0 nodes and an empty donut on a complete artifact. The
|
|
107
|
+
* CLI's `artifact preview` keeps them out of its JSON so a 400-node artifact
|
|
108
|
+
* does not print every link.
|
|
109
|
+
*/
|
|
110
|
+
export function previewArtifact(artifactDir, options = {}) {
|
|
66
111
|
const absolute = path.resolve(artifactDir);
|
|
67
112
|
const resolved = fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
|
|
68
113
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
69
114
|
return { ok: false, artifact_dir: resolved };
|
|
70
115
|
}
|
|
71
116
|
const report = loadJson(path.join(resolved, 'pipeline_report.json'));
|
|
117
|
+
const finalNodes = readFinalNodes(resolved);
|
|
72
118
|
return {
|
|
73
119
|
ok: true,
|
|
74
120
|
artifact_dir: resolved,
|
|
@@ -80,6 +126,7 @@ export function previewArtifact(artifactDir) {
|
|
|
80
126
|
retry_context: report.retry_context ?? {},
|
|
81
127
|
error: redactText(String(report.error ?? '')),
|
|
82
128
|
files: fileInventory(resolved),
|
|
83
|
-
safe_node_counts: safeNodeCounts(
|
|
129
|
+
safe_node_counts: safeNodeCounts(finalNodes.rows, finalNodes.nodeSource),
|
|
130
|
+
...(options.includeNodeRows ? { nodeRows: finalNodes.rows } : {})
|
|
84
131
|
};
|
|
85
132
|
}
|
package/dist/config/profile.js
CHANGED
|
@@ -57,7 +57,11 @@ const OFFICIAL_SOURCE_KEYS = new Set(['leiting', 'heidong', 'mifeng', 'xuanfeng'
|
|
|
57
57
|
const OBSOLETE_SOURCE_FIELDS = [
|
|
58
58
|
'url', 'key', 'account', 'password', 'platform',
|
|
59
59
|
'use_random_area', 'area_min', 'area_max',
|
|
60
|
-
'resume_from_iteration', 'failure_limit'
|
|
60
|
+
'resume_from_iteration', 'failure_limit',
|
|
61
|
+
// Internal: the extractor sets this when `backend_brand` redirects the data
|
|
62
|
+
// plane. It is derived state, so a hand-edited profile must not persist it —
|
|
63
|
+
// it would outlive the redirect that produced it.
|
|
64
|
+
'brand_override'
|
|
61
65
|
];
|
|
62
66
|
function positiveInteger(value, fallback) {
|
|
63
67
|
const parsed = Number(value);
|
|
@@ -87,15 +91,26 @@ export function normalizeOfficialSources(sources) {
|
|
|
87
91
|
mode: 'credential',
|
|
88
92
|
official_site: String(config.official_site ?? '').trim() || brandSite(brand),
|
|
89
93
|
enabled: config.enabled !== false,
|
|
90
|
-
// A round is
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// 50 consecutive rounds with no genuinely new node.
|
|
94
|
+
// A round is one cursor step over the fixed area list, not a full sweep
|
|
95
|
+
// of every region. 200 rounds × the default 8 areas / 50 with no new
|
|
96
|
+
// node matches the documented defaults.
|
|
94
97
|
max_iterations: wasCredential ? positiveInteger(config.max_iterations, 200) : 200,
|
|
95
98
|
plateau_limit: wasCredential ? positiveInteger(config.plateau_limit, 50) : 50,
|
|
96
99
|
areas_per_round: Math.min(64, positiveInteger(config.areas_per_round, 8)),
|
|
97
100
|
area_concurrency: Math.min(16, positiveInteger(config.area_concurrency, 4))
|
|
98
101
|
};
|
|
102
|
+
// Only a known vendor can serve as a data plane; an unknown name would
|
|
103
|
+
// silently fall back to the row's own brand and produce the very node
|
|
104
|
+
// shortage the field exists to fix.
|
|
105
|
+
if (config.backend_brand !== undefined) {
|
|
106
|
+
const backend = normalizeBrand(String(config.backend_brand).trim());
|
|
107
|
+
if (backend && backend !== brand) {
|
|
108
|
+
normalized.backend_brand = backend;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
delete normalized.backend_brand;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
99
114
|
if (!wasCredential) {
|
|
100
115
|
// A legacy URL row's caps describe a different operation; start the
|
|
101
116
|
// cursor flow at the documented scheduled-run default instead. A floor
|
|
@@ -9,6 +9,23 @@ import { BRANDS, brandFromSite, brandSite, normalizeBrand } from '../pool/brands
|
|
|
9
9
|
import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
|
|
10
10
|
import { resolveUserRuntimeRoot } from '../runtime/paths.js';
|
|
11
11
|
import { redactText } from '../runtime/redaction.js';
|
|
12
|
+
/**
|
|
13
|
+
* How many pooled accounts one source may try before it reports failure.
|
|
14
|
+
*
|
|
15
|
+
* A redirected row leases from a foreign vendor's list, and only a minority of
|
|
16
|
+
* those accounts exist on the backend: measured against the deployed pool, 2 of
|
|
17
|
+
* 40 黑洞 accounts log in on the 雷霆 backend. Each rejected attempt retires that
|
|
18
|
+
* account and leases the next by least-recent use, so the width decides whether
|
|
19
|
+
* the run ever reaches a usable credential. A row without a redirect keeps the
|
|
20
|
+
* tighter default.
|
|
21
|
+
*/
|
|
22
|
+
export function credentialAccountAttempts(source, brand) {
|
|
23
|
+
const backend = normalizeBrand(String(source.backend_brand ?? '').trim());
|
|
24
|
+
const redirected = Boolean(backend && brand && backend !== brand);
|
|
25
|
+
const fallback = redirected ? 24 : 3;
|
|
26
|
+
const parsed = Math.trunc(Number(source.account_retry_limit ?? fallback));
|
|
27
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
|
|
28
|
+
}
|
|
12
29
|
/**
|
|
13
30
|
* Each brand concatenates `getCommParams()` in its own order with its own
|
|
14
31
|
* literal block. The layout can be overridden from the profile, and is picked
|
|
@@ -28,7 +45,9 @@ export function resolveBrandLayout(source) {
|
|
|
28
45
|
return { afterPackage: explicit.startsWith('&') ? explicit : `&${explicit}` };
|
|
29
46
|
}
|
|
30
47
|
}
|
|
31
|
-
|
|
48
|
+
// A redirected data plane speaks the backend's parameter order, not the
|
|
49
|
+
// storefront's: the backend decides which flag block it recognises.
|
|
50
|
+
const brand = String(source.brand_override ?? source.brand ?? '').trim();
|
|
32
51
|
if (brand.includes('旋风') || /xuanfeng/i.test(brand)) {
|
|
33
52
|
return { vipFirst: true, viewport: true };
|
|
34
53
|
}
|
|
@@ -47,7 +66,7 @@ export function resolveBrandLayout(source) {
|
|
|
47
66
|
* downloader uses a Chinese product name, so both are mapped. The official
|
|
48
67
|
* host is the last resort when a profile uses a custom source name.
|
|
49
68
|
*/
|
|
50
|
-
const BRAND_APK_PREFIXES = {
|
|
69
|
+
export const BRAND_APK_PREFIXES = {
|
|
51
70
|
雷霆: 'lt',
|
|
52
71
|
旋风: 'xf',
|
|
53
72
|
黑洞: 'hd',
|
|
@@ -151,9 +170,9 @@ export function resolveRoundPolicy(source) {
|
|
|
151
170
|
};
|
|
152
171
|
}
|
|
153
172
|
/**
|
|
154
|
-
* A round is one cursor step over the fixed area list, not a full sweep
|
|
155
|
-
*
|
|
156
|
-
*
|
|
173
|
+
* A round is one cursor step over the fixed area list, not a full sweep.
|
|
174
|
+
* 200 rounds × the default 8 areas covers the whole list many times over, and
|
|
175
|
+
* the plateau stop ends a channel the moment the server has nothing new left.
|
|
157
176
|
*/
|
|
158
177
|
export const DEFAULT_MAX_ITERATIONS = 200;
|
|
159
178
|
export const DEFAULT_PLATEAU_LIMIT = 50;
|
|
@@ -490,6 +509,16 @@ function declaredSizeTooLarge(response) {
|
|
|
490
509
|
const length = Number(raw);
|
|
491
510
|
return Number.isFinite(length) && length > MAX_APK_BYTES;
|
|
492
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* Test seam: `defaultLoadApk` with the brand name spelled out.
|
|
514
|
+
*
|
|
515
|
+
* The archive prefix is chosen from this name, and a redirected row must pass
|
|
516
|
+
* its backend's name rather than the row's. Exported so a test can prove which
|
|
517
|
+
* name reaches the loader without standing up a whole extraction.
|
|
518
|
+
*/
|
|
519
|
+
export async function loadApkWithBrand(source, fetchImpl, timeoutMs, now, log, brandName, egress) {
|
|
520
|
+
return defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName, egress);
|
|
521
|
+
}
|
|
493
522
|
async function defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName = '', egress) {
|
|
494
523
|
if (source.apk_path) {
|
|
495
524
|
try {
|
|
@@ -805,7 +834,9 @@ export async function fetchCredentialSourceLinks(input, options = {}) {
|
|
|
805
834
|
}
|
|
806
835
|
}
|
|
807
836
|
async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
808
|
-
|
|
837
|
+
// A copy: a redirected data plane rewrites the site and the layout marker,
|
|
838
|
+
// and the caller's config object must not carry that into later stages.
|
|
839
|
+
const source = { ...input.source };
|
|
809
840
|
const platforms = resolveCredentialPlatforms(source);
|
|
810
841
|
const policy = resolveRoundPolicy(source);
|
|
811
842
|
const errors = [];
|
|
@@ -845,6 +876,29 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
845
876
|
const brand = normalizeBrand(input.source_name)
|
|
846
877
|
?? brandFromSite(source.official_site)
|
|
847
878
|
?? normalizeBrand(credentialBrandKey(input.source_name, source));
|
|
879
|
+
// The account is always leased as the row's own brand; only the data plane
|
|
880
|
+
// can be redirected. See `backend_brand` for why 黑洞 needs this.
|
|
881
|
+
const backendBrand = normalizeBrand(String(source.backend_brand ?? '').trim()) ?? brand;
|
|
882
|
+
if (backendBrand && brand && backendBrand !== brand) {
|
|
883
|
+
const redirected = brandSite(backendBrand);
|
|
884
|
+
if (redirected) {
|
|
885
|
+
// A pinned `apk_path` / `apk_url` is the operator's explicit choice of
|
|
886
|
+
// build and stays as given: it may already be the backend's own APK, and
|
|
887
|
+
// discarding it turned a working pinned profile into "APK could not be
|
|
888
|
+
// located". Only the site moves.
|
|
889
|
+
if (!source.apk_path && !source.apk_url) {
|
|
890
|
+
source.official_site = redirected;
|
|
891
|
+
}
|
|
892
|
+
// The layout is picked from `brand` at every call site; carrying the row
|
|
893
|
+
// name through would keep sending the storefront's own flag block.
|
|
894
|
+
source.brand_override = backendBrand;
|
|
895
|
+
}
|
|
896
|
+
// Informational, not an error: the row is expected to work this way.
|
|
897
|
+
diagnostics.backend_brand = backendBrand;
|
|
898
|
+
event(options, 'log', {
|
|
899
|
+
message: `[extract][${input.source_name}] data plane ${brand} -> ${backendBrand}`
|
|
900
|
+
});
|
|
901
|
+
}
|
|
848
902
|
let store = options.accountStore ?? null;
|
|
849
903
|
let ownsStore = false;
|
|
850
904
|
let leased = null;
|
|
@@ -879,13 +933,25 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
879
933
|
});
|
|
880
934
|
return { ...empty, failed_iterations: 1 };
|
|
881
935
|
}
|
|
882
|
-
|
|
936
|
+
// Keyed by the data plane, not the storefront: a redirected row downloads and
|
|
937
|
+
// inspects the backend's APK, so it must share that brand's cached entry.
|
|
938
|
+
//
|
|
939
|
+
// A caller-supplied memo cannot be trusted here. The orchestrator builds one
|
|
940
|
+
// per row name before the redirect exists, so `options.memo` for a `heidong`
|
|
941
|
+
// row is seeded from 黑洞's entry while every read and write below goes to the
|
|
942
|
+
// 雷霆 one. That pairing sends the 黑洞 APK's package and seeds to the 雷霆
|
|
943
|
+
// backend on a cached run, and it hides the 雷霆 entry the next run should
|
|
944
|
+
// reuse. A redirect therefore always gets a memo of its own key; the shared
|
|
945
|
+
// one is used only when the two brands agree.
|
|
946
|
+
const memoBrandKey = source.brand_override ?? credentialBrandKey(input.source_name, source);
|
|
947
|
+
const memo = source.brand_override
|
|
948
|
+
? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey)
|
|
949
|
+
: (options.memo ?? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey));
|
|
883
950
|
let linkQueue = Promise.resolve();
|
|
884
951
|
// A non-numeric retry limit (a hand-edited `account_retry_limit = "three"`)
|
|
885
952
|
// must not turn the attempt loop into zero passes, which reported the source
|
|
886
953
|
// as successful with no links and no error.
|
|
887
|
-
const
|
|
888
|
-
const attempts = explicitAccount && explicitPassword ? 1 : (Number.isFinite(retryLimit) && retryLimit >= 1 ? retryLimit : 3);
|
|
954
|
+
const attempts = explicitAccount && explicitPassword ? 1 : credentialAccountAttempts(source, brand);
|
|
889
955
|
let results = [];
|
|
890
956
|
let callbackFailure = null;
|
|
891
957
|
// The dashboard reads per-source totals from one `extract_iteration` stream.
|
|
@@ -1006,13 +1072,35 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
|
|
|
1006
1072
|
if (lapsed) {
|
|
1007
1073
|
store.markExpired(leased.id, 'membership expired');
|
|
1008
1074
|
}
|
|
1009
|
-
else if (level !== undefined && level < 2 && !linked) {
|
|
1075
|
+
else if (level !== undefined && level < 2 && !linked && !source.brand_override) {
|
|
1010
1076
|
// A free-tier account (level 0/1) only ever produces placeholders.
|
|
1011
1077
|
// Retire it, but only when it actually produced nothing: a paid
|
|
1012
1078
|
// account misreported as level 1 must not lose its row.
|
|
1079
|
+
//
|
|
1080
|
+
// A redirected row is exempt: the level it reports is the *row*
|
|
1081
|
+
// brand's backend grading it, not the backend it now reads, so a
|
|
1082
|
+
// low level there says nothing about whether the account works.
|
|
1083
|
+
// Retiring on it would discard exactly the accounts the rotation
|
|
1084
|
+
// below is trying to find.
|
|
1013
1085
|
store.markInvalid(leased.id, 'no paid membership');
|
|
1014
1086
|
}
|
|
1015
1087
|
}
|
|
1088
|
+
// A login is not proof the account is usable. On a backend that gates
|
|
1089
|
+
// routes by membership, a free-tier account logs in fine and then sees
|
|
1090
|
+
// every paid row disabled, so the vendor filter leaves nothing to ask.
|
|
1091
|
+
// Publishing that as a finished source was the bug; rotate instead.
|
|
1092
|
+
// The account itself is valid, so it is not retired — only skipped.
|
|
1093
|
+
const producedRoutes = results.some((result) => result.diagnostics.route_count > 0);
|
|
1094
|
+
const producedLinks = results.some((result) => result.links.length > 0);
|
|
1095
|
+
if (!producedRoutes && !producedLinks && store && leased && attempt < attempts && brand) {
|
|
1096
|
+
const next = store.lease(brand);
|
|
1097
|
+
if (next) {
|
|
1098
|
+
leased = next;
|
|
1099
|
+
account = next.account;
|
|
1100
|
+
password = next.password;
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1016
1104
|
break;
|
|
1017
1105
|
}
|
|
1018
1106
|
const membershipExpired = results.length > 0
|
|
@@ -1203,9 +1291,15 @@ async function runCredentialPlatform(input, options) {
|
|
|
1203
1291
|
// These only change when the vendor publishes a new build, so a cached entry
|
|
1204
1292
|
// is used first and the APK is inspected only when the cached seeds all stop
|
|
1205
1293
|
// answering. That keeps a normal run to a handful of HTTP calls.
|
|
1206
|
-
|
|
1294
|
+
// Keyed by the data plane, not the storefront: a redirected row downloads and
|
|
1295
|
+
// inspects the backend's APK, so it must share that brand's cached entry.
|
|
1296
|
+
const brandKey = source.brand_override ?? credentialBrandKey(input.source_name, source);
|
|
1207
1297
|
const store = options.fingerprintStore ?? null;
|
|
1208
|
-
|
|
1298
|
+
// Same rule as the outer memo: a caller-supplied memo is keyed by the row
|
|
1299
|
+
// name, so a redirected row must not read or write through it.
|
|
1300
|
+
const memo = source.brand_override
|
|
1301
|
+
? createFingerprintMemo(store, brandKey)
|
|
1302
|
+
: (options.memo ?? createFingerprintMemo(store, brandKey));
|
|
1209
1303
|
// A fingerprint past its trust window is still used when its seeds answer,
|
|
1210
1304
|
// but it is re-verified against the APK rather than trusted indefinitely: a
|
|
1211
1305
|
// vendor can rotate the signing certificate while the old seeds still serve
|
|
@@ -1228,7 +1322,13 @@ async function runCredentialPlatform(input, options) {
|
|
|
1228
1322
|
try {
|
|
1229
1323
|
apkBytes = options.loadApk
|
|
1230
1324
|
? await options.loadApk(source)
|
|
1231
|
-
: await defaultLoadApk(source, fetchImpl, timeoutMs, now, log,
|
|
1325
|
+
: await defaultLoadApk(source, fetchImpl, timeoutMs, now, log,
|
|
1326
|
+
// The archive prefix names the *backend's* build (`_lt`, not `_hd`):
|
|
1327
|
+
// a redirected row downloads from the backend's CDN, which publishes
|
|
1328
|
+
// only the backend's own archives. Passing the row name here made
|
|
1329
|
+
// every candidate `_hd<N>.apk` against a 雷霆 host, so all lookback
|
|
1330
|
+
// slots missed and the run failed with "APK could not be located".
|
|
1331
|
+
source.brand_override ?? input.source_name, egress);
|
|
1232
1332
|
}
|
|
1233
1333
|
catch (error) {
|
|
1234
1334
|
errors.push(`apk: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1348,7 +1448,7 @@ async function runCredentialPlatform(input, options) {
|
|
|
1348
1448
|
platform,
|
|
1349
1449
|
timestampMs: ts,
|
|
1350
1450
|
code: ''
|
|
1351
|
-
}, resolveBrandLayout({ ...source, brand: input.source_name })), keys, timeoutMs, egress);
|
|
1451
|
+
}, resolveBrandLayout({ ...source, brand: source.brand_override ?? input.source_name })), keys, timeoutMs, egress);
|
|
1352
1452
|
substage('domain_discovery', resolved ? 'success' : 'failed');
|
|
1353
1453
|
return resolved;
|
|
1354
1454
|
}
|
|
@@ -1359,7 +1459,7 @@ async function runCredentialPlatform(input, options) {
|
|
|
1359
1459
|
identity,
|
|
1360
1460
|
device,
|
|
1361
1461
|
platform,
|
|
1362
|
-
layout: resolveBrandLayout({ ...source, brand: input.source_name }),
|
|
1462
|
+
layout: resolveBrandLayout({ ...source, brand: source.brand_override ?? input.source_name }),
|
|
1363
1463
|
token: '',
|
|
1364
1464
|
// Before login the app's local KV has no `code`, so the request carries an
|
|
1365
1465
|
// empty one; `&code=` only holds a value once the account has one.
|
package/dist/server/runtime.js
CHANGED
|
@@ -66,7 +66,7 @@ function normalizeLatestArtifact(projectRoot, env) {
|
|
|
66
66
|
if (!latest.ok || !latest.artifact_dir) {
|
|
67
67
|
return undefined;
|
|
68
68
|
}
|
|
69
|
-
const preview = previewArtifact(String(latest.artifact_dir));
|
|
69
|
+
const preview = previewArtifact(String(latest.artifact_dir), { includeNodeRows: true });
|
|
70
70
|
const progress = readRunProgress(path.join(String(latest.artifact_dir), 'run.db'));
|
|
71
71
|
return {
|
|
72
72
|
...latest,
|
|
@@ -75,7 +75,10 @@ function normalizeLatestArtifact(projectRoot, env) {
|
|
|
75
75
|
// replace the authoritative totals that already exist in the run database.
|
|
76
76
|
...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: progress.source_counts, progress_snapshot: progress } : {}),
|
|
77
77
|
outputFiles: (preview.files ?? []),
|
|
78
|
-
|
|
78
|
+
// The results page and the country donut read these rows. Answering with an
|
|
79
|
+
// empty array left a browser session at 0 nodes and an empty 国家分布 while
|
|
80
|
+
// the artifact held the full final list.
|
|
81
|
+
nodeRows: (preview.nodeRows ?? [])
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
function parseJsonLine(line) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { buildStageModel, PAGE_INDEX, PAGE_ORDER, resolveRunControlState } from './state.js';
|
|
2
2
|
import { icon } from './icons.js';
|
|
3
3
|
|
|
4
|
-
// Defaults for a source row that has never stored a value. A round
|
|
5
|
-
// cursor
|
|
6
|
-
// count is per platform and per
|
|
4
|
+
// Defaults for a source row that has never stored a value. A round advances a
|
|
5
|
+
// cursor over that brand's fixed area list and pulls `areas_per_round` ids, so
|
|
6
|
+
// the count is per platform and per round: 4/2 would fetch only a handful of
|
|
7
|
+
// nodes.
|
|
7
8
|
export const SOURCE_ROUND_DEFAULTS = {
|
|
8
9
|
max_iterations: 200,
|
|
9
10
|
plateau_limit: 50,
|
|
@@ -929,7 +930,7 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
|
|
|
929
930
|
<div class="source-credential-rounds">
|
|
930
931
|
<label class="source-field">
|
|
931
932
|
<span>轮数</span>
|
|
932
|
-
<input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="Android 与 iOS 各自独立跑满该轮数(1000 = 两端各 1000
|
|
933
|
+
<input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="Android 与 iOS 各自独立跑满该轮数(1000 = 两端各 1000 轮);每轮是固定区域列表上的一个游标步,按「每轮区域数」取区域" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_iterations" value="${escapeHtml(source.max_iterations ?? SOURCE_ROUND_DEFAULTS.max_iterations)}" />
|
|
933
934
|
</label>
|
|
934
935
|
<label class="source-field">
|
|
935
936
|
<span>早停</span>
|