poi-plugin-kai-planner 1.0.19 → 1.0.20

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.
package/README.md CHANGED
@@ -6,10 +6,10 @@
6
6
 
7
7
  - 在`每日改修`页面查看当天可改修装备、基础消耗、秘书舰要求与可进化状态。
8
8
  - 在`每日改修`页面把常看的装备标记为收藏,收藏项会优先排序并长期保存在本地。
9
- - 在`改修心愿清单`页面创建、编辑、刷新和追踪个人改修计划,查看实时完成进度与资源缺口。
9
+ - 在`改修规划`页面创建、编辑、刷新和追踪个人改修计划,查看实时完成进度与资源缺口。
10
10
  - 在`装备获取规划`页面管理想提前准备的装备,查看它们与未完成改修计划之间的待消耗关联。
11
- - 在`Debug`页面查看插件运行时数据与调试信息。
12
- - 静态数据支持远端版本更新;插件默认每 2 天自动检查一次,也可以手动点击“检查更新”。
11
+ - 按需开启 `poiPlugin.enableDebug` 后,在 `Debug` 页面查看运行时数据;默认隐藏。
12
+ - 静态数据在页面挂载时检查:成功后间隔 2 个 UTC 自然日,失败后间隔 15 分钟;可手动“检查更新”。下载有超时和有限重试,成功后页面自动刷新并保留编辑草稿。
13
13
  - 改修规划的待消耗道具会区分 useitem 与 `api_material` 资源类材料,例如高速建造材、高速修复材、开发资材、改修资材。
14
14
 
15
15
  ## 技术文档
package/index.js CHANGED
@@ -7,6 +7,8 @@ const { WindowEnv } = require("views/components/etc/window-env");
7
7
  const { KaiPlannerApp } = require("./src/app/KaiPlannerApp");
8
8
  const { subscribeMaterialResourceUpdates } = require("./src/services/player/materialResourceCounts");
9
9
 
10
+ const { enableDataUpdates, disposeDataUpdates } = require("./src/services/static/version/dataUpdateManager");
11
+
10
12
  let unsubscribeMaterialResourceUpdates = null;
11
13
 
12
14
  function KaiPlannerAppWithEnv() {
@@ -21,27 +23,26 @@ function KaiPlannerAppWithEnv() {
21
23
  });
22
24
  }
23
25
 
