@swimmingliu/autovpn 2.0.2 → 2.1.0

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.
@@ -172,6 +172,18 @@ export function renderWebAdapterScript(options = {}) {
172
172
  method: 'POST',
173
173
  body: JSON.stringify(payload || {})
174
174
  }),
175
+ // The H5 build reaches the same expiry editor the Electron preload
176
+ // exposes; without this the browser UI hides the affordance entirely.
177
+ // Accepts both shapes the renderer may use: (id, date) like the preload
178
+ // bridge, or ({id, expires_at}).
179
+ setAccountExpiry: async (idOrPayload, date) => request('/api/accounts/expiry', {
180
+ method: 'POST',
181
+ body: JSON.stringify(
182
+ idOrPayload && typeof idOrPayload === 'object'
183
+ ? idOrPayload
184
+ : { id: idOrPayload, expires_at: date }
185
+ )
186
+ }),
175
187
  accountsTemplate: async () => ({ ok: true, content: ${JSON.stringify(accountsTemplate())} }),
176
188
  runPipeline: async (options = {}) => request('/api/runs', {
177
189
  method: 'POST',
@@ -9,8 +9,10 @@ import { renderAnalytics, clearAnalytics } from './analytics.bundle.js';
9
9
  import {
10
10
  addAvailabilityTargetDraft,
11
11
  applyAvailabilityTargetDraft,
12
+ applyNetworkDraft,
12
13
  applySourceIterationDraft,
13
14
  buildAvailabilityTargetDraft,
15
+ buildNetworkDraft,
14
16
  buildPageMarkup,
15
17
  buildDashboardMetricsMarkup,
16
18
  buildDashboardStateMarkup,
@@ -25,6 +27,7 @@ import {
25
27
  buildAccountPoolRecords,
26
28
  removeAvailabilityTargetDraft,
27
29
  resolveDashboardRunSummary,
30
+ SOURCE_ROUND_DEFAULTS,
28
31
  validateSourceDraft,
29
32
  classifyLogEntry,
30
33
  escapeHtml,
@@ -36,10 +39,10 @@ initializeAppearance();
36
39
 
37
40
  const demoProfile = {
38
41
  sources: {
39
- leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
40
- heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
41
- mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
42
- xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, max_iterations: 4, plateau_limit: 2 }
42
+ leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
43
+ heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
44
+ mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
45
+ xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, ...SOURCE_ROUND_DEFAULTS }
43
46
  },
44
47
  speed_test: {
45
48
  min_download_mb_s: 1.0,
@@ -114,7 +117,9 @@ const state = {
114
117
  statusFilter: 'all',
115
118
  brandFilter: 'all',
116
119
  sortBy: 'brand',
117
- busy: false
120
+ busy: false,
121
+ canSetExpiry: typeof window.vpnAutomation?.setAccountExpiry === 'function',
122
+ expiryEditorId: null
118
123
  },
119
124
  isDemo: false,
120
125
  runtime: document.body.dataset.runtime === 'web' ? 'web' : 'electron',
@@ -138,7 +143,8 @@ const state = {
138
143
  qr: createQrState(),
139
144
  runStartedAt: null,
140
145
  lastUpdateAt: null,
141
- modalTransform: ''
146
+ modalTransform: '',
147
+ progressSnapshot: null
142
148
  };
143
149
 
144
150
  let settingsDrawerOpener = null;
@@ -197,7 +203,10 @@ function hydrateInitialRuntimeState(loadedState = {}) {
197
203
  const loadedProfile = loadedState.profile ?? {};
198
204
  state.activity = Array.isArray(loadedState.activity) ? loadedState.activity : [];
199
205
  state.activityUnavailable = Number(loadedState.activityUnavailable ?? 0);
200
- state.profile = loadedProfile;
206
+ // An older profile has no [network] section. Seeding the displayed defaults
207
+ // here means saving any other drawer writes the section the pipeline reads
208
+ // instead of leaving it absent, and the network drawer opens on real values.
209
+ state.profile = { ...loadedProfile, network: buildNetworkDraft(loadedProfile.network) };
201
210
  state.savedProfile = structuredClone(loadedProfile);
202
211
  const nextRunState = String(loadedState.runState ?? '');
203
212
  if (['idle', 'running', 'stopping', 'failed', 'success'].includes(nextRunState)) {
@@ -222,11 +231,13 @@ function hydrateHistoricalEvents(events = []) {
222
231
  }
223
232
  const previousRunState = state.runState;
224
233
  const previousRunResult = state.runResult;
234
+ const snapshot = state.progressSnapshot;
225
235
  for (const event of events) {
226
236
  handlePipelineEvent(event, { historical: true });
227
237
  }
228
238
  state.runState = previousRunState;
229
239
  state.runResult = previousRunState === 'idle' ? previousRunResult : previousRunState;
240
+ if (snapshot) applyProgressSnapshot(snapshot);
230
241
  }
231
242
 
232
243
  function bindActions() {
@@ -639,6 +650,10 @@ function handleDocumentInput(event) {
639
650
  const floor = SOURCE_MINIMUM_ONE_KEYS.has(key) ? 1 : 0;
640
651
  sourceDraft[sourceName][key] = Math.max(floor, Math.trunc(parsed));
641
652
  }
653
+ // The area controls have a hard ceiling the backend also clamps to.
654
+ if (SOURCE_MAXIMUM_KEYS.has(key)) {
655
+ sourceDraft[sourceName][key] = Math.min(SOURCE_MAXIMUM_KEYS.get(key), sourceDraft[sourceName][key]);
656
+ }
642
657
  }
643
658
  return;
644
659
  }
@@ -790,6 +805,8 @@ const SOURCE_NUMERIC_KEYS = new Set([
790
805
  'max_iterations',
791
806
  'min_iterations',
792
807
  'plateau_limit',
808
+ 'areas_per_round',
809
+ 'area_concurrency',
793
810
  'max_runtime_seconds',
794
811
  'max_areas',
795
812
  'area_min',
@@ -799,7 +816,15 @@ const SOURCE_NUMERIC_KEYS = new Set([
799
816
  /** Of those, the ones the runner clamps to at least one. */
800
817
  const SOURCE_MINIMUM_ONE_KEYS = new Set([
801
818
  'max_iterations',
802
- 'plateau_limit'
819
+ 'plateau_limit',
820
+ 'areas_per_round',
821
+ 'area_concurrency'
822
+ ]);
823
+
824
+ /** Numeric source fields with a hard upper bound, matching the backend clamp. */
825
+ const SOURCE_MAXIMUM_KEYS = new Map([
826
+ ['areas_per_round', 64],
827
+ ['area_concurrency', 16]
803
828
  ]);
804
829
 
805
830
  function resolveErrorMessage(error) {
@@ -1034,6 +1059,7 @@ function buildSettingsDraft(section) {
1034
1059
  if (section === 'speed_test') return buildSpeedTestDraft(state.profile.speed_test);
1035
1060
  if (section === 'availability_targets') return buildAvailabilityTargetDraft(state.profile.availability_targets);
1036
1061
  if (section === 'deploy') return buildDeployDraft(state.profile.deploy);
1062
+ if (section === 'network') return buildNetworkDraft(state.profile.network);
1037
1063
  if (section === 'paths') return structuredClone(state.profile.paths);
1038
1064
  if (section === 'about') return { version: getMessages(state.language).sidebarVersion };
1039
1065
  return null;
@@ -1088,6 +1114,9 @@ async function saveSettingsDrawer() {
1088
1114
 
1089
1115
  async function hydrateAccountPool() {
1090
1116
  state.accountPool.status = 'loading';
1117
+ // Re-detect each hydration: the preload bridge is fixed for the page, but a
1118
+ // test harness or a future H5 adapter may attach it after bootstrap.
1119
+ state.accountPool.canSetExpiry = typeof window.vpnAutomation?.setAccountExpiry === 'function';
1091
1120
  touchUpdate();
1092
1121
  renderAccountPoolPanelOnly();
1093
1122
  if (!window.vpnAutomation?.loadAccounts) {
@@ -1151,9 +1180,56 @@ async function handleAccountPoolAction(button) {
1151
1180
  }
1152
1181
  if (action === 'toggle') {
1153
1182
  await toggleAccountStatus(Number(button.dataset.accountId));
1183
+ return;
1184
+ }
1185
+ if (action === 'expiry-edit') {
1186
+ state.accountPool.expiryEditorId = Number(button.dataset.accountId);
1187
+ renderAll();
1188
+ queueMicrotask(() => document.querySelector(`[data-account-expiry-input="${CSS.escape(String(button.dataset.accountId))}"]`)?.focus());
1189
+ return;
1190
+ }
1191
+ if (action === 'expiry-cancel') {
1192
+ state.accountPool.expiryEditorId = null;
1193
+ renderAll();
1194
+ return;
1195
+ }
1196
+ if (action === 'expiry-save') {
1197
+ const id = Number(button.dataset.accountId);
1198
+ const input = document.querySelector(`[data-account-expiry-input="${CSS.escape(String(id))}"]`);
1199
+ await setAccountExpiry(id, input?.value ?? '');
1154
1200
  }
1155
1201
  }
1156
1202
 
1203
+ async function setAccountExpiry(id, expiresAt) {
1204
+ const account = (state.accountPool.accounts ?? []).find((item) => item.id === id);
1205
+ // Feature-detected: the browser build has no preload bridge, so this path is
1206
+ // never reachable there and the row hides the affordance.
1207
+ if (!account || typeof window.vpnAutomation?.setAccountExpiry !== 'function') {
1208
+ return;
1209
+ }
1210
+ const value = String(expiresAt ?? '').trim();
1211
+ if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1212
+ showToast({ tone: 'danger', message: '请选择有效的到期日期' });
1213
+ return;
1214
+ }
1215
+ state.accountPool.busy = true;
1216
+ renderAccountPoolPanelOnly();
1217
+ try {
1218
+ // Preload bridge signature: setAccountExpiry(id, 'YYYY-MM-DD'); '' clears it.
1219
+ const result = await window.vpnAutomation.setAccountExpiry(id, value);
1220
+ if (!result?.ok) {
1221
+ throw new Error(result?.error || '操作失败');
1222
+ }
1223
+ state.accountPool.expiryEditorId = null;
1224
+ showToast({ tone: 'success', message: value ? `${account.account_masked} 到期日已设为 ${value}` : `${account.account_masked} 到期日已清除` });
1225
+ } catch (error) {
1226
+ showToast({ tone: 'danger', message: resolveErrorMessage(error) });
1227
+ } finally {
1228
+ state.accountPool.busy = false;
1229
+ }
1230
+ await hydrateAccountPool();
1231
+ }
1232
+
1157
1233
  async function downloadAccountsTemplate() {
1158
1234
  try {
1159
1235
  let content = '';
@@ -1288,6 +1364,9 @@ function resolveSettingsDraftPayload(section, draft) {
1288
1364
  if (section === 'deploy') {
1289
1365
  return sanitizeDeployDraft(draft);
1290
1366
  }
1367
+ if (section === 'network') {
1368
+ return applyNetworkDraft(draft);
1369
+ }
1291
1370
  return structuredClone(draft);
1292
1371
  }
1293
1372
 
@@ -1554,7 +1633,24 @@ function finishRun(result = {}) {
1554
1633
  void hydrateRetryArtifacts();
1555
1634
  }
1556
1635
 
1636
+ function applyProgressSnapshot(snapshot) {
1637
+ if (!snapshot?.counts || !snapshot.source_counts) return;
1638
+ state.progressSnapshot = snapshot;
1639
+ state.counts = normalizeCounts({ ...state.counts, ...snapshot.counts });
1640
+ state.sourceCounts = normalizeSourceCounts(snapshot.source_counts);
1641
+ }
1642
+
1557
1643
  function handlePipelineEvent(event, options = {}) {
1644
+ handlePipelineEventDetails(event, options);
1645
+ if (event.progress_snapshot && !options.historical
1646
+ && (!event.progress_snapshot.artifact_dir || event.progress_snapshot.artifact_dir === state.artifactDir)) {
1647
+ applyProgressSnapshot(event.progress_snapshot);
1648
+ touchUpdate();
1649
+ renderRuntimeOnly({ chrome: false });
1650
+ }
1651
+ }
1652
+
1653
+ function handlePipelineEventDetails(event, options = {}) {
1558
1654
  const historical = Boolean(options.historical);
1559
1655
  if (event.type === 'server_state') {
1560
1656
  const nextRunState = String(event.run_state ?? '');
@@ -1673,6 +1769,24 @@ function handlePipelineEvent(event, options = {}) {
1673
1769
  return;
1674
1770
  }
1675
1771
 
1772
+ if (event.type === 'extract_platform_started') {
1773
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}开始提取,最多 ${event.requested_iterations ?? 0} 次,最少 ${event.min_iterations ?? 0} 次`, {
1774
+ kind: 'stage',
1775
+ stage: 'extract',
1776
+ level: 'info'
1777
+ });
1778
+ return;
1779
+ }
1780
+
1781
+ if (event.type === 'extract_platform_completed') {
1782
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}完成,成功 ${event.successful_iterations ?? 0} 次,失败 ${event.failed_iterations ?? 0} 次,原始节点 ${event.raw_links ?? 0} 个`, {
1783
+ kind: 'stage',
1784
+ stage: 'extract',
1785
+ level: 'info'
1786
+ });
1787
+ return;
1788
+ }
1789
+
1676
1790
  if (event.type === 'extract_source_started') {
1677
1791
  appendLog(`[extract] ${event.source_name} 开始提取,最多 ${event.requested_iterations ?? 0} 次,最少 ${event.min_iterations ?? 0} 次`, {
1678
1792
  kind: 'stage',
@@ -1685,7 +1799,7 @@ function handlePipelineEvent(event, options = {}) {
1685
1799
  if (event.type === 'extract_request_result') {
1686
1800
  const status = event.success ? '成功' : '失败';
1687
1801
  const retry = event.will_retry ? ',将重试' : '';
1688
- appendLog(`[extract] ${event.source_name} #${event.iteration} ${event.via} ${status}${retry}`, {
1802
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}#${event.iteration} ${event.via} ${status}${retry}`, {
1689
1803
  kind: 'stage',
1690
1804
  stage: 'extract',
1691
1805
  level: event.success ? 'info' : 'warning'
@@ -1694,7 +1808,7 @@ function handlePipelineEvent(event, options = {}) {
1694
1808
  }
1695
1809
 
1696
1810
  if (event.type === 'extract_decrypt_result' && !event.success) {
1697
- appendLog(`[extract] ${event.source_name} #${event.iteration} 解密失败`, {
1811
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}#${event.iteration} 解密失败`, {
1698
1812
  kind: 'stage',
1699
1813
  stage: 'extract',
1700
1814
  level: 'error'
@@ -1703,7 +1817,7 @@ function handlePipelineEvent(event, options = {}) {
1703
1817
  }
1704
1818
 
1705
1819
  if (event.type === 'extract_source_completed') {
1706
- updateExtractMetrics({ source_name: event.source_name, total_links: event.raw_links });
1820
+ if (!event.progress_snapshot && !(historical && state.progressSnapshot)) updateExtractMetrics({ source_name: event.source_name, total_links: event.raw_links });
1707
1821
  appendLog(`[extract] ${event.source_name} 完成,成功 ${event.successful_iterations ?? 0} 次,失败 ${event.failed_iterations ?? 0} 次,原始节点 ${event.raw_links ?? 0} 个`, {
1708
1822
  kind: 'stage',
1709
1823
  stage: 'extract',
@@ -1713,8 +1827,11 @@ function handlePipelineEvent(event, options = {}) {
1713
1827
  }
1714
1828
 
1715
1829
  if (event.type === 'extract_iteration') {
1716
- updateExtractMetrics(event);
1717
- appendLog(`[extract] ${event.source_name} #${event.iteration ?? 0} 新增 ${event.new_items ?? 0} 个,本次解析 ${event.extracted_links ?? 0} 个,累计 ${event.total_links ?? 0} 个`, {
1830
+ if (!event.progress_snapshot && !(historical && state.progressSnapshot)) updateExtractMetrics(event);
1831
+ // Android and iOS run the same round numbers independently, so a bare `#N`
1832
+ // shows two identical lines. The tag only appears when the event carries a
1833
+ // platform, which keeps older backends and the URL-mode stream unchanged.
1834
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}#${event.iteration ?? 0} 新增 ${event.new_items ?? 0} 个,本次解析 ${event.extracted_links ?? 0} 个,累计 ${event.total_links ?? 0} 个`, {
1718
1835
  kind: 'stage',
1719
1836
  stage: 'extract',
1720
1837
  level: 'info'
@@ -1723,6 +1840,7 @@ function handlePipelineEvent(event, options = {}) {
1723
1840
  }
1724
1841
 
1725
1842
  if (event.type === 'speedtest_result') {
1843
+ if (event.progress_snapshot || (historical && state.progressSnapshot)) return;
1726
1844
  if (event.passed_threshold) {
1727
1845
  state.counts.speedtest_links = Number(state.counts.speedtest_links ?? 0) + 1;
1728
1846
  }
@@ -1732,6 +1850,7 @@ function handlePipelineEvent(event, options = {}) {
1732
1850
  }
1733
1851
 
1734
1852
  if (event.type === 'availability_link_result') {
1853
+ if (event.progress_snapshot || (historical && state.progressSnapshot)) return;
1735
1854
  if (event.all_passed) {
1736
1855
  state.counts.availability_links = Number(state.counts.availability_links ?? 0) + 1;
1737
1856
  }
@@ -1770,6 +1889,7 @@ function normalizeStageStatuses(stageStatuses = {}) {
1770
1889
  }
1771
1890
 
1772
1891
  function resetRunScopedProgress() {
1892
+ state.progressSnapshot = null;
1773
1893
  state.stageStatus = {};
1774
1894
  state.counts = {};
1775
1895
  state.sourceCounts = {};
@@ -1875,6 +1995,8 @@ function hydrateArtifactState(result) {
1875
1995
  state.artifactDir = result.artifact_dir;
1876
1996
  state.counts = normalizeCounts(result.counts ?? {});
1877
1997
  state.sourceCounts = normalizeSourceCounts(result.source_counts ?? {});
1998
+ // Bounded log history cannot reconstruct metrics that predate its tail.
1999
+ state.progressSnapshot = result.progress_snapshot ?? null;
1878
2000
  state.extractDedupedFingerprints = new Set();
1879
2001
  state.outputFiles = result.outputFiles ?? [];
1880
2002
  state.nodeRows = result.nodeRows ?? [];
@@ -1951,6 +2073,14 @@ function resolveDefaultRetryStage(artifact) {
1951
2073
  return failedStage || retryableStages.at(-1) || '';
1952
2074
  }
1953
2075
 
2076
+ // Both channels emit the same round numbers, so the log needs the channel on
2077
+ // the line. `[android] ` keeps the rest of the format untouched; an event with
2078
+ // no platform (older backend, URL-mode extract) renders exactly as before.
2079
+ function platformTag(platform) {
2080
+ const value = String(platform ?? '').trim().toLowerCase();
2081
+ return value === 'android' || value === 'ios' ? `[${value}] ` : '';
2082
+ }
2083
+
1954
2084
  function appendLog(message, overrides = {}) {
1955
2085
  const logCenter = document.querySelector('#logCenterTable');
1956
2086
  if (logCenter) {
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.2.0.2',
8
+ sidebarVersion: 'v.2.1.0',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
@@ -1390,6 +1390,8 @@ body[data-page="logs"] #logCenterTable {
1390
1390
 
1391
1391
  .source-credential-rounds {
1392
1392
  display: grid;
1393
+ /* Four controls in a 232px column: two per line, so the labels stay readable
1394
+ next to a 36px number input. */
1393
1395
  grid-template-columns: repeat(2, minmax(72px, 1fr));
1394
1396
  gap: 8px;
1395
1397
  }
@@ -1478,7 +1480,7 @@ body[data-page="logs"] #logCenterTable {
1478
1480
  }
1479
1481
 
1480
1482
  .settings-col-rounds {
1481
- width: 196px;
1483
+ width: 232px;
1482
1484
  }
1483
1485
 
1484
1486
  .availability-col-url {
@@ -1704,13 +1706,40 @@ body[data-page="logs"] #logCenterTable {
1704
1706
  }
1705
1707
 
1706
1708
  .account-col-expiry {
1707
- width: 110px;
1709
+ width: 178px;
1708
1710
  }
1709
1711
 
1710
1712
  .account-col-usage {
1711
1713
  width: 132px;
1712
1714
  }
1713
1715
 
1716
+ .account-expiry-value {
1717
+ display: block;
1718
+ font-size: 13px;
1719
+ }
1720
+
1721
+ .account-expiry-value.unknown {
1722
+ color: var(--text-soft);
1723
+ }
1724
+
1725
+ .account-expiry-edit {
1726
+ margin-top: 4px;
1727
+ }
1728
+
1729
+ .account-expiry-editor {
1730
+ display: flex;
1731
+ flex-wrap: wrap;
1732
+ align-items: center;
1733
+ gap: 6px;
1734
+ margin-top: 6px;
1735
+ }
1736
+
1737
+ .account-expiry-editor input {
1738
+ min-width: 132px;
1739
+ padding-inline: 8px;
1740
+ font-size: 12px;
1741
+ }
1742
+
1714
1743
  .account-col-actions {
1715
1744
  width: 92px;
1716
1745
  }
@@ -2841,6 +2870,12 @@ body[data-page="logs"] #logCenterTable {
2841
2870
  min-height: 44px;
2842
2871
  }
2843
2872
 
2873
+ /* The expiry editor sits in that same action cell at phone widths, so its
2874
+ date input has to meet the same tap-target floor. */
2875
+ .account-expiry-editor input {
2876
+ min-height: 44px;
2877
+ }
2878
+
2844
2879
  .app-frame {
2845
2880
  display: block;
2846
2881
  height: auto;
@@ -8,8 +8,8 @@ const TABLES = {
8
8
  ],
9
9
  accounts: [
10
10
  ['account', '账号', 240, 160], ['brand', '加速器', 110, 90],
11
- ['status', '状态', 160, 120], ['expiry', '到期', 150, 120],
12
- ['usage', '使用', 180, 140], ['actions', '操作', 96, 80]
11
+ ['status', '状态', 150, 120], ['expiry', '到期', 180, 130],
12
+ ['usage', '使用', 150, 120], ['actions', '操作', 96, 80]
13
13
  ]
14
14
  };
15
15
  const initialized = new WeakSet();