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 +3 -3
- package/index.js +24 -23
- package/package.json +1 -1
- package/src/app/KaiPlannerApp.js +58 -11
- package/src/app/tabs/acquisition/AcquisitionTab.js +16 -5
- package/src/app/tabs/daily/DailyTabV2.js +4 -0
- package/src/app/tabs/debug/debugDiagnostics.js +1 -0
- package/src/app/tabs/wishlist/WishlistTabV2.js +19 -8
- package/src/core/poi/ensurePluginWindowVisible.js +54 -0
- package/src/services/static/version/dataUpdateManager.js +75 -40
- package/src/services/static/version/requestText.js +81 -0
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
- 在`每日改修`页面查看当天可改修装备、基础消耗、秘书舰要求与可进化状态。
|
|
8
8
|
- 在`每日改修`页面把常看的装备标记为收藏,收藏项会优先排序并长期保存在本地。
|
|
9
|
-
-
|
|
9
|
+
- 在`改修规划`页面创建、编辑、刷新和追踪个人改修计划,查看实时完成进度与资源缺口。
|
|
10
10
|
- 在`装备获取规划`页面管理想提前准备的装备,查看它们与未完成改修计划之间的待消耗关联。
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
package/src/app/KaiPlannerApp.js
CHANGED
|
@@ -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
|
|
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:
|
|
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.
|
|
138
|
-
this.setState({
|
|
175
|
+
if (!this._mounted) return;
|
|
176
|
+
this.setState({ updateTip: force ? "正在检查..." : "", copyTip: "" });
|
|
139
177
|
try {
|
|
140
178
|
const result = await checkAndUpdateData({ force: !!force });
|
|
141
|
-
this.
|
|
179
|
+
if (!this._mounted) return;
|
|
142
180
|
if (force) {
|
|
143
|
-
if (result
|
|
144
|
-
else if (result
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
53
|
-
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|