24
- module.exports = {
25
- reactClass: KaiPlannerAppWithEnv,
26
- windowMode: true,
27
-
28
- pluginDidLoad() {
29
- if (!unsubscribeMaterialResourceUpdates) {
30
- unsubscribeMaterialResourceUpdates = subscribeMaterialResourceUpdates(
31
- typeof window !== "undefined" ? window : null
32
- );
33
- }
34
- if (typeof window !== "undefined" && window.log) {
35
- window.log("[kai-planner] loaded");
36
- }
37
- },
38
- pluginWillUnload() {
39
- if (unsubscribeMaterialResourceUpdates) {
40
- unsubscribeMaterialResourceUpdates();
41
- unsubscribeMaterialResourceUpdates = null;
42
- }
43
- if (typeof window !== "undefined" && window.log) {
44
- window.log("[kai-planner] unloaded");
45
- }
46
- },
26
+ exports.reactClass = KaiPlannerAppWithEnv;
27
+ exports.windowMode = true;
28
+ exports.pluginDidLoad = function pluginDidLoad() {
29
+ enableDataUpdates();
30
+ if (!unsubscribeMaterialResourceUpdates) {
31
+ unsubscribeMaterialResourceUpdates = subscribeMaterialResourceUpdates(
32
+ typeof window !== "undefined" ? window : null
33
+ );
34
+ }
35
+ if (typeof window !== "undefined" && window.log) {
36
+ window.log("[kai-planner] loaded");
37
+ }
38
+ };
39
+ exports.pluginWillUnload = function pluginWillUnload() {
40
+ disposeDataUpdates();
41
+ if (unsubscribeMaterialResourceUpdates) {
42
+ unsubscribeMaterialResourceUpdates();
43
+ unsubscribeMaterialResourceUpdates = null;
44
+ }
45
+ if (typeof window !== "undefined" && window.log) {
46
+ window.log("[kai-planner] unloaded");
47
+ }
47
48
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-kai-planner",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "main": "index.js",
5
5
  "author": "aulu",
6
6
  "contributors": ["aulu"],
@@ -1,7 +1,8 @@
1
1
  const React = require("react");
2
2
  const { DailyTabV2 } = require("./tabs/daily/DailyTabV2");
3
- const { checkAndUpdateData } = require("../services/static/version/dataUpdateManager");
3
+ const { checkAndUpdateData, getUpdateState, subscribeDataUpdates } = require("../services/static/version/dataUpdateManager");
4
4
  const { readMeta } = require("../services/static/version/versionStore");
5
+ const { ensurePluginWindowVisible } = require("../core/poi/ensurePluginWindowVisible");
5
6
  const {
6
7
  DEFAULT_THEME_TOKENS,
7
8
  readPoiTheme,
@@ -66,7 +67,7 @@ function getUpdateFailureTip(result) {
66
67
  data_source_not_configured: "未配置远端数据源",
67
68
  };
68
69
  const label = labels[reason] || "检查失败";
69
- return result && result.error ? `${label}:${result.error}` : label;
70
+ return reason === "update_cancelled" ? "更新已取消。" : `${label},可点击检查更新重试。`;
70
71
  }
71
72
 
72
73
  class KaiPlannerApp extends React.Component {
@@ -77,7 +78,10 @@ class KaiPlannerApp extends React.Component {
77
78
  wishlistLoadError: null,
78
79
  acquisitionLoadError: null,
79
80
  debugLoadError: null,
80
- checkingUpdate: false,
81
+ checkingUpdate: getUpdateState().checking,
82
+ dataRevision: getUpdateState().dataRevision,
83
+ updateDiagnostic: "",
84
+ copyTip: "",
81
85
  lastUpdateAtText: "-",
82
86
  updateTip: "",
83
87
  materialResourceVersion: 0,
@@ -89,12 +93,17 @@ class KaiPlannerApp extends React.Component {
89
93
  this._unsubscribeTheme = null;
90
94
  this._unsubscribeMaterialResources = null;
91
95
  this._themeWarmupTimer = null;
96
+ this._cancelWindowVisibility = null;
92
97
  this.enableDebug = readEnableDebugFlag();
93
98
  this.handleCheckUpdate = this.handleCheckUpdate.bind(this);
94
99
  this.syncTheme = this.syncTheme.bind(this);
95
100
  }
96
101
 
97
102
  componentDidMount() {
103
+ this._mounted = true;
104
+ this._unsubscribeDataUpdates = subscribeDataUpdates(() => this.syncDataUpdateState());
105
+ this.syncDataUpdateState();
106
+ this._cancelWindowVisibility = ensurePluginWindowVisible(this.props.envWindow, this.props.envCtx);
98
107
  this.syncTheme();
99
108
  this._unsubscribeTheme = subscribePoiTheme(this.props.envWindow, (hostTheme) => {
100
109
  this.setState({ themeTokens: buildPlannerThemeTokens(hostTheme) });
@@ -109,6 +118,10 @@ class KaiPlannerApp extends React.Component {
109
118
  }
110
119
 
111
120
  componentWillUnmount() {
121
+ this._mounted = false;
122
+ if (this._unsubscribeDataUpdates) this._unsubscribeDataUpdates();
123
+ if (this._cancelWindowVisibility) this._cancelWindowVisibility();
124
+ this._cancelWindowVisibility = null;
112
125
  if (typeof this._unsubscribeTheme === "function") this._unsubscribeTheme();
113
126
  if (typeof this._unsubscribeMaterialResources === "function") this._unsubscribeMaterialResources();
114
127
  if (this._themeWarmupTimer != null) {
@@ -133,21 +146,44 @@ class KaiPlannerApp extends React.Component {
133
146
  } catch {}
134
147
  }
135
148
 
149
+ syncDataUpdateState() {
150
+ if (!this._mounted) return;
151
+ const update = getUpdateState();
152
+ const meta = readMeta();
153
+ const result = update.result;
154
+ const failure = result && !result.ok ? result : ((!result || result.skipped) && meta.lastCheckOk === false ? {
155
+ reason: meta.lastCheckReason, error: meta.lastCheckError, diagnostic: meta.lastCheckDiagnostic,
156
+ } : null);
157
+ this.setState({
158
+ checkingUpdate: update.checking,
159
+ dataRevision: update.dataRevision,
160
+ updateDiagnostic: failure ? JSON.stringify({ checkedAt: meta.lastCheckAt, reason: failure.reason, diagnostic: failure.diagnostic, error: failure.error }, null, 2) : "",
161
+ });
162
+ this.syncUpdateMeta();
163
+ }
164
+
165
+ async copyUpdateDiagnostic() {
166
+ try {
167
+ await require("views/services/clipboard").writeClipboardText(this.state.updateDiagnostic);
168
+ if (this._mounted) this.setState({ copyTip: "已复制" });
169
+ } catch {
170
+ if (this._mounted) this.setState({ copyTip: "复制失败,请选中下方详情手动复制。" });
171
+ }
172
+ }
173
+
136
174
  async handleCheckUpdate(force) {
137
- if (this.state.checkingUpdate) return;
138
- this.setState({ checkingUpdate: true, updateTip: force ? "正在检查..." : "" });
175
+ if (!this._mounted) return;
176
+ this.setState({ updateTip: force ? "正在检查..." : "", copyTip: "" });
139
177
  try {
140
178
  const result = await checkAndUpdateData({ force: !!force });
141
- this.syncUpdateMeta();
179
+ if (!this._mounted) return;
142
180
  if (force) {
143
- if (result && result.ok && result.updated) this.setState({ updateTip: `已更新到数据版本 ${result.remoteVersion}` });
144
- else if (result && result.ok && !result.updated) this.setState({ updateTip: "当前已是最新数据。" });
181
+ if (result.ok && result.updated) this.setState({ updateTip: `已更新到数据版本 ${result.remoteVersion}` });
182
+ else if (result.ok) this.setState({ updateTip: "当前已是最新数据。" });
145
183
  else this.setState({ updateTip: getUpdateFailureTip(result) });
146
184
  }
147
185
  } catch (error) {
148
- if (force) this.setState({ updateTip: `检查失败:${String(error && (error.stack || error))}` });
149
- } finally {
150
- this.setState({ checkingUpdate: false });
186
+ if (this._mounted) this.setState({ updateTip: "检查失败,可重试。", updateDiagnostic: String(error.stack || error) });
151
187
  }
152
188
  }
153
189
 
@@ -177,6 +213,7 @@ class KaiPlannerApp extends React.Component {
177
213
  content = React.createElement(DailyTabV2, {
178
214
  envWindow: this.props.envWindow,
179
215
  envCtx: this.props.envCtx,
216
+ dataRevision: this.state.dataRevision,
180
217
  themeTokens,
181
218
  });
182
219
  } else if (tab === "wishlist") {
@@ -198,6 +235,7 @@ class KaiPlannerApp extends React.Component {
198
235
  content = React.createElement(this._WishlistTab, {
199
236
  envWindow: this.props.envWindow,
200
237
  envCtx: this.props.envCtx,
238
+ dataRevision: this.state.dataRevision,
201
239
  materialResourceVersion: this.state.materialResourceVersion,
202
240
  themeTokens,
203
241
  });
@@ -223,6 +261,7 @@ class KaiPlannerApp extends React.Component {
223
261
  content = React.createElement(this._AcquisitionTab, {
224
262
  envWindow: this.props.envWindow,
225
263
  envCtx: this.props.envCtx,
264
+ dataRevision: this.state.dataRevision,
226
265
  themeTokens,
227
266
  });
228
267
  } else {
@@ -247,6 +286,7 @@ class KaiPlannerApp extends React.Component {
247
286
  content = React.createElement(this._DebugTab, {
248
287
  envWindow: this.props.envWindow,
249
288
  envCtx: this.props.envCtx,
289
+ dataRevision: this.state.dataRevision,
250
290
  themeTokens,
251
291
  });
252
292
  } else {
@@ -284,6 +324,13 @@ class KaiPlannerApp extends React.Component {
284
324
  this.state.updateTip ? React.createElement("span", { style: { fontSize: 14, color: themeTokens.textMuted } }, this.state.updateTip) : null
285
325
  )
286
326
  ),
327
+ this.state.updateDiagnostic ? React.createElement("details", { style: { marginBottom: 12 } },
328
+ React.createElement("summary", null, "最近一次更新未完成:查看诊断详情"),
329
+ React.createElement("button", { onClick: () => this.copyUpdateDiagnostic() }, "复制诊断详情"),
330
+ React.createElement("span", null, this.state.copyTip),
331
+ React.createElement("textarea", { readOnly: true, value: this.state.updateDiagnostic,
332
+ style: { width: "100%", height: 140 }, onFocus: (event) => event.target.select() })
333
+ ) : null,
287
334
  content
288
335
  );
289
336
  }
@@ -48,11 +48,7 @@ class AcquisitionTab extends React.Component {
48
48
  constructor(props) {
49
49
  super(props);
50
50
 
51
- this.staticData = loadStaticData();
52
- this.consumeIndexes = buildConsumeIndexes(this.staticData);
53
- this.upgradeIndexes = buildUpgradeIndexes(this.staticData);
54
- this.pathByPair = buildPathIndexes(this.staticData).pathByPair;
55
- this.arrangementIndex = buildArrangementIndex(this.staticData.improvementArrangement).index;
51
+ this.reloadStaticData();
56
52
 
57
53
  this.state = {
58
54
  nowText: formatTokyoText(),
@@ -77,11 +73,26 @@ class AcquisitionTab extends React.Component {
77
73
  this.refresh = this.refresh.bind(this);
78
74
  }
79
75
 
76
+ reloadStaticData() {
77
+ this.staticData = loadStaticData();
78
+ this.consumeIndexes = buildConsumeIndexes(this.staticData);
79
+ this.upgradeIndexes = buildUpgradeIndexes(this.staticData);
80
+ this.pathByPair = buildPathIndexes(this.staticData).pathByPair;
81
+ this.arrangementIndex = buildArrangementIndex(this.staticData.improvementArrangement).index;
82
+ }
83
+
80
84
  componentDidMount() {
81
85
  this._timer = setInterval(() => this.setState({ nowText: formatTokyoText() }), 1000);
82
86
  this.refresh();
83
87
  }
84
88
 
89
+ componentDidUpdate(prevProps) {
90
+ if (prevProps.dataRevision !== this.props.dataRevision) {
91
+ this.reloadStaticData();
92
+ this.refresh();
93
+ }
94
+ }
95
+
85
96
  componentWillUnmount() {
86
97
  if (this._timer) clearInterval(this._timer);
87
98
  }
@@ -185,6 +185,10 @@ class DailyTabV2 extends React.Component {
185
185
  }, 1000);
186
186
  }
187
187
 
188
+ componentDidUpdate(prevProps) {
189
+ if (prevProps.dataRevision !== this.props.dataRevision) this.refresh();
190
+ }
191
+
188
192
  componentWillUnmount() {
189
193
  if (this._timer) clearInterval(this._timer);
190
194
  }
@@ -42,6 +42,7 @@ function buildDebugDiagnostics({
42
42
  lastCheckAt: meta.lastCheckAt || "",
43
43
  lastCheckOk: meta.lastCheckOk == null ? null : !!meta.lastCheckOk,
44
44
  lastCheckReason: meta.lastCheckReason || "",
45
+ lastCheckDiagnostic: meta.lastCheckDiagnostic || null,
45
46
  dataDir: staticInfo.dataDir || "",
46
47
  cachePath: staticInfo.cachePath || "",
47
48
  cacheExists: !!staticInfo.cacheExists,
@@ -121,11 +121,7 @@ class WishlistTabV2 extends React.Component {
121
121
  constructor(props) {
122
122
  super(props);
123
123
 
124
- this.staticData = loadStaticData();
125
- this.consumeIndexes = buildConsumeIndexes(this.staticData);
126
- this.upgradeIndexes = buildUpgradeIndexes(this.staticData);
127
- this.pathByPair = buildPathIndexes(this.staticData).pathByPair;
128
- this.arrangementIndex = buildArrangementIndex(this.staticData.improvementArrangement).index;
124
+ this.reloadStaticData();
129
125
  this._ownerShipIndexCache = { shipsRef: null, map: {} };
130
126
  this._startOptionsCache = { targetEquipId: null, equipsRef: null, plansKey: "", ownerMapRef: null, options: [] };
131
127
 
@@ -167,6 +163,14 @@ class WishlistTabV2 extends React.Component {
167
163
  this.refreshPlanById = this.refreshPlanById.bind(this);
168
164
  }
169
165
 
166
+ reloadStaticData() {
167
+ this.staticData = loadStaticData();
168
+ this.consumeIndexes = buildConsumeIndexes(this.staticData);
169
+ this.upgradeIndexes = buildUpgradeIndexes(this.staticData);
170
+ this.pathByPair = buildPathIndexes(this.staticData).pathByPair;
171
+ this.arrangementIndex = buildArrangementIndex(this.staticData.improvementArrangement).index;
172
+ }
173
+
170
174
  componentDidMount() {
171
175
  this._timer = setInterval(() => this.setState({ nowText: formatTokyoText() }), 1000);
172
176
  this.refreshPlans();
@@ -179,6 +183,12 @@ class WishlistTabV2 extends React.Component {
179
183
  }
180
184
 
181
185
  componentDidUpdate(prevProps) {
186
+ if (prevProps.dataRevision !== this.props.dataRevision) {
187
+ this.reloadStaticData();
188
+ this._startOptionsCache = {};
189
+ this.refreshPlans({ preserveInteraction: true });
190
+ return;
191
+ }
182
192
  if (prevProps.materialResourceVersion === this.props.materialResourceVersion) return;
183
193
  const state = this.getReduxState();
184
194
  if (!state || !state.const || !state.info) return;
@@ -374,7 +384,8 @@ class WishlistTabV2 extends React.Component {
374
384
  setFilterNote(value) {
375
385
  this.setState(this.clearSelectionState({ filterNote: value, info: null }));
376
386
  }
377
- refreshPlans() {
387
+ refreshPlans({ preserveInteraction = false } = {}) {
388
+ const interactionPatch = (patch) => preserveInteraction ? patch : this.clearSelectionState(patch);
378
389
  try {
379
390
  const state = this.getReduxState();
380
391
  const plans = loadPlans();
@@ -461,7 +472,7 @@ class WishlistTabV2 extends React.Component {
461
472
  };
462
473
 
463
474
  this.setState(
464
- this.clearSelectionState({
475
+ interactionPatch({
465
476
  ...buildInitialWishlistHydrationState({
466
477
  plans,
467
478
  initialSnapshot,
@@ -474,7 +485,7 @@ class WishlistTabV2 extends React.Component {
474
485
  }
475
486
  );
476
487
  } catch (error) {
477
- this.setState(this.clearSelectionState({ error: String(error && (error.stack || error)), hydrating: false, hydrationQueue: [] }));
488
+ this.setState(interactionPatch({ error: String(error && (error.stack || error)), hydrating: false, hydrationQueue: [] }));
478
489
  }
479
490
  }
480
491
 
@@ -0,0 +1,54 @@
1
+ const PLUGIN_QUERY = "?poi-plugin-kai-planner";
2
+
3
+ // Portal components execute in poi's main renderer. Never use
4
+ // remote.getCurrentWindow(): that would target the main window.
5
+ function ensurePluginWindowVisible(envWindow, envCtx, options = {}) {
6
+ let cancelled = false;
7
+ let timer = null;
8
+ const schedule = options.schedule || setTimeout;
9
+ const clear = options.clear || clearTimeout;
10
+ const warn = options.warn || ((error) => console.warn("[kai-planner] window visibility fallback failed", error));
11
+ const cancel = () => {
12
+ cancelled = true;
13
+ if (timer !== null) clear(timer);
14
+ timer = null;
15
+ };
16
+
17
+ try {
18
+ const mountPoint = envCtx && envCtx.mountPoint;
19
+ if (!envWindow || envWindow.closed || envWindow.isWindowMode !== true || !mountPoint) return cancel;
20
+ const doc = envWindow.document;
21
+ const url = new URL(envWindow.location.href);
22
+ if (url.protocol !== "file:" || !url.pathname.endsWith("/index-plugin.html") || url.search !== PLUGIN_QUERY) return cancel;
23
+ if (mountPoint.ownerDocument !== doc || !mountPoint.isConnected) return cancel;
24
+
25
+ const remote = options.remote || require("@electron/remote");
26
+ const matches = remote.BrowserWindow.getAllWindows().filter(
27
+ (win) => !win.isDestroyed() && win.webContents.getURL() === url.href
28
+ );
29
+ if (matches.length !== 1) return cancel;
30
+ // Capture this instance now, never search again after a close/reopen.
31
+ const target = matches[0];
32
+ if (target.isVisible() || target.isMinimized()) return cancel;
33
+
34
+ // Run after the mount commit; one check only, no polling or focus call.
35
+ timer = schedule(() => {
36
+ timer = null;
37
+ if (cancelled) return;
38
+ try {
39
+ if (envWindow.closed || target.isDestroyed()) return;
40
+ if (envWindow.document !== doc || envWindow.location.href !== url.href) return;
41
+ if (!mountPoint.isConnected || mountPoint.ownerDocument !== doc || !mountPoint.querySelector(".kai-planner-root")) return;
42
+ if (target.webContents.getURL() !== url.href || target.isVisible() || target.isMinimized()) return;
43
+ target.show();
44
+ } catch (error) {
45
+ warn(error);
46
+ }
47
+ }, 0);
48
+ } catch (error) {
49
+ warn(error);
50
+ }
51
+ return cancel;
52
+ }
53
+
54
+ module.exports = { ensurePluginWindowVisible };
@@ -13,14 +13,31 @@ const {
13
13
 
14
14
  const CHECK_INTERVAL_DAYS = 2;
15
15
 
16
- function nowMs() {
17
- return Date.now();
18
- }
16
+ const { requestText, throwIfCancelled } = require("./requestText");
17
+ const FAILURE_RETRY_MS = 15 * 60 * 1000;
18
+ let activeTask = null;
19
+ let enabled = true;
20
+ let dataRevision = 0;
21
+ let lastResult = null;
22
+ const listeners = new Set();
19
23
 
20
- function toMs(iso) {
21
- if (!iso) return 0;
22
- const t = new Date(iso).getTime();
23
- return Number.isFinite(t) ? t : 0;
24
+ function getUpdateState() {
25
+ return { checking: !!activeTask, dataRevision, result: lastResult };
26
+ }
27
+ function subscribeDataUpdates(listener) {
28
+ listeners.add(listener);
29
+ return () => listeners.delete(listener);
30
+ }
31
+ function notify() {
32
+ for (const listener of listeners) {
33
+ try { listener(getUpdateState()); } catch (error) { console.warn("[kai-planner] update subscriber failed", error); }
34
+ }
35
+ }
36
+ function enableDataUpdates() { enabled = true; }
37
+ function disposeDataUpdates() {
38
+ enabled = false;
39
+ if (activeTask) activeTask.controller.abort();
40
+ listeners.clear();
24
41
  }
25
42
 
26
43
  function parseVersion(v) {
@@ -48,30 +65,14 @@ function makeReasonError(reason, message) {
48
65
  return error;
49
66
  }
50
67
 
51
- async function fetchJson(url) {
52
- let res;
53
- try {
54
- res = await fetch(url, { method: "GET", cache: "no-store" });
55
- } catch (error) {
56
- throw makeReasonError("manifest_fetch_failed", String(error && (error.stack || error)));
57
- }
58
- if (!res.ok) throw makeReasonError("manifest_fetch_failed", `http ${res.status} for ${url}`);
59
- try {
60
- return await res.json();
61
- } catch (error) {
62
- throw makeReasonError("manifest_invalid", String(error && (error.stack || error)));
63
- }
64
- }
65
-
66
- async function fetchText(url) {
67
- let res;
68
- try {
69
- res = await fetch(url, { method: "GET", cache: "no-store" });
70
- } catch (error) {
71
- throw makeReasonError("remote_file_fetch_failed", String(error && (error.stack || error)));
68
+ async function fetchJson(url, signal) {
69
+ const text = await requestText(url, { signal, file: "data_manifest.json", reason: "manifest_fetch_failed" });
70
+ try { return JSON.parse(text); }
71
+ catch (error) {
72
+ throw Object.assign(makeReasonError("manifest_invalid", error.message), {
73
+ diagnostic: { file: "data_manifest.json", url, stage: "parse", error: String(error.stack || error) },
74
+ });
72
75
  }
73
- if (!res.ok) throw makeReasonError("remote_file_fetch_failed", `http ${res.status} for ${url}`);
74
- return res.text();
75
76
  }
76
77
 
77
78
  function getDaySerial(d) {
@@ -85,12 +86,13 @@ function shouldAutoCheck(meta) {
85
86
  if (!lastIso) return true;
86
87
  const last = new Date(lastIso);
87
88
  if (!Number.isFinite(last.getTime())) return true;
89
+ if (meta.lastCheckOk === false) return Date.now() - last.getTime() >= FAILURE_RETRY_MS;
88
90
  const now = new Date();
89
91
  const diffDays = getDaySerial(now) - getDaySerial(last);
90
92
  return diffDays >= CHECK_INTERVAL_DAYS;
91
93
  }
92
94
 
93
- async function applyRemoteData(remoteManifest, rawBaseUrl) {
95
+ async function applyRemoteData(remoteManifest, rawBaseUrl, signal) {
94
96
  const files = Array.isArray(remoteManifest && remoteManifest.files) ? remoteManifest.files : [];
95
97
  if (!files.length) {
96
98
  throw makeReasonError("manifest_invalid", "remote manifest has no files");
@@ -108,17 +110,19 @@ async function applyRemoteData(remoteManifest, rawBaseUrl) {
108
110
  for (const f of files) {
109
111
  const fileName = String(f);
110
112
  const url = `${rawBaseUrl.replace(/\/+$/, "")}/${fileName}`;
111
- const text = await fetchText(url);
113
+ const text = await requestText(url, { signal, file: fileName, reason: "remote_file_fetch_failed" });
114
+ throwIfCancelled(signal);
112
115
  try {
113
116
  JSON.parse(text);
114
117
  } catch (error) {
115
- throw makeReasonError("remote_file_invalid_json", `${fileName}: ${String(error && error.message ? error.message : error)}`);
118
+ throw Object.assign(makeReasonError("remote_file_invalid_json", error.message), { diagnostic: { file: fileName, url, stage: "parse", error: String(error.stack || error) } });
116
119
  }
117
120
  if (!writeCacheFileToDir(tempDir, fileName, text)) {
118
121
  throw makeReasonError("cache_stage_failed", `failed to stage ${fileName}`);
119
122
  }
120
123
  }
121
124
 
125
+ throwIfCancelled(signal);
122
126
  if (!commitCacheDir(tempDir)) {
123
127
  throw makeReasonError("cache_commit_failed", "failed to commit remote data cache");
124
128
  }
@@ -153,12 +157,13 @@ function getLocalManifestUpdatedAt() {
153
157
  return null;
154
158
  }
155
159
 
156
- async function checkAndUpdateData({ force = false } = {}) {
160
+ async function runUpdate(force, signal) {
161
+ throwIfCancelled(signal);
157
162
  const cfg = getDataSourceConfig();
158
163
  const meta = readMeta();
159
164
  const startCheckAt = new Date().toISOString();
160
165
 
161
- if (!cfg.manifestRawUrl || !cfg.rawBaseUrl) {
166
+ if (!cfg.manifestRawUrl || !cfg.rawBaseUrl) {
162
167
  const nextMeta = writeMeta({
163
168
  lastCheckAt: startCheckAt,
164
169
  lastCheckOk: false,
@@ -182,7 +187,7 @@ async function checkAndUpdateData({ force = false } = {}) {
182
187
  }
183
188
 
184
189
  try {
185
- const remoteManifest = await fetchJson(cfg.manifestRawUrl);
190
+ const remoteManifest = await fetchJson(cfg.manifestRawUrl, signal);
186
191
  if (!remoteManifest || typeof remoteManifest !== "object" || !remoteManifest.data_version) {
187
192
  throw makeReasonError("manifest_invalid", "remote manifest missing data_version");
188
193
  }
@@ -191,7 +196,7 @@ async function checkAndUpdateData({ force = false } = {}) {
191
196
  const hasNewer = compareVersion(remoteVersion, localVersion) > 0;
192
197
 
193
198
  if (hasNewer) {
194
- await applyRemoteData(remoteManifest, cfg.rawBaseUrl);
199
+ await applyRemoteData(remoteManifest, cfg.rawBaseUrl, signal);
195
200
  }
196
201
 
197
202
  const nextMeta = writeMeta({
@@ -199,6 +204,7 @@ async function checkAndUpdateData({ force = false } = {}) {
199
204
  lastCheckOk: true,
200
205
  lastCheckReason: hasNewer ? "updated" : "not_updated",
201
206
  lastCheckError: "",
207
+ lastCheckDiagnostic: null,
202
208
  lastVersion: hasNewer ? remoteVersion : localVersion,
203
209
  lastUpdateAt: hasNewer
204
210
  ? (remoteManifest.updated_at || startCheckAt)
@@ -215,25 +221,54 @@ async function checkAndUpdateData({ force = false } = {}) {
215
221
  meta: nextMeta,
216
222
  };
217
223
  } catch (e) {
224
+ if (signal.aborted) return { ok: false, reason: "update_cancelled" };
218
225
  const reason = e && e.reason ? String(e.reason) : "update_failed";
219
226
  const nextMeta = writeMeta({
220
227
  lastCheckAt: startCheckAt,
221
228
  lastCheckOk: false,
222
229
  lastCheckReason: reason,
223
230
  lastCheckError: String(e && (e.stack || e)),
231
+ lastCheckDiagnostic: e.diagnostic || { stage: reason, error: String(e && (e.stack || e)) },
224
232
  });
225
233
  return {
226
234
  ok: false,
227
235
  skipped: false,
228
236
  reason,
229
237
  error: String(e && (e.stack || e)),
238
+ diagnostic: nextMeta.lastCheckDiagnostic,
230
239
  meta: nextMeta,
231
240
  };
232
241
  }
233
242
  }
234
243
 
244
+ // A single promise belongs to the service, not a mounted React component.
245
+ function checkAndUpdateData({ force = false } = {}) {
246
+ if (!enabled) return Promise.resolve({ ok: false, reason: "update_cancelled" });
247
+ if (activeTask) {
248
+ if (activeTask.controller.signal.aborted) {
249
+ return activeTask.promise.then(() => checkAndUpdateData({ force }));
250
+ }
251
+ return activeTask.promise;
252
+ }
253
+ const task = { controller: new AbortController(), promise: null };
254
+ activeTask = task;
255
+ task.promise = Promise.resolve().then(() => runUpdate(force, task.controller.signal))
256
+ .catch(error => ({ ok: false, reason: task.controller.signal.aborted ? "update_cancelled" : "update_failed", error: String(error.stack || error) }))
257
+ .then(result => {
258
+ if (!task.controller.signal.aborted) {
259
+ if (!result.skipped || !lastResult) lastResult = result;
260
+ if (result.ok && result.updated) dataRevision++;
261
+ }
262
+ return result;
263
+ }).finally(() => {
264
+ if (activeTask === task) activeTask = null;
265
+ if (enabled) notify();
266
+ });
267
+ notify();
268
+ return task.promise;
269
+ }
270
+
235
271
  module.exports = {
236
- CHECK_INTERVAL_DAYS,
237
- shouldAutoCheck,
238
- checkAndUpdateData,
272
+ CHECK_INTERVAL_DAYS, FAILURE_RETRY_MS, shouldAutoCheck, checkAndUpdateData,
273
+ getUpdateState, subscribeDataUpdates, enableDataUpdates, disposeDataUpdates,
239
274
  };
@@ -0,0 +1,81 @@
1
+ const REQUEST_TIMEOUT_MS = 15000;
2
+ const MAX_ATTEMPTS = 3;
3
+ const RETRY_DELAYS_MS = [500, 1500];
4
+
5
+ function cancelledError() {
6
+ return Object.assign(new Error("Update cancelled"), { reason: "update_cancelled" });
7
+ }
8
+
9
+ function throwIfCancelled(signal) {
10
+ if (signal && signal.aborted) throw cancelledError();
11
+ }
12
+
13
+ function waitForRetry(ms, signal) {
14
+ return new Promise((resolve, reject) => {
15
+ throwIfCancelled(signal);
16
+ const abort = () => { clearTimeout(timer); reject(cancelledError()); };
17
+ const timer = setTimeout(() => {
18
+ signal.removeEventListener("abort", abort);
19
+ resolve();
20
+ }, ms);
21
+ signal.addEventListener("abort", abort, { once: true });
22
+ });
23
+ }
24
+
25
+ // Keep the renderer fetch implementation so Electron session proxy settings apply.
26
+ // The deadline includes reading the response body, not just receiving headers.
27
+ async function requestText(url, { signal, file, reason, timeoutMs = REQUEST_TIMEOUT_MS,
28
+ delays = RETRY_DELAYS_MS, fetchImpl = (...args) => fetch(...args) } = {}) {
29
+ const started = Date.now();
30
+ const parentSignal = signal || new AbortController().signal;
31
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
32
+ throwIfCancelled(parentSignal);
33
+ const controller = new AbortController();
34
+ let timer;
35
+ let abort;
36
+ let stage = "request";
37
+ let status = null;
38
+ try {
39
+ const interrupt = new Promise((_, reject) => {
40
+ abort = () => { reject(cancelledError()); controller.abort(); };
41
+ parentSignal.addEventListener("abort", abort, { once: true });
42
+ timer = setTimeout(() => {
43
+ reject(Object.assign(new Error("Request timed out"), { timeout: true }));
44
+ controller.abort();
45
+ }, timeoutMs);
46
+ });
47
+ const work = (async () => {
48
+ const response = await fetchImpl(url, { method: "GET", cache: "no-store", signal: controller.signal });
49
+ status = response.status || null;
50
+ if (!response.ok) {
51
+ stage = "http";
52
+ const error = Object.assign(new Error(`HTTP ${status}`), {
53
+ retryable: status === 408 || status === 429 || status >= 500,
54
+ });
55
+ controller.abort();
56
+ throw error;
57
+ }
58
+ stage = "body";
59
+ return await response.text();
60
+ })();
61
+ const text = await Promise.race([work, interrupt]);
62
+ throwIfCancelled(parentSignal);
63
+ return text;
64
+ } catch (error) {
65
+ throwIfCancelled(parentSignal);
66
+ if (attempt === MAX_ATTEMPTS || error.retryable === false) {
67
+ throw Object.assign(new Error(error.message || String(error)), {
68
+ reason,
69
+ diagnostic: { file, url, stage, attempt, elapsedMs: Date.now() - started,
70
+ status, timeout: !!error.timeout, error: String(error.stack || error) },
71
+ });
72
+ }
73
+ } finally {
74
+ clearTimeout(timer);
75
+ parentSignal.removeEventListener("abort", abort);
76
+ }
77
+ await waitForRetry(delays[attempt - 1] || 0, parentSignal);
78
+ }
79
+ }
80
+
81
+ module.exports = { requestText, throwIfCancelled, REQUEST_TIMEOUT_MS, MAX_ATTEMPTS, RETRY_DELAYS_MS };