@www.hyperlinks.space/program-kit 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/api/ai.ts +111 -0
- package/api/base.ts +117 -0
- package/api/blockchain.ts +58 -0
- package/api/bot.ts +19 -0
- package/api/ping.ts +41 -0
- package/api/releases.ts +162 -0
- package/api/telegram.ts +65 -0
- package/api/tsconfig.json +17 -0
- package/app/_layout.tsx +135 -0
- package/app/ai.tsx +39 -0
- package/app/components/GlobalBottomBar.tsx +447 -0
- package/app/components/GlobalBottomBarWeb.tsx +362 -0
- package/app/components/GlobalLogoBar.tsx +108 -0
- package/app/components/GlobalLogoBarFallback.tsx +66 -0
- package/app/components/GlobalLogoBarWithFallback.tsx +24 -0
- package/app/components/HyperlinksSpaceLogo.tsx +29 -0
- package/app/components/Telegram.tsx +648 -0
- package/app/components/telegramWebApp.ts +359 -0
- package/app/fonts.ts +12 -0
- package/app/index.tsx +102 -0
- package/app/theme.ts +117 -0
- package/app.json +60 -0
- package/assets/icon.ico +0 -0
- package/assets/images/favicon.png +0 -0
- package/blockchain/coffee.ts +217 -0
- package/blockchain/router.ts +44 -0
- package/bot/format.ts +143 -0
- package/bot/grammy.ts +52 -0
- package/bot/responder.ts +620 -0
- package/bot/webhook.ts +262 -0
- package/database/messages.ts +128 -0
- package/database/start.ts +133 -0
- package/database/users.ts +46 -0
- package/docs/ai_and_search_bar_input.md +94 -0
- package/docs/ai_bot_messages.md +124 -0
- package/docs/backlogs/medium_term_backlog.md +26 -0
- package/docs/backlogs/short_term_backlog.md +39 -0
- package/docs/blue_bar_tackling.md +143 -0
- package/docs/bot_async_streaming.md +174 -0
- package/docs/build_and_install.md +129 -0
- package/docs/database_messages.md +34 -0
- package/docs/fonts.md +18 -0
- package/docs/releases.md +201 -0
- package/docs/releases_github_actions.md +188 -0
- package/docs/scalability.md +34 -0
- package/docs/security_plan_raw.md +244 -0
- package/docs/security_raw.md +345 -0
- package/docs/timing_raw.md +63 -0
- package/docs/tma_logo_bar_jump_investigation.md +69 -0
- package/docs/update.md +205 -0
- package/docs/wallets_hosting_architecture.md +257 -0
- package/eas.json +47 -0
- package/eslint.config.js +10 -0
- package/fullREADME.md +159 -0
- package/global.css +67 -0
- package/npmReadMe.md +53 -0
- package/package.json +214 -0
- package/scripts/load-env.ts +17 -0
- package/scripts/migrate-db.ts +16 -0
- package/scripts/program-kit-init.cjs +58 -0
- package/scripts/run-bot-local.ts +30 -0
- package/scripts/set-webhook.ts +67 -0
- package/scripts/test-api-base.ts +12 -0
- package/telegram/post.ts +328 -0
- package/tsconfig.json +17 -0
- package/vercel.json +7 -0
- package/windows/after-sign-windows-icon.cjs +13 -0
- package/windows/build-layout.cjs +72 -0
- package/windows/build-with-progress.cjs +88 -0
- package/windows/build.cjs +2247 -0
- package/windows/cleanup-legacy-appdata-installs.ps1 +91 -0
- package/windows/cleanup-legacy-windows-shortcuts.ps1 +46 -0
- package/windows/cleanup.cjs +200 -0
- package/windows/embed-windows-exe-icon.cjs +55 -0
- package/windows/extractAppPackage.nsh +150 -0
- package/windows/forge/README.md +41 -0
- package/windows/forge/forge.config.js +138 -0
- package/windows/forge/make-with-stamp.cjs +65 -0
- package/windows/forge-cleanup.cjs +255 -0
- package/windows/hsp-app-process.ps1 +63 -0
- package/windows/installer-hooks.nsi +373 -0
- package/windows/product-brand.cjs +42 -0
- package/windows/remove-orphan-uninstall-registry.ps1 +67 -0
- package/windows/run-installed-with-icon-debug.cmd +20 -0
- package/windows/run-win-electron-builder.cjs +46 -0
- package/windows/updater-dialog.html +143 -0
|
@@ -0,0 +1,2247 @@
|
|
|
1
|
+
const {
|
|
2
|
+
app,
|
|
3
|
+
BrowserWindow,
|
|
4
|
+
Menu,
|
|
5
|
+
protocol,
|
|
6
|
+
net,
|
|
7
|
+
dialog,
|
|
8
|
+
Notification,
|
|
9
|
+
ipcMain,
|
|
10
|
+
nativeImage,
|
|
11
|
+
nativeTheme,
|
|
12
|
+
} = require("electron");
|
|
13
|
+
|
|
14
|
+
/** Must match package.json `build.appId`. Call synchronously before `ready` on Windows (Electron + shell taskbar expectations). */
|
|
15
|
+
const WIN_APP_USER_MODEL_ID = "com.sraibaby.app";
|
|
16
|
+
if (process.platform === "win32") {
|
|
17
|
+
try {
|
|
18
|
+
app.setAppUserModelId(WIN_APP_USER_MODEL_ID);
|
|
19
|
+
} catch (_) {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Optional: set HSP_DISABLE_GPU=1 to test whether GPU stack affects shortcuts (e.g. Print Screen) on Windows.
|
|
23
|
+
if (process.platform === "win32" && process.env.HSP_DISABLE_GPU === "1") {
|
|
24
|
+
try {
|
|
25
|
+
app.disableHardwareAcceleration();
|
|
26
|
+
} catch (_) {}
|
|
27
|
+
}
|
|
28
|
+
const path = require("path");
|
|
29
|
+
const fs = require("fs");
|
|
30
|
+
const crypto = require("crypto");
|
|
31
|
+
const { spawn, spawnSync } = require("child_process");
|
|
32
|
+
const { pathToFileURL } = require("url");
|
|
33
|
+
const brand = require("./product-brand.cjs");
|
|
34
|
+
|
|
35
|
+
const UPDATE_GITHUB_OWNER = "HyperlinksSpace";
|
|
36
|
+
/** Must match `build.publish.repo` and the repo where CI uploads releases. */
|
|
37
|
+
const UPDATE_GITHUB_REPO = "HyperlinksSpaceProgram";
|
|
38
|
+
const ZIP_LATEST_YML = "zip-latest.yml";
|
|
39
|
+
/** Same pattern as package.json build.win.artifactName for the zip target. */
|
|
40
|
+
const WIN_PORTABLE_ZIP_PREFIX = brand.portableZipPrefix;
|
|
41
|
+
const LATEST_YML = "latest.yml";
|
|
42
|
+
|
|
43
|
+
function sleep(ms) {
|
|
44
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** GitHub / Electron net layer: transient errors worth retrying (backoff in checkForUpdatesWithRetry). */
|
|
48
|
+
function isTransientGithubUpdateError(err) {
|
|
49
|
+
if (!err) return false;
|
|
50
|
+
const code = err.statusCode ?? err.status;
|
|
51
|
+
if (code === 502 || code === 503 || code === 504) return true;
|
|
52
|
+
const msg = String(err.message || err);
|
|
53
|
+
if (
|
|
54
|
+
/\b502\b|\b503\b|\b504\b|Bad Gateway|Service Unavailable|Gateway Timeout|taking too long|ECONNRESET|ETIMEDOUT/i.test(
|
|
55
|
+
msg,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return true;
|
|
59
|
+
// Electron reports URL loader failures as net::ERR_* (not Node ECONNRESET).
|
|
60
|
+
if (/net::ERR_CONNECTION_RESET|net::ERR_CONNECTION_TIMED_OUT|net::ERR_NETWORK_CHANGED/i.test(msg))
|
|
61
|
+
return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Ring buffer for the updater dialog (last lines only). */
|
|
66
|
+
const UPDATER_DIALOG_LOG_MAX = 120;
|
|
67
|
+
const updaterDialogLogBuffer = [];
|
|
68
|
+
|
|
69
|
+
/** Set in setupAutoUpdater: sends a fully formatted log line (with ISO time) to the dialog. */
|
|
70
|
+
let updaterLogToDialog = null;
|
|
71
|
+
|
|
72
|
+
function appendUpdaterDialogLogLine(messageBody) {
|
|
73
|
+
const line = `[${new Date().toISOString()}] ${messageBody}`;
|
|
74
|
+
updaterDialogLogBuffer.push(line);
|
|
75
|
+
if (updaterDialogLogBuffer.length > UPDATER_DIALOG_LOG_MAX) {
|
|
76
|
+
updaterDialogLogBuffer.splice(0, updaterDialogLogBuffer.length - UPDATER_DIALOG_LOG_MAX);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
updaterLogToDialog?.(line);
|
|
80
|
+
} catch (_) {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Structured lines in userData/main.log and updater dialog: `[updater:tag] message` */
|
|
84
|
+
function logUpdater(tag, msg) {
|
|
85
|
+
const body = `[updater:${tag}] ${msg}`;
|
|
86
|
+
log(body);
|
|
87
|
+
appendUpdaterDialogLogLine(body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function safeJson(obj, maxLen = 800) {
|
|
91
|
+
try {
|
|
92
|
+
const s = JSON.stringify(obj);
|
|
93
|
+
return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s;
|
|
94
|
+
} catch (_) {
|
|
95
|
+
return String(obj);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Escape for a PowerShell single-quoted literal (only ' is doubled). */
|
|
100
|
+
function escapePsSingleQuotedPath(p) {
|
|
101
|
+
return String(p).replace(/'/g, "''");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Detached `powershell -File script.ps1 -PlanPath ...` often drops or misparses args on Windows.
|
|
106
|
+
* Use a short UTF-16LE -EncodedCommand that writes %TEMP%\\hsp-apply-trace.log then invokes the script.
|
|
107
|
+
*/
|
|
108
|
+
function buildWindowsApplyLauncherCommand(ps1Path, planPath) {
|
|
109
|
+
const qPs1 = escapePsSingleQuotedPath(ps1Path);
|
|
110
|
+
const qPlan = escapePsSingleQuotedPath(planPath);
|
|
111
|
+
return (
|
|
112
|
+
`$ErrorActionPreference='Stop';` +
|
|
113
|
+
`try{$t=Join-Path $env:TEMP 'hsp-apply-trace.log';` +
|
|
114
|
+
`$ts=(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ');` +
|
|
115
|
+
`Add-Content -LiteralPath $t -Encoding UTF8 -Value ('['+$ts+'] launcher start pid='+$PID)}catch{};` +
|
|
116
|
+
`& '${qPs1}' -PlanPath '${qPlan}'`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Prefer zip-latest.yml (has sha512 for the zip). If missing (404), use latest.yml + inferred zip name.
|
|
122
|
+
* @returns {{ version: string, fileName: string, sha512: string | null, source: string }}
|
|
123
|
+
*/
|
|
124
|
+
async function resolveWindowsZipSidecarMeta(netFetch, currentVersion) {
|
|
125
|
+
logUpdater("meta", `resolve sidecar manifests (current=${currentVersion})`);
|
|
126
|
+
const zipLatestUrl = githubLatestAssetUrl(ZIP_LATEST_YML);
|
|
127
|
+
const zlRes = await netFetch(zipLatestUrl);
|
|
128
|
+
if (zlRes.ok) {
|
|
129
|
+
const text = await zlRes.text();
|
|
130
|
+
const meta = parseSimpleUpdateYml(text);
|
|
131
|
+
if (meta.version && meta.fileName && meta.sha512) {
|
|
132
|
+
if (compareSemverLike(meta.version, currentVersion) <= 0) {
|
|
133
|
+
throw new Error("zip-latest.yml version is not newer than current app");
|
|
134
|
+
}
|
|
135
|
+
logUpdater("meta", `using zip-latest.yml version=${meta.version} file=${meta.fileName}`);
|
|
136
|
+
return { version: meta.version, fileName: meta.fileName, sha512: meta.sha512, source: "zip-latest.yml" };
|
|
137
|
+
}
|
|
138
|
+
log("[updater] zip-latest.yml incomplete; falling back to latest.yml + inferred zip name");
|
|
139
|
+
} else {
|
|
140
|
+
log(
|
|
141
|
+
`[updater] zip-latest.yml HTTP ${zlRes.status} — using latest.yml and inferred ${WIN_PORTABLE_ZIP_PREFIX}<version>.zip`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lyUrl = githubLatestAssetUrl(LATEST_YML);
|
|
146
|
+
const lyRes = await netFetch(lyUrl);
|
|
147
|
+
if (!lyRes.ok) {
|
|
148
|
+
throw new Error(`latest.yml HTTP ${lyRes.status} (need a GitHub release with latest.yml)`);
|
|
149
|
+
}
|
|
150
|
+
const lyText = await lyRes.text();
|
|
151
|
+
const ly = parseSimpleUpdateYml(lyText);
|
|
152
|
+
if (!ly.version) {
|
|
153
|
+
throw new Error("latest.yml has no version");
|
|
154
|
+
}
|
|
155
|
+
if (compareSemverLike(ly.version, currentVersion) <= 0) {
|
|
156
|
+
throw new Error("latest.yml version is not newer than current app");
|
|
157
|
+
}
|
|
158
|
+
const fileName = `${WIN_PORTABLE_ZIP_PREFIX}${ly.version}.zip`;
|
|
159
|
+
logUpdater("meta", `using latest.yml+inferred version=${ly.version} file=${fileName}`);
|
|
160
|
+
return {
|
|
161
|
+
version: ly.version,
|
|
162
|
+
fileName,
|
|
163
|
+
sha512: null,
|
|
164
|
+
source: "latest.yml+inferred",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function compareSemverLike(a, b) {
|
|
169
|
+
const pa = String(a || "0")
|
|
170
|
+
.split(".")
|
|
171
|
+
.map((x) => parseInt(x, 10) || 0);
|
|
172
|
+
const pb = String(b || "0")
|
|
173
|
+
.split(".")
|
|
174
|
+
.map((x) => parseInt(x, 10) || 0);
|
|
175
|
+
const n = Math.max(pa.length, pb.length);
|
|
176
|
+
for (let i = 0; i < n; i++) {
|
|
177
|
+
const da = pa[i] ?? 0;
|
|
178
|
+
const db = pb[i] ?? 0;
|
|
179
|
+
if (da < db) return -1;
|
|
180
|
+
if (da > db) return 1;
|
|
181
|
+
}
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* NSIS install: INSTDIR\versions\<semver>\… plus INSTDIR\current → junction (exe is …\current\<name>.exe).
|
|
187
|
+
* Legacy installs: exe lives directly in the install folder (no `current`). Returns the app root (parent of `current`, or the folder that contains the exe for legacy).
|
|
188
|
+
*/
|
|
189
|
+
function getWindowsAppRootFromExecPath(execPath) {
|
|
190
|
+
const dir = path.dirname(execPath);
|
|
191
|
+
if (path.basename(dir).toLowerCase() === "current") {
|
|
192
|
+
return path.dirname(dir);
|
|
193
|
+
}
|
|
194
|
+
return dir;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseSimpleUpdateYml(text) {
|
|
198
|
+
const versionM = text.match(/^version:\s*(.+)$/m);
|
|
199
|
+
const version = versionM ? versionM[1].trim() : null;
|
|
200
|
+
const pathM = text.match(/^path:\s*(.+)$/m);
|
|
201
|
+
let fileName = pathM ? pathM[1].trim() : null;
|
|
202
|
+
if (!fileName) {
|
|
203
|
+
const urlM = text.match(/^\s*url:\s*(.+)$/m);
|
|
204
|
+
fileName = urlM ? urlM[1].trim() : null;
|
|
205
|
+
}
|
|
206
|
+
const shaM = text.match(/^sha512:\s*(.+)$/m);
|
|
207
|
+
const sha512 = shaM ? shaM[1].trim() : null;
|
|
208
|
+
const sizeM = text.match(/^\s*size:\s*(\d+)\s*$/m);
|
|
209
|
+
const size = sizeM ? parseInt(sizeM[1], 10) : null;
|
|
210
|
+
return { version, fileName, sha512, size };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function githubLatestAssetUrl(fileName) {
|
|
214
|
+
const enc = encodeURIComponent(fileName).replace(/%20/g, "%20");
|
|
215
|
+
return `https://github.com/${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO}/releases/latest/download/${enc}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const GITHUB_API_HEADERS = {
|
|
219
|
+
Accept: "application/vnd.github+json",
|
|
220
|
+
"User-Agent": `${brand.productSlug}/electron-updater`,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* When /releases/latest/download/<name>.zip returns 404, find the portable zip on the latest release
|
|
225
|
+
* via the GitHub API (asset names may differ slightly from artifactName).
|
|
226
|
+
* @returns {Promise<string|null>} browser_download_url or null
|
|
227
|
+
*/
|
|
228
|
+
async function fetchPortableZipBrowserUrlFromGitHubApi(netFetch, version, preferredFileName) {
|
|
229
|
+
logUpdater(
|
|
230
|
+
"github-api",
|
|
231
|
+
`resolve zip URL via API (version=${version} preferred=${preferredFileName})`,
|
|
232
|
+
);
|
|
233
|
+
const apiUrl = `https://api.github.com/repos/${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO}/releases/latest`;
|
|
234
|
+
const res = await netFetch(apiUrl, { headers: GITHUB_API_HEADERS });
|
|
235
|
+
if (!res.ok) {
|
|
236
|
+
log(`[updater] GitHub API GET releases/latest: HTTP ${res.status}`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
let data;
|
|
240
|
+
try {
|
|
241
|
+
data = await res.json();
|
|
242
|
+
} catch (_) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const assets = Array.isArray(data.assets) ? data.assets : [];
|
|
246
|
+
const zips = assets.filter((a) => a && typeof a.name === "string" && /\.zip$/i.test(a.name));
|
|
247
|
+
const skipName = (n) =>
|
|
248
|
+
/blockmap|\.7z\.|\.delta/i.test(n) || /-ia32-|arm64|\.msi$/i.test(n);
|
|
249
|
+
const candidates = zips.filter((a) => !skipName(a.name));
|
|
250
|
+
|
|
251
|
+
const exact = candidates.find((a) => a.name === preferredFileName);
|
|
252
|
+
if (exact?.browser_download_url) {
|
|
253
|
+
log(`[updater] GitHub API: exact zip match ${exact.name}`);
|
|
254
|
+
logUpdater("github-api", `picked exact asset url=${exact.browser_download_url.slice(0, 120)}…`);
|
|
255
|
+
return exact.browser_download_url;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const verLoose = String(version).trim();
|
|
259
|
+
const withVersion = candidates.filter((a) => a.name.includes(verLoose));
|
|
260
|
+
if (withVersion.length === 1 && withVersion[0].browser_download_url) {
|
|
261
|
+
log(`[updater] GitHub API: single zip matching version ${verLoose}: ${withVersion[0].name}`);
|
|
262
|
+
logUpdater("github-api", `picked version-match url=${withVersion[0].browser_download_url.slice(0, 120)}…`);
|
|
263
|
+
return withVersion[0].browser_download_url;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const prefixed = candidates.find(
|
|
267
|
+
(a) =>
|
|
268
|
+
a.name.startsWith(WIN_PORTABLE_ZIP_PREFIX) ||
|
|
269
|
+
brand.portableZipAssetPattern().test(a.name) ||
|
|
270
|
+
/Hyperlinks\s*Space/i.test(a.name),
|
|
271
|
+
);
|
|
272
|
+
if (prefixed?.browser_download_url) {
|
|
273
|
+
log(`[updater] GitHub API: portable-like zip ${prefixed.name}`);
|
|
274
|
+
logUpdater("github-api", `picked portable-like url=${prefixed.browser_download_url.slice(0, 120)}…`);
|
|
275
|
+
return prefixed.browser_download_url;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (candidates.length === 1 && candidates[0].browser_download_url) {
|
|
279
|
+
log(`[updater] GitHub API: only zip on release: ${candidates[0].name}`);
|
|
280
|
+
logUpdater("github-api", `picked sole zip url=${candidates[0].browser_download_url.slice(0, 120)}…`);
|
|
281
|
+
return candidates[0].browser_download_url;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
log(
|
|
285
|
+
`[updater] GitHub API: could not pick zip (candidates: ${candidates.map((c) => c.name).join(", ") || "none"})`,
|
|
286
|
+
);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function downloadToFile(netFetch, url, destPath, onProgress) {
|
|
291
|
+
logUpdater("download", `start → ${destPath}`);
|
|
292
|
+
logUpdater("download", `GET ${url.length > 200 ? `${url.slice(0, 200)}…` : url}`);
|
|
293
|
+
const res = await netFetch(url);
|
|
294
|
+
if (!res.ok) {
|
|
295
|
+
throw new Error(`Download failed ${res.status} ${url}`);
|
|
296
|
+
}
|
|
297
|
+
const total =
|
|
298
|
+
parseInt(res.headers.get("content-length") || res.headers.get("Content-Length") || "0", 10) || 0;
|
|
299
|
+
const reader = res.body?.getReader?.();
|
|
300
|
+
if (!reader) {
|
|
301
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
302
|
+
if (onProgress) onProgress(buf.length, total || buf.length);
|
|
303
|
+
fs.writeFileSync(destPath, buf);
|
|
304
|
+
logUpdater("download", `done bytes=${buf.length} (buffer path) → ${destPath}`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const ws = fs.createWriteStream(destPath);
|
|
308
|
+
let received = 0;
|
|
309
|
+
try {
|
|
310
|
+
for (;;) {
|
|
311
|
+
const { done, value } = await reader.read();
|
|
312
|
+
if (done) break;
|
|
313
|
+
if (value && value.length) {
|
|
314
|
+
received += value.length;
|
|
315
|
+
if (!ws.write(Buffer.from(value))) {
|
|
316
|
+
await new Promise((res) => ws.once("drain", res));
|
|
317
|
+
}
|
|
318
|
+
if (onProgress) onProgress(received, total);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
await new Promise((resolve, reject) => {
|
|
323
|
+
ws.end((err) => (err ? reject(err) : resolve()));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
let sizeOnDisk = received;
|
|
327
|
+
try {
|
|
328
|
+
sizeOnDisk = fs.statSync(destPath).size;
|
|
329
|
+
} catch (_) {}
|
|
330
|
+
logUpdater("download", `done bytes=${sizeOnDisk} streamed=${received} totalHdr=${total || "?"} → ${destPath}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Unpack portable app .zip. On Windows, prefer system tar.exe (native I/O; avoids long
|
|
335
|
+
* apparent stalls streaming huge files through Node). Fall back to extract-zip.
|
|
336
|
+
* Pulse callback keeps the updater UI moving during large single-file writes (e.g. app.asar).
|
|
337
|
+
*/
|
|
338
|
+
/**
|
|
339
|
+
* @param {object} [opts]
|
|
340
|
+
* @param {string} [opts.verifyExeBase] If set, after system tar succeeds we require resolveZipAppContentRoot
|
|
341
|
+
* to find the app; otherwise we clear and fall back to extract-zip (tar can exit 0 with a bad tree for some zips).
|
|
342
|
+
*/
|
|
343
|
+
async function extractPortableZipToDir(zipPath, extractDir, logFn, pulse, unpackLo, unpackHi, opts = {}) {
|
|
344
|
+
const verifyExeBase = opts.verifyExeBase;
|
|
345
|
+
const runExtractZip = async () => {
|
|
346
|
+
logUpdater("extract", `extract-zip (yauzl) → ${extractDir}`);
|
|
347
|
+
const extractZip = require("extract-zip");
|
|
348
|
+
let unpackEntryCount = 0;
|
|
349
|
+
let unpackLastName = "";
|
|
350
|
+
const pulseUnpack = () => {
|
|
351
|
+
const span = Math.max(1, unpackHi - unpackLo);
|
|
352
|
+
const bump = Math.min(span, 4 + Math.floor(unpackEntryCount / 30));
|
|
353
|
+
const pct = Math.min(unpackHi, unpackLo + bump);
|
|
354
|
+
pulse({
|
|
355
|
+
text:
|
|
356
|
+
unpackEntryCount > 0
|
|
357
|
+
? `${pct}% — Unpacking… ${unpackEntryCount} files${unpackLastName ? ` — ${unpackLastName.slice(-56)}` : ""}`
|
|
358
|
+
: `${pct}% — Unpacking… starting`,
|
|
359
|
+
percent: pct,
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
const unpackHeartbeat = setInterval(pulseUnpack, 2800);
|
|
363
|
+
const t0 = Date.now();
|
|
364
|
+
logFn(`[updater] extract-zip begin → ${extractDir}`);
|
|
365
|
+
try {
|
|
366
|
+
await extractZip(zipPath, {
|
|
367
|
+
dir: extractDir,
|
|
368
|
+
onEntry: (entry) => {
|
|
369
|
+
unpackEntryCount += 1;
|
|
370
|
+
unpackLastName = entry.fileName || "";
|
|
371
|
+
if (unpackEntryCount <= 4 || unpackEntryCount % 40 === 0) {
|
|
372
|
+
setImmediate(() => pulseUnpack());
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
} finally {
|
|
377
|
+
clearInterval(unpackHeartbeat);
|
|
378
|
+
}
|
|
379
|
+
pulse({
|
|
380
|
+
text: `${unpackHi}% — Unpacking… (done)`,
|
|
381
|
+
percent: unpackHi,
|
|
382
|
+
});
|
|
383
|
+
logFn(`[updater] extract-zip done in ${Date.now() - t0}ms (${unpackEntryCount} entries)`);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (process.platform === "win32") {
|
|
387
|
+
const tarExe = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe");
|
|
388
|
+
if (fs.existsSync(tarExe)) {
|
|
389
|
+
logUpdater("extract", `try system tar first ${tarExe}`);
|
|
390
|
+
try {
|
|
391
|
+
const t0 = Date.now();
|
|
392
|
+
logFn(`[updater] extracting with ${tarExe}`);
|
|
393
|
+
let tarPct = unpackLo;
|
|
394
|
+
const hb = setInterval(() => {
|
|
395
|
+
tarPct = Math.min(
|
|
396
|
+
unpackHi,
|
|
397
|
+
tarPct + Math.max(1, Math.round((unpackHi - unpackLo) / 35)),
|
|
398
|
+
);
|
|
399
|
+
pulse({
|
|
400
|
+
text: `${tarPct}% — Unpacking… (system archiver)`,
|
|
401
|
+
percent: tarPct,
|
|
402
|
+
});
|
|
403
|
+
}, 450);
|
|
404
|
+
try {
|
|
405
|
+
await new Promise((resolve, reject) => {
|
|
406
|
+
const child = spawn(tarExe, ["-xf", zipPath, "-C", extractDir], {
|
|
407
|
+
windowsHide: true,
|
|
408
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
409
|
+
});
|
|
410
|
+
logUpdater(
|
|
411
|
+
"extract",
|
|
412
|
+
`system tar pid=${child.pid} cmd=tar -xf <zip> -C <extractDir> zip=${path.basename(zipPath)}`,
|
|
413
|
+
);
|
|
414
|
+
let errBuf = "";
|
|
415
|
+
child.stderr?.on("data", (d) => {
|
|
416
|
+
errBuf += d.toString();
|
|
417
|
+
});
|
|
418
|
+
child.on("error", reject);
|
|
419
|
+
child.on("close", (code) => {
|
|
420
|
+
logUpdater("extract", `system tar pid=${child.pid} exit=${code}`);
|
|
421
|
+
if (code === 0) resolve();
|
|
422
|
+
else reject(new Error(`tar.exe exited ${code}${errBuf ? `: ${errBuf.slice(-500)}` : ""}`));
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
} finally {
|
|
426
|
+
clearInterval(hb);
|
|
427
|
+
}
|
|
428
|
+
pulse({
|
|
429
|
+
text: `${unpackHi}% — Unpacking… (done)`,
|
|
430
|
+
percent: unpackHi,
|
|
431
|
+
});
|
|
432
|
+
logFn(`[updater] system tar done in ${Date.now() - t0}ms`);
|
|
433
|
+
if (verifyExeBase) {
|
|
434
|
+
const root = resolveZipAppContentRoot(extractDir, verifyExeBase);
|
|
435
|
+
if (!root) {
|
|
436
|
+
logFn(
|
|
437
|
+
`[updater] system tar left no recognizable main exe (wanted basename like ${verifyExeBase}); clearing extract dir and using extract-zip`,
|
|
438
|
+
);
|
|
439
|
+
logUpdater("extract", "tar output verification failed → extract-zip");
|
|
440
|
+
try {
|
|
441
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
442
|
+
} catch (_) {}
|
|
443
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
444
|
+
await runExtractZip();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
} catch (e) {
|
|
450
|
+
logFn(`[updater] system tar failed (${e?.message || e}); clearing partial extract, retrying with extract-zip`);
|
|
451
|
+
try {
|
|
452
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
453
|
+
} catch (_) {}
|
|
454
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
logUpdater("extract", `tar.exe not present (${tarExe}) → extract-zip`);
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
logUpdater("extract", "non-Windows → extract-zip only");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
await runExtractZip();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function sha512Base64OfFile(filePath) {
|
|
467
|
+
const hash = crypto.createHash("sha512");
|
|
468
|
+
hash.update(fs.readFileSync(filePath));
|
|
469
|
+
return hash.digest("base64");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Portable zip main exe name may differ from the running process (spaced vs compact).
|
|
474
|
+
* Must match resolveZipAppContentRoot / apply relaunch candidates.
|
|
475
|
+
*/
|
|
476
|
+
function winStagingDirHasMainExe(stagingDir, exeBaseName) {
|
|
477
|
+
const alt = new Set([exeBaseName, ...brand.allKnownExeBaseNames()]);
|
|
478
|
+
for (const name of alt) {
|
|
479
|
+
const p = path.join(stagingDir, name);
|
|
480
|
+
try {
|
|
481
|
+
if (fs.existsSync(p) && fs.statSync(p).isFile()) return true;
|
|
482
|
+
} catch (_) {}
|
|
483
|
+
}
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function resolveZipAppContentRoot(extractDir, exeBaseName) {
|
|
488
|
+
const direct = path.join(extractDir, exeBaseName);
|
|
489
|
+
if (fs.existsSync(direct)) return extractDir;
|
|
490
|
+
|
|
491
|
+
/** Names to treat as the main app exe (portable zip vs running binary name can differ). */
|
|
492
|
+
const altNames = new Set([exeBaseName]);
|
|
493
|
+
if (process.platform === "win32") {
|
|
494
|
+
for (const n of brand.allKnownExeBaseNames()) altNames.add(n);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const matchesMainExe = (fileName) => {
|
|
498
|
+
const lower = fileName.toLowerCase();
|
|
499
|
+
for (const n of altNames) {
|
|
500
|
+
if (lower === n.toLowerCase()) return true;
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/** Prefer shallowest match; skip common subtrees that are not the main exe. */
|
|
506
|
+
const hits = [];
|
|
507
|
+
const MAX_DEPTH = 6;
|
|
508
|
+
const walk = (dir, depth) => {
|
|
509
|
+
if (depth > MAX_DEPTH) return;
|
|
510
|
+
let entries = [];
|
|
511
|
+
try {
|
|
512
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
513
|
+
} catch (_) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
for (const ent of entries) {
|
|
517
|
+
if (!ent.isFile()) continue;
|
|
518
|
+
if (!/\.exe$/i.test(ent.name)) continue;
|
|
519
|
+
if (matchesMainExe(ent.name)) hits.push({ root: dir, depth });
|
|
520
|
+
}
|
|
521
|
+
for (const ent of entries) {
|
|
522
|
+
if (!ent.isDirectory()) continue;
|
|
523
|
+
const n = ent.name.toLowerCase();
|
|
524
|
+
if (n === "resources" || n === "locales") continue;
|
|
525
|
+
walk(path.join(dir, ent.name), depth + 1);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
walk(extractDir, 0);
|
|
529
|
+
if (hits.length === 0) return null;
|
|
530
|
+
hits.sort((a, b) => a.depth - b.depth || a.root.length - b.root.length);
|
|
531
|
+
return hits[0].root;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* After a successful version switch, remove staged builds older than the running app
|
|
536
|
+
* (and same-version leftovers if the apply script already removed the folder).
|
|
537
|
+
*/
|
|
538
|
+
function scheduleVersionsFolderCleanup() {
|
|
539
|
+
if (isDev || !app.isPackaged || process.platform !== "win32") return;
|
|
540
|
+
const current = app.getVersion();
|
|
541
|
+
setTimeout(() => {
|
|
542
|
+
try {
|
|
543
|
+
logUpdater("cleanup", `versions folder sweep (current=${current})`);
|
|
544
|
+
const sweep = (versionsRoot, label) => {
|
|
545
|
+
if (!fs.existsSync(versionsRoot)) return;
|
|
546
|
+
for (const name of fs.readdirSync(versionsRoot)) {
|
|
547
|
+
const full = path.join(versionsRoot, name);
|
|
548
|
+
let st;
|
|
549
|
+
try {
|
|
550
|
+
st = fs.statSync(full);
|
|
551
|
+
} catch (_) {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!st.isDirectory()) continue;
|
|
555
|
+
// Staging for builds older than the running app (previous releases).
|
|
556
|
+
if (compareSemverLike(name, current) < 0) {
|
|
557
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
558
|
+
log(`[updater] removed old staged folder (${label}): ${name}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
sweep(path.join(app.getPath("userData"), "pending-update-versions"), "userData");
|
|
563
|
+
sweep(path.join(getWindowsAppRootFromExecPath(process.execPath), "versions"), "installDir versions");
|
|
564
|
+
} catch (e) {
|
|
565
|
+
log(`[updater] versions cleanup: ${e?.message || e}`);
|
|
566
|
+
}
|
|
567
|
+
}, 5000);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const isDev = process.env.NODE_ENV === "development";
|
|
571
|
+
const updaterMenuApi = {
|
|
572
|
+
checkNow: null,
|
|
573
|
+
};
|
|
574
|
+
const updateDialogState = {
|
|
575
|
+
window: null,
|
|
576
|
+
installEnabled: false,
|
|
577
|
+
ipcBound: false,
|
|
578
|
+
};
|
|
579
|
+
/** When true, skip app.quit() from window closed handlers so quitAndInstall can run first (avoids race + long hang). */
|
|
580
|
+
let suppressQuitForUpdateInstall = false;
|
|
581
|
+
|
|
582
|
+
/** Real path to icon.ico (prefer asar-unpacked so Windows can load it for the window/taskbar). */
|
|
583
|
+
function resolveAppIconIcoPath() {
|
|
584
|
+
const candidates = [
|
|
585
|
+
process.resourcesPath && path.join(process.resourcesPath, "icon.ico"),
|
|
586
|
+
process.resourcesPath && path.join(process.resourcesPath, "app.asar.unpacked", "assets", "icon.ico"),
|
|
587
|
+
process.resourcesPath && path.join(process.resourcesPath, "assets", "icon.ico"),
|
|
588
|
+
app.getAppPath && path.join(app.getAppPath(), "assets", "icon.ico"),
|
|
589
|
+
path.join(__dirname, "..", "assets", "icon.ico"),
|
|
590
|
+
].filter(Boolean);
|
|
591
|
+
for (const p of candidates) {
|
|
592
|
+
try {
|
|
593
|
+
if (fs.existsSync(p)) return p;
|
|
594
|
+
} catch (_) {}
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** All existing .ico paths (packaged app may have the file only inside app.asar — see nativeImage note below). */
|
|
600
|
+
function collectAppIconIcoCandidates() {
|
|
601
|
+
const candidates = [
|
|
602
|
+
process.resourcesPath && path.join(process.resourcesPath, "icon.ico"),
|
|
603
|
+
process.resourcesPath && path.join(process.resourcesPath, "app.asar.unpacked", "assets", "icon.ico"),
|
|
604
|
+
process.resourcesPath && path.join(process.resourcesPath, "assets", "icon.ico"),
|
|
605
|
+
app.getAppPath && path.join(app.getAppPath(), "assets", "icon.ico"),
|
|
606
|
+
path.join(__dirname, "..", "assets", "icon.ico"),
|
|
607
|
+
].filter(Boolean);
|
|
608
|
+
const out = [];
|
|
609
|
+
const seen = new Set();
|
|
610
|
+
for (const p of candidates) {
|
|
611
|
+
try {
|
|
612
|
+
if (!p || !fs.existsSync(p)) continue;
|
|
613
|
+
const n = path.normalize(p);
|
|
614
|
+
if (seen.has(n)) continue;
|
|
615
|
+
seen.add(n);
|
|
616
|
+
out.push(n);
|
|
617
|
+
} catch (_) {}
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Paths under app.asar\... are real for Node (readFileSync) but not for nativeImage.createFromPath /
|
|
624
|
+
* app.getFileIcon (shell/GDI cannot read inside the asar archive). Always prefer readFileSync + createFromBuffer for .ico.
|
|
625
|
+
*/
|
|
626
|
+
function nativeImageFromIcoFilePath(p) {
|
|
627
|
+
if (!p) return null;
|
|
628
|
+
try {
|
|
629
|
+
if (!fs.existsSync(p)) return null;
|
|
630
|
+
const buf = fs.readFileSync(p);
|
|
631
|
+
const img = nativeImage.createFromBuffer(buf);
|
|
632
|
+
return img.isEmpty() ? null : img;
|
|
633
|
+
} catch (_) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function nativeImageFromAppIcon() {
|
|
639
|
+
const paths = collectAppIconIcoCandidates();
|
|
640
|
+
for (const p of paths) {
|
|
641
|
+
const img = nativeImageFromIcoFilePath(p);
|
|
642
|
+
if (img) return img;
|
|
643
|
+
}
|
|
644
|
+
for (const p of paths) {
|
|
645
|
+
try {
|
|
646
|
+
const inAsarArchive = p.includes("app.asar") && !p.includes("app.asar.unpacked");
|
|
647
|
+
if (inAsarArchive) continue;
|
|
648
|
+
const img = nativeImage.createFromPath(p);
|
|
649
|
+
if (!img.isEmpty()) return img;
|
|
650
|
+
} catch (_) {}
|
|
651
|
+
}
|
|
652
|
+
if (process.platform === "win32" && app.isPackaged) {
|
|
653
|
+
try {
|
|
654
|
+
const img = nativeImage.createFromPath(process.execPath);
|
|
655
|
+
if (!img.isEmpty()) return img;
|
|
656
|
+
} catch (_) {}
|
|
657
|
+
}
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function resolveNotificationIcon() {
|
|
662
|
+
const candidates = [
|
|
663
|
+
resolveAppIconIcoPath(),
|
|
664
|
+
process.resourcesPath && path.join(process.resourcesPath, "icon.ico"),
|
|
665
|
+
path.join(process.resourcesPath || "", "assets", "icon.ico"),
|
|
666
|
+
path.join(app.getAppPath(), "assets", "icon.ico"),
|
|
667
|
+
app.getPath("exe"),
|
|
668
|
+
].filter(Boolean);
|
|
669
|
+
return candidates.find((p) => {
|
|
670
|
+
try {
|
|
671
|
+
return p && fs.existsSync(p);
|
|
672
|
+
} catch (_) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// One running instance on Windows: avoids two Electron processes during NSIS upgrade.
|
|
679
|
+
if (!isDev && process.platform === "win32") {
|
|
680
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
681
|
+
if (!gotLock) {
|
|
682
|
+
app.quit();
|
|
683
|
+
process.exit(0);
|
|
684
|
+
}
|
|
685
|
+
app.on("second-instance", () => {
|
|
686
|
+
const win = BrowserWindow.getAllWindows()[0];
|
|
687
|
+
if (win) {
|
|
688
|
+
if (win.isMinimized()) win.restore();
|
|
689
|
+
win.focus();
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function setupAutoUpdater() {
|
|
695
|
+
if (isDev || !app.isPackaged) return;
|
|
696
|
+
try {
|
|
697
|
+
const { autoUpdater } = require("electron-updater");
|
|
698
|
+
// Without this, checkForUpdates reads resources/app-update.yml (embedded by electron-builder, absent in Forge installs).
|
|
699
|
+
autoUpdater.setFeedURL({
|
|
700
|
+
provider: "github",
|
|
701
|
+
owner: UPDATE_GITHUB_OWNER,
|
|
702
|
+
repo: UPDATE_GITHUB_REPO,
|
|
703
|
+
});
|
|
704
|
+
let manualCheckInProgress = false;
|
|
705
|
+
let manualDownloadInProgress = false;
|
|
706
|
+
let updaterCheckRetrying = false;
|
|
707
|
+
|
|
708
|
+
const checkForUpdatesWithRetry = async (attempts = 4) => {
|
|
709
|
+
let lastErr;
|
|
710
|
+
for (let i = 0; i < attempts; i++) {
|
|
711
|
+
try {
|
|
712
|
+
logUpdater("check", `checkForUpdates attempt ${i + 1}/${attempts}`);
|
|
713
|
+
const result = await autoUpdater.checkForUpdates();
|
|
714
|
+
const u = result?.updateInfo ?? result;
|
|
715
|
+
logUpdater(
|
|
716
|
+
"check",
|
|
717
|
+
`checkForUpdates ok version=${u?.version ?? "?"} release=${u?.releaseDate ?? "?"} ` +
|
|
718
|
+
`downloadURL=${u?.downloadUrl ?? u?.path ?? "?"}`,
|
|
719
|
+
);
|
|
720
|
+
return result;
|
|
721
|
+
} catch (e) {
|
|
722
|
+
lastErr = e;
|
|
723
|
+
logUpdater("check", `checkForUpdates error attempt ${i + 1}: ${e?.message || e}`);
|
|
724
|
+
if (!isTransientGithubUpdateError(e) || i === attempts - 1) throw e;
|
|
725
|
+
const delayMs = 1500 * 2 ** i;
|
|
726
|
+
log(
|
|
727
|
+
`[updater] transient GitHub/update error (${i + 1}/${attempts}), retry in ${delayMs}ms: ${e?.message || e}`,
|
|
728
|
+
);
|
|
729
|
+
await sleep(delayMs);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
throw lastErr;
|
|
733
|
+
};
|
|
734
|
+
const currentVersion = app.getVersion();
|
|
735
|
+
const applyUserLogPath = path.join(app.getPath("userData"), "hsp-update-apply.log");
|
|
736
|
+
|
|
737
|
+
/** Prefer transferred/total when known; Windows often keeps percent at 0 until late. */
|
|
738
|
+
const progressPercent = (progress) => {
|
|
739
|
+
if (!progress || typeof progress !== "object") return 0;
|
|
740
|
+
const total = progress.total;
|
|
741
|
+
const transferred = progress.transferred ?? 0;
|
|
742
|
+
if (typeof total === "number" && total > 0) {
|
|
743
|
+
return Math.max(0, Math.min(100, (100 * transferred) / total));
|
|
744
|
+
}
|
|
745
|
+
const p = progress.percent;
|
|
746
|
+
if (typeof p === "number" && !Number.isNaN(p)) {
|
|
747
|
+
if (p > 0 && p <= 1) {
|
|
748
|
+
return Math.max(0, Math.min(100, p * 100));
|
|
749
|
+
}
|
|
750
|
+
return Math.max(0, Math.min(100, p));
|
|
751
|
+
}
|
|
752
|
+
return 0;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// Single content height: always leave room for the activity log so it is not clipped when progress/actions hide.
|
|
756
|
+
const UPDATER_LOG_PANEL = 108;
|
|
757
|
+
const UPDATER_DIALOG_H = 198 + UPDATER_LOG_PANEL;
|
|
758
|
+
|
|
759
|
+
const sendUpdaterLogInitToDialog = () => {
|
|
760
|
+
const w = updateDialogState.window;
|
|
761
|
+
if (!w || w.isDestroyed()) return;
|
|
762
|
+
try {
|
|
763
|
+
w.webContents.send("updater-log-init", updaterDialogLogBuffer.slice());
|
|
764
|
+
} catch (_) {}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
updaterLogToDialog = (line) => {
|
|
768
|
+
const w = updateDialogState.window;
|
|
769
|
+
if (!w || w.isDestroyed()) return;
|
|
770
|
+
const wc = w.webContents;
|
|
771
|
+
const send = () => {
|
|
772
|
+
try {
|
|
773
|
+
wc.send("updater-log", line);
|
|
774
|
+
} catch (_) {}
|
|
775
|
+
};
|
|
776
|
+
if (wc.isLoading()) wc.once("did-finish-load", send);
|
|
777
|
+
else send();
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
/** Set after syncZipReadyUi / stagingHasMainExe; enables main-process installEnabled when opening the dialog. */
|
|
781
|
+
let refreshUpdaterDialogIfStagedReady = () => {};
|
|
782
|
+
|
|
783
|
+
const openOrFocusUpdateDialog = () => {
|
|
784
|
+
if (updateDialogState.window && !updateDialogState.window.isDestroyed()) {
|
|
785
|
+
updateDialogState.window.show();
|
|
786
|
+
updateDialogState.window.focus();
|
|
787
|
+
sendUpdaterLogInitToDialog();
|
|
788
|
+
refreshUpdaterDialogIfStagedReady();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
updateDialogState.window = new BrowserWindow({
|
|
792
|
+
width: 420,
|
|
793
|
+
height: UPDATER_DIALOG_H,
|
|
794
|
+
useContentSize: true,
|
|
795
|
+
title: "Updater",
|
|
796
|
+
resizable: false,
|
|
797
|
+
minimizable: false,
|
|
798
|
+
maximizable: false,
|
|
799
|
+
show: false,
|
|
800
|
+
autoHideMenuBar: true,
|
|
801
|
+
// No parent: a child window + showMessageBox(modal to child) often leaves alerts behind the main frame.
|
|
802
|
+
modal: false,
|
|
803
|
+
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false },
|
|
804
|
+
});
|
|
805
|
+
const updaterHtmlPath = path.join(__dirname, "updater-dialog.html");
|
|
806
|
+
if (!fs.existsSync(updaterHtmlPath)) {
|
|
807
|
+
log(`[updater] FATAL: updater-dialog.html missing at ${updaterHtmlPath}`);
|
|
808
|
+
}
|
|
809
|
+
updateDialogState.window.loadFile(updaterHtmlPath);
|
|
810
|
+
updateDialogState.window.webContents.once("did-finish-load", () => {
|
|
811
|
+
const w = updateDialogState.window;
|
|
812
|
+
if (!w || w.isDestroyed()) return;
|
|
813
|
+
const wc = w.webContents;
|
|
814
|
+
const cvText = `Current version: ${currentVersion}`;
|
|
815
|
+
wc.executeJavaScript(`document.getElementById('cv').textContent = ${JSON.stringify(cvText)}`).catch(() => {});
|
|
816
|
+
sendUpdaterLogInitToDialog();
|
|
817
|
+
refreshUpdaterDialogIfStagedReady();
|
|
818
|
+
});
|
|
819
|
+
updateDialogState.window.once("ready-to-show", () => {
|
|
820
|
+
if (updateDialogState.window && !updateDialogState.window.isDestroyed()) updateDialogState.window.show();
|
|
821
|
+
});
|
|
822
|
+
updateDialogState.window.on("closed", () => {
|
|
823
|
+
updateDialogState.window = null;
|
|
824
|
+
});
|
|
825
|
+
};
|
|
826
|
+
/**
|
|
827
|
+
* @param {object} opts
|
|
828
|
+
* @param {string} opts.text
|
|
829
|
+
* @param {number} [opts.percent]
|
|
830
|
+
* @param {boolean} [opts.showProgress]
|
|
831
|
+
* @param {boolean} [opts.showActions] Update button row (when false: version + text only; dismiss via title bar X)
|
|
832
|
+
* @param {boolean} [opts.installEnabled]
|
|
833
|
+
*/
|
|
834
|
+
const updateDialogUi = ({ text, percent = 0, showProgress = false, showActions = false, installEnabled = false }) => {
|
|
835
|
+
if (!updateDialogState.window || updateDialogState.window.isDestroyed()) return;
|
|
836
|
+
const safe = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
|
|
837
|
+
updateDialogState.installEnabled = Boolean(installEnabled);
|
|
838
|
+
try {
|
|
839
|
+
updateDialogState.window.setSize(420, UPDATER_DIALOG_H);
|
|
840
|
+
} catch (_) {}
|
|
841
|
+
const payload = {
|
|
842
|
+
text,
|
|
843
|
+
percent: safe,
|
|
844
|
+
showProgress,
|
|
845
|
+
showActions,
|
|
846
|
+
installEnabled: Boolean(installEnabled),
|
|
847
|
+
};
|
|
848
|
+
const wc = updateDialogState.window.webContents;
|
|
849
|
+
const send = () => {
|
|
850
|
+
try {
|
|
851
|
+
wc.send("updater-ui", payload);
|
|
852
|
+
} catch (e) {
|
|
853
|
+
log(`[updater] updateDialogUi send: ${e?.message || e}`);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
// IPC survives rapid download-progress; executeJavaScript could drop or race with load state.
|
|
857
|
+
if (wc.isLoading()) {
|
|
858
|
+
wc.once("did-finish-load", send);
|
|
859
|
+
} else {
|
|
860
|
+
send();
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
const closeUpdateDialog = () => {
|
|
864
|
+
if (updateDialogState.window && !updateDialogState.window.isDestroyed()) updateDialogState.window.close();
|
|
865
|
+
updateDialogState.window = null;
|
|
866
|
+
updateDialogState.installEnabled = false;
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
/** Prefer main/browser window so native dialogs are not hidden behind a frame owned as child. */
|
|
870
|
+
const focusMainWindowForDialog = () => {
|
|
871
|
+
const all = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed());
|
|
872
|
+
const mainLike = all.find((w) => w !== updateDialogState.window) ?? all[0];
|
|
873
|
+
try {
|
|
874
|
+
if (mainLike) {
|
|
875
|
+
if (mainLike.isMinimized()) mainLike.restore();
|
|
876
|
+
mainLike.focus();
|
|
877
|
+
}
|
|
878
|
+
} catch (_) {}
|
|
879
|
+
return mainLike ?? null;
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
if (!updateDialogState.ipcBound) {
|
|
883
|
+
updateDialogState.ipcBound = true;
|
|
884
|
+
// invoke/handle is more reliable than send for click→main from file:// updater pages on some Electron builds.
|
|
885
|
+
ipcMain.handle("updater-install-now", () => {
|
|
886
|
+
logUpdater("ipc", "updater-install-now (invoke)");
|
|
887
|
+
try {
|
|
888
|
+
requestInstallNow();
|
|
889
|
+
return { ok: true };
|
|
890
|
+
} catch (e) {
|
|
891
|
+
const m = e?.message || String(e);
|
|
892
|
+
log(`[updater] requestInstallNow threw: ${m}`);
|
|
893
|
+
return { ok: false, err: m };
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
ipcMain.on("updater-install-click", () => {
|
|
897
|
+
logUpdater("ipc", "updater-install-click received (legacy send)");
|
|
898
|
+
requestInstallNow();
|
|
899
|
+
});
|
|
900
|
+
ipcMain.on("updater-renderer-error", (_e, msg) => {
|
|
901
|
+
log(`[updater] renderer: ${typeof msg === "string" ? msg : String(msg)}`);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const logUpdaterChannel = (m) => {
|
|
905
|
+
const body = `[updater] ${typeof m === "string" ? m : JSON.stringify(m)}`;
|
|
906
|
+
log(body);
|
|
907
|
+
appendUpdaterDialogLogLine(body);
|
|
908
|
+
};
|
|
909
|
+
autoUpdater.logger = {
|
|
910
|
+
info: logUpdaterChannel,
|
|
911
|
+
warn: logUpdaterChannel,
|
|
912
|
+
error: logUpdaterChannel,
|
|
913
|
+
debug: logUpdaterChannel,
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const useWinVersionsSidecar = process.platform === "win32";
|
|
917
|
+
let zipPrepareInFlight = false;
|
|
918
|
+
let zipReadyVersion = null;
|
|
919
|
+
let zipStagingContentPath = null;
|
|
920
|
+
|
|
921
|
+
autoUpdater.autoDownload = !useWinVersionsSidecar;
|
|
922
|
+
// Windows: never install a downloaded NSIS on quit — in-app Update uses staged zip + robocopy only.
|
|
923
|
+
autoUpdater.autoInstallOnAppQuit = !useWinVersionsSidecar;
|
|
924
|
+
autoUpdater.autoRunAppAfterInstall = true;
|
|
925
|
+
log(
|
|
926
|
+
`[updater] initialized (github, winVersions=${useWinVersionsSidecar}, autoDownload=${autoUpdater.autoDownload})`,
|
|
927
|
+
);
|
|
928
|
+
logUpdater(
|
|
929
|
+
"init",
|
|
930
|
+
`repo=${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO} app=${currentVersion} platform=${process.platform} ` +
|
|
931
|
+
`winZipSidecar=${useWinVersionsSidecar} autoDownload=${autoUpdater.autoDownload} autoInstallOnQuit=${autoUpdater.autoInstallOnAppQuit}`,
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
let installRequested = false;
|
|
935
|
+
|
|
936
|
+
const syncZipReadyUi = (v) => {
|
|
937
|
+
if (!updateDialogState.window || updateDialogState.window.isDestroyed()) return;
|
|
938
|
+
updateDialogUi({
|
|
939
|
+
text: `Update ${v} is ready. Click "Update with reload" to close and open the new version.`,
|
|
940
|
+
percent: 100,
|
|
941
|
+
showProgress: true,
|
|
942
|
+
showActions: true,
|
|
943
|
+
installEnabled: true,
|
|
944
|
+
});
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const stagingHasMainExe = (stagingDir) => {
|
|
948
|
+
const exeBase = path.basename(process.execPath);
|
|
949
|
+
if (process.platform === "win32") {
|
|
950
|
+
return winStagingDirHasMainExe(stagingDir, exeBase);
|
|
951
|
+
}
|
|
952
|
+
const direct = path.join(stagingDir, exeBase);
|
|
953
|
+
if (fs.existsSync(direct)) return true;
|
|
954
|
+
try {
|
|
955
|
+
const want = exeBase.toLowerCase();
|
|
956
|
+
return fs.readdirSync(stagingDir).some((n) => n.toLowerCase() === want);
|
|
957
|
+
} catch (_) {
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
refreshUpdaterDialogIfStagedReady = () => {
|
|
963
|
+
if (!useWinVersionsSidecar || !zipReadyVersion || !zipStagingContentPath) return;
|
|
964
|
+
if (!stagingHasMainExe(zipStagingContentPath)) return;
|
|
965
|
+
syncZipReadyUi(zipReadyVersion);
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const getVersionsStagingRoot = () => path.join(app.getPath("userData"), "pending-update-versions");
|
|
969
|
+
|
|
970
|
+
const restoreVersionsStagingFromDisk = () => {
|
|
971
|
+
const root = getVersionsStagingRoot();
|
|
972
|
+
logUpdater("staging", `restore scan root=${root}`);
|
|
973
|
+
if (!fs.existsSync(root)) {
|
|
974
|
+
logUpdater("staging", "restore skip (root missing)");
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const exeBase = path.basename(process.execPath);
|
|
978
|
+
let bestVer = null;
|
|
979
|
+
let bestContent = null;
|
|
980
|
+
for (const name of fs.readdirSync(root)) {
|
|
981
|
+
const full = path.join(root, name);
|
|
982
|
+
let st;
|
|
983
|
+
try {
|
|
984
|
+
st = fs.statSync(full);
|
|
985
|
+
} catch (_) {
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (!st.isDirectory()) continue;
|
|
989
|
+
if (compareSemverLike(name, currentVersion) <= 0) continue;
|
|
990
|
+
const extractDir = path.join(full, "extract");
|
|
991
|
+
if (!fs.existsSync(extractDir)) continue;
|
|
992
|
+
const contentRoot = resolveZipAppContentRoot(extractDir, exeBase);
|
|
993
|
+
if (!contentRoot || !stagingHasMainExe(contentRoot)) continue;
|
|
994
|
+
if (!bestVer || compareSemverLike(name, bestVer) > 0) {
|
|
995
|
+
bestVer = name;
|
|
996
|
+
bestContent = contentRoot;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (bestVer && bestContent) {
|
|
1000
|
+
zipReadyVersion = bestVer;
|
|
1001
|
+
zipStagingContentPath = bestContent;
|
|
1002
|
+
log(`[updater] restored staging from disk: ${bestVer} -> ${bestContent}`);
|
|
1003
|
+
logUpdater("staging", `restore picked version=${bestVer} contentRoot=${bestContent}`);
|
|
1004
|
+
} else {
|
|
1005
|
+
logUpdater("staging", "restore no valid staged build found");
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
restoreVersionsStagingFromDisk();
|
|
1010
|
+
|
|
1011
|
+
const tryBeginVersionsPrepare = async (info, opts) => {
|
|
1012
|
+
const remoteV = info?.version;
|
|
1013
|
+
logUpdater(
|
|
1014
|
+
"prepare",
|
|
1015
|
+
`tryBeginVersionsPrepare enter remote=${remoteV || "?"} feed=${safeJson(info)} opts=${safeJson(opts)}`,
|
|
1016
|
+
);
|
|
1017
|
+
if (!useWinVersionsSidecar) {
|
|
1018
|
+
logUpdater("prepare", "skip (not Windows zip sidecar mode)");
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (!remoteV || compareSemverLike(remoteV, currentVersion) <= 0) {
|
|
1022
|
+
logUpdater("prepare", `skip (no remote or not newer remote=${remoteV} current=${currentVersion})`);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (zipPrepareInFlight) {
|
|
1026
|
+
logUpdater("prepare", "skip (zipPrepareInFlight)");
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const exeBase = path.basename(process.execPath);
|
|
1030
|
+
if (zipReadyVersion === remoteV && zipStagingContentPath && stagingHasMainExe(zipStagingContentPath)) {
|
|
1031
|
+
logUpdater("prepare", `skip (already staged ${remoteV})`);
|
|
1032
|
+
if (!updateDialogState.window || updateDialogState.window.isDestroyed()) {
|
|
1033
|
+
openOrFocusUpdateDialog();
|
|
1034
|
+
}
|
|
1035
|
+
syncZipReadyUi(remoteV);
|
|
1036
|
+
manualDownloadInProgress = false;
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
zipPrepareInFlight = true;
|
|
1040
|
+
logUpdater("prepare", `start pipeline → ${remoteV} exeBase=${exeBase}`);
|
|
1041
|
+
const uiManual = Boolean(opts?.uiManual);
|
|
1042
|
+
const uiActive =
|
|
1043
|
+
uiManual || (updateDialogState.window && !updateDialogState.window.isDestroyed());
|
|
1044
|
+
/** Overall 0–100: 0–81 download, 81–100 verify/unpack/finalize (single bar, monotonic). */
|
|
1045
|
+
let prepareProgressCeiling = 0;
|
|
1046
|
+
const pushUi = (partial) => {
|
|
1047
|
+
if (!uiActive) return;
|
|
1048
|
+
const raw = partial.percent;
|
|
1049
|
+
const n = typeof raw === "number" && !Number.isNaN(raw) ? raw : Number(raw);
|
|
1050
|
+
const next =
|
|
1051
|
+
typeof n === "number" && !Number.isNaN(n)
|
|
1052
|
+
? Math.max(prepareProgressCeiling, Math.round(Math.max(0, Math.min(100, n))))
|
|
1053
|
+
: prepareProgressCeiling;
|
|
1054
|
+
prepareProgressCeiling = next;
|
|
1055
|
+
updateDialogUi({
|
|
1056
|
+
showProgress: true,
|
|
1057
|
+
showActions: true,
|
|
1058
|
+
installEnabled: false,
|
|
1059
|
+
percent: 0,
|
|
1060
|
+
text: "",
|
|
1061
|
+
...partial,
|
|
1062
|
+
percent: next,
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
let versionsPrepareOk = false;
|
|
1066
|
+
try {
|
|
1067
|
+
const meta = await resolveWindowsZipSidecarMeta((u) => net.fetch(u), currentVersion);
|
|
1068
|
+
if (meta.version !== remoteV) {
|
|
1069
|
+
log(`[updater] sidecar version ${meta.version} vs feed ${remoteV} (using sidecar manifest)`);
|
|
1070
|
+
}
|
|
1071
|
+
log(`[updater] sidecar source: ${meta.source} → ${meta.fileName}`);
|
|
1072
|
+
|
|
1073
|
+
// One bar: 81% for download, 19% for verify + unpack + finalize (overall 0–100).
|
|
1074
|
+
const PREP_PCT_DOWNLOAD_MAX = 81;
|
|
1075
|
+
const PREP_PCT_VERIFY_END = 87;
|
|
1076
|
+
const PREP_UNPACK_LO = 87;
|
|
1077
|
+
const PREP_UNPACK_HI = 99;
|
|
1078
|
+
pushUi({ text: "0% — Downloading update…", percent: 0 });
|
|
1079
|
+
|
|
1080
|
+
const versionsRoot = getVersionsStagingRoot();
|
|
1081
|
+
const versionDir = path.join(versionsRoot, meta.version);
|
|
1082
|
+
const extractDir = path.join(versionDir, "extract");
|
|
1083
|
+
logUpdater("prepare", `paths versionDir=${versionDir} extractDir=${extractDir}`);
|
|
1084
|
+
try {
|
|
1085
|
+
fs.rmSync(versionDir, { recursive: true, force: true });
|
|
1086
|
+
} catch (_) {}
|
|
1087
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
1088
|
+
|
|
1089
|
+
const zipPath = path.join(versionDir, meta.fileName);
|
|
1090
|
+
const primaryZipUrl = githubLatestAssetUrl(meta.fileName);
|
|
1091
|
+
logUpdater("prepare", `download primaryURL asset=${meta.fileName}`);
|
|
1092
|
+
let lastZipPush = 0;
|
|
1093
|
+
const onZipProgress = (received, total) => {
|
|
1094
|
+
const now = Date.now();
|
|
1095
|
+
const hasTotal = typeof total === "number" && total > 0;
|
|
1096
|
+
if (hasTotal && now - lastZipPush < 100 && received < total) return;
|
|
1097
|
+
if (!hasTotal && lastZipPush > 0 && now - lastZipPush < 150) return;
|
|
1098
|
+
lastZipPush = now;
|
|
1099
|
+
const mb = received / (1024 * 1024);
|
|
1100
|
+
let overall;
|
|
1101
|
+
if (hasTotal) {
|
|
1102
|
+
const dl = received / total;
|
|
1103
|
+
overall = Math.min(
|
|
1104
|
+
PREP_PCT_DOWNLOAD_MAX,
|
|
1105
|
+
Math.round(PREP_PCT_DOWNLOAD_MAX * dl),
|
|
1106
|
+
);
|
|
1107
|
+
} else {
|
|
1108
|
+
overall = Math.min(
|
|
1109
|
+
PREP_PCT_DOWNLOAD_MAX - 1,
|
|
1110
|
+
Math.round(PREP_PCT_DOWNLOAD_MAX * (1 - Math.exp(-mb / 55))),
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
pushUi({
|
|
1114
|
+
text: hasTotal
|
|
1115
|
+
? `${overall}% — Downloading update…`
|
|
1116
|
+
: `${overall}% — Downloading update… (~${mb.toFixed(1)} MB, size unknown)`,
|
|
1117
|
+
percent: overall,
|
|
1118
|
+
});
|
|
1119
|
+
};
|
|
1120
|
+
try {
|
|
1121
|
+
await downloadToFile((u) => net.fetch(u), primaryZipUrl, zipPath, onZipProgress);
|
|
1122
|
+
} catch (e) {
|
|
1123
|
+
const msg = String(e?.message || e);
|
|
1124
|
+
if (!/404/.test(msg)) throw e;
|
|
1125
|
+
const altUrl = await fetchPortableZipBrowserUrlFromGitHubApi(
|
|
1126
|
+
(u, init) => net.fetch(u, init),
|
|
1127
|
+
meta.version,
|
|
1128
|
+
meta.fileName,
|
|
1129
|
+
);
|
|
1130
|
+
if (!altUrl) throw e;
|
|
1131
|
+
log(`[updater] primary zip 404; downloading from GitHub API URL`);
|
|
1132
|
+
logUpdater("prepare", `download fallbackURL (API) → ${altUrl.length > 160 ? `${altUrl.slice(0, 160)}…` : altUrl}`);
|
|
1133
|
+
await downloadToFile((u) => net.fetch(u), altUrl, zipPath, onZipProgress);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
pushUi({ text: `${PREP_PCT_DOWNLOAD_MAX}% — Download finished`, percent: PREP_PCT_DOWNLOAD_MAX });
|
|
1137
|
+
|
|
1138
|
+
let verifyHb = null;
|
|
1139
|
+
try {
|
|
1140
|
+
let vPct = PREP_PCT_DOWNLOAD_MAX + 1;
|
|
1141
|
+
verifyHb = setInterval(() => {
|
|
1142
|
+
vPct = Math.min(PREP_PCT_VERIFY_END - 1, vPct + 1);
|
|
1143
|
+
pushUi({ text: `${vPct}% — Verifying update…`, percent: vPct });
|
|
1144
|
+
}, 350);
|
|
1145
|
+
pushUi({ text: `${PREP_PCT_DOWNLOAD_MAX + 1}% — Verifying update…`, percent: PREP_PCT_DOWNLOAD_MAX + 1 });
|
|
1146
|
+
|
|
1147
|
+
if (meta.sha512) {
|
|
1148
|
+
logUpdater("verify", "sha512 check (zip-latest)");
|
|
1149
|
+
const hash = sha512Base64OfFile(zipPath);
|
|
1150
|
+
if (hash !== meta.sha512) throw new Error("zip sha512 mismatch");
|
|
1151
|
+
logUpdater("verify", "sha512 ok");
|
|
1152
|
+
} else {
|
|
1153
|
+
log(
|
|
1154
|
+
"[updater] no sha512 manifest for zip (optional: add zip-latest.yml from cleanup for integrity check)",
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
} finally {
|
|
1158
|
+
if (verifyHb) clearInterval(verifyHb);
|
|
1159
|
+
}
|
|
1160
|
+
pushUi({ text: `${PREP_PCT_VERIFY_END}% — Verifying update…`, percent: PREP_PCT_VERIFY_END });
|
|
1161
|
+
|
|
1162
|
+
pushUi({
|
|
1163
|
+
text: `${PREP_UNPACK_LO}% — Unpacking update…`,
|
|
1164
|
+
percent: PREP_UNPACK_LO,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
await extractPortableZipToDir(zipPath, extractDir, log, pushUi, PREP_UNPACK_LO, PREP_UNPACK_HI, {
|
|
1168
|
+
verifyExeBase: exeBase,
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
pushUi({ text: "99% — Finalizing…", percent: 99 });
|
|
1172
|
+
|
|
1173
|
+
const contentRoot = resolveZipAppContentRoot(extractDir, exeBase);
|
|
1174
|
+
if (!contentRoot) throw new Error("extracted update has no app executable");
|
|
1175
|
+
logUpdater("prepare", `resolveZipAppContentRoot ok contentRoot=${contentRoot}`);
|
|
1176
|
+
|
|
1177
|
+
try {
|
|
1178
|
+
fs.unlinkSync(zipPath);
|
|
1179
|
+
} catch (_) {}
|
|
1180
|
+
logUpdater("prepare", `removed cached zip ${zipPath}`);
|
|
1181
|
+
|
|
1182
|
+
zipStagingContentPath = contentRoot;
|
|
1183
|
+
zipReadyVersion = meta.version;
|
|
1184
|
+
manualDownloadInProgress = false;
|
|
1185
|
+
log(`[updater] staged update at ${contentRoot}`);
|
|
1186
|
+
logUpdater("prepare", `COMPLETE readyVersion=${meta.version} staging=${contentRoot}`);
|
|
1187
|
+
// syncZipReadyUi needs an open dialog; background checks used uiActive=false and would skip UI.
|
|
1188
|
+
if (!uiActive) {
|
|
1189
|
+
openOrFocusUpdateDialog();
|
|
1190
|
+
}
|
|
1191
|
+
syncZipReadyUi(meta.version);
|
|
1192
|
+
if (!uiActive && process.platform === "win32" && Notification.isSupported()) {
|
|
1193
|
+
try {
|
|
1194
|
+
new Notification({
|
|
1195
|
+
title: brand.productDisplayName,
|
|
1196
|
+
body: `Update ${meta.version} is ready. Open Updates → Check for updates.`,
|
|
1197
|
+
}).show();
|
|
1198
|
+
} catch (_) {}
|
|
1199
|
+
}
|
|
1200
|
+
versionsPrepareOk = true;
|
|
1201
|
+
} catch (e) {
|
|
1202
|
+
const errMsg = e?.message || e;
|
|
1203
|
+
const errStack = typeof e?.stack === "string" ? e.stack : "";
|
|
1204
|
+
logUpdater("prepare", `FAILED ${errMsg}`);
|
|
1205
|
+
log(`[updater] versions sidecar failed: ${errMsg}`);
|
|
1206
|
+
if (errStack) log(`[updater] versions sidecar stack: ${errStack.split("\n").slice(0, 8).join(" | ")}`);
|
|
1207
|
+
log(
|
|
1208
|
+
`[updater] Ensure latest GitHub release includes latest.yml, ${WIN_PORTABLE_ZIP_PREFIX}<version>.zip (zip build), and optionally zip-latest.yml from cleanup for sha512.`,
|
|
1209
|
+
);
|
|
1210
|
+
zipStagingContentPath = null;
|
|
1211
|
+
zipReadyVersion = null;
|
|
1212
|
+
manualDownloadInProgress = false;
|
|
1213
|
+
const hint =
|
|
1214
|
+
`Update prepare failed: ${e?.message || String(e)}. ` +
|
|
1215
|
+
`Publish the Windows zip (${WIN_PORTABLE_ZIP_PREFIX}<version>.zip) on https://github.com/${UPDATE_GITHUB_OWNER}/${UPDATE_GITHUB_REPO}/releases/latest — latest.yml is enough; add zip-latest.yml from cleanup for checksum verification.`;
|
|
1216
|
+
if (uiActive) {
|
|
1217
|
+
openOrFocusUpdateDialog();
|
|
1218
|
+
updateDialogUi({
|
|
1219
|
+
text: hint,
|
|
1220
|
+
percent: 0,
|
|
1221
|
+
showProgress: false,
|
|
1222
|
+
showActions: true,
|
|
1223
|
+
installEnabled: false,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
} finally {
|
|
1227
|
+
zipPrepareInFlight = false;
|
|
1228
|
+
logUpdater(
|
|
1229
|
+
"prepare",
|
|
1230
|
+
versionsPrepareOk
|
|
1231
|
+
? "zipPrepareInFlight=false (success)"
|
|
1232
|
+
: "zipPrepareInFlight=false (incomplete — look for prepare FAILED above)",
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
const applyVersionsStagedUpdate = () => {
|
|
1238
|
+
const execPath = process.execPath;
|
|
1239
|
+
const installDir = path.dirname(execPath);
|
|
1240
|
+
const exeName = path.basename(execPath);
|
|
1241
|
+
const appRoot = getWindowsAppRootFromExecPath(execPath);
|
|
1242
|
+
const useVersionedLayout =
|
|
1243
|
+
process.platform === "win32" && path.basename(installDir).toLowerCase() === "current";
|
|
1244
|
+
const applyLogPath = applyUserLogPath;
|
|
1245
|
+
logUpdater(
|
|
1246
|
+
"apply",
|
|
1247
|
+
`applyVersionsStagedUpdate installDir=${installDir} appRoot=${appRoot} versioned=${useVersionedLayout} exe=${exeName} staging=${zipStagingContentPath} version=${zipReadyVersion} pid=${process.pid}`,
|
|
1248
|
+
);
|
|
1249
|
+
logUpdater("apply", `helper log (next run): ${applyLogPath}`);
|
|
1250
|
+
const planPath = path.join(app.getPath("temp"), `hsp-update-plan-${Date.now()}.json`);
|
|
1251
|
+
const stagingVersionDirToRemove = zipReadyVersion
|
|
1252
|
+
? path.join(getVersionsStagingRoot(), zipReadyVersion)
|
|
1253
|
+
: null;
|
|
1254
|
+
const targetVersionDir =
|
|
1255
|
+
useVersionedLayout && zipReadyVersion ? path.join(appRoot, "versions", zipReadyVersion) : null;
|
|
1256
|
+
const currentLink = useVersionedLayout ? path.join(appRoot, "current") : null;
|
|
1257
|
+
const plan = {
|
|
1258
|
+
stagingContent: zipStagingContentPath,
|
|
1259
|
+
installDir,
|
|
1260
|
+
exeName,
|
|
1261
|
+
waitPid: process.pid,
|
|
1262
|
+
appliedVersion: zipReadyVersion,
|
|
1263
|
+
stagingVersionDirToRemove,
|
|
1264
|
+
logPath: applyLogPath,
|
|
1265
|
+
useVersionedLayout,
|
|
1266
|
+
appRoot,
|
|
1267
|
+
targetVersionDir,
|
|
1268
|
+
currentLink,
|
|
1269
|
+
};
|
|
1270
|
+
fs.writeFileSync(planPath, JSON.stringify(plan), "utf8");
|
|
1271
|
+
logUpdater("apply", `wrote plan ${planPath} ${safeJson(plan)}`);
|
|
1272
|
+
|
|
1273
|
+
const ps1Path = path.join(app.getPath("temp"), `hsp-apply-versions-${Date.now()}.ps1`);
|
|
1274
|
+
/**
|
|
1275
|
+
* Apply script uses no fixed sleeps: wait for parent via Wait-Process, kill stragglers, copy, relaunch.
|
|
1276
|
+
* Robocopy: /R:0 /W:0 (no retry delay), /MT:64 /J (throughput on SSD/large files), staging tree delete is async after relaunch.
|
|
1277
|
+
*/
|
|
1278
|
+
const ps1Body = [
|
|
1279
|
+
"param([string]$PlanPath, [string]$LogPath)",
|
|
1280
|
+
'$ErrorActionPreference = "Stop"',
|
|
1281
|
+
"if (-not $PlanPath) { $PlanPath = $env:HSP_UPDATE_PLAN }",
|
|
1282
|
+
"if (-not $LogPath) { $LogPath = $env:HSP_UPDATE_LOG }",
|
|
1283
|
+
"try {",
|
|
1284
|
+
" $trace = Join-Path $env:TEMP 'hsp-apply-trace.log'",
|
|
1285
|
+
" $ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')",
|
|
1286
|
+
" Add-Content -LiteralPath $trace -Encoding UTF8 -Value (\"[$ts] ps1 -File started pid=$PID\")",
|
|
1287
|
+
" if ($LogPath) {",
|
|
1288
|
+
" Add-Content -LiteralPath $LogPath -Encoding UTF8 -Value (\"[$ts] [ps1] bootstrap (before plan JSON)\")",
|
|
1289
|
+
" }",
|
|
1290
|
+
"} catch {}",
|
|
1291
|
+
'if (-not $PlanPath) { throw "Plan path missing (pass -PlanPath to this script or set HSP_UPDATE_PLAN)" }',
|
|
1292
|
+
"try {",
|
|
1293
|
+
" $plan = Get-Content -LiteralPath $PlanPath -Encoding UTF8 -Raw | ConvertFrom-Json",
|
|
1294
|
+
"} catch {",
|
|
1295
|
+
" $ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')",
|
|
1296
|
+
" $m = \"FATAL: plan JSON read/parse failed: \" + $_.Exception.Message",
|
|
1297
|
+
" if ($LogPath) { try { Add-Content -LiteralPath $LogPath -Encoding UTF8 -Value (\"[$ts] \" + $m) } catch {} }",
|
|
1298
|
+
" throw",
|
|
1299
|
+
"}",
|
|
1300
|
+
"$LogFile = $plan.logPath",
|
|
1301
|
+
"function Write-ApplyLog([string]$m) {",
|
|
1302
|
+
" $ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')",
|
|
1303
|
+
' Add-Content -LiteralPath $LogFile -Value ("[$ts] " + $m) -Encoding UTF8',
|
|
1304
|
+
"}",
|
|
1305
|
+
"try {",
|
|
1306
|
+
' Write-ApplyLog "apply start waitPid=$($plan.waitPid) exe=$($plan.exeName) versioned=$($plan.useVersionedLayout)"',
|
|
1307
|
+
" $pp = Get-Process -Id $plan.waitPid -ErrorAction SilentlyContinue",
|
|
1308
|
+
" if ($pp) { Wait-Process -InputObject $pp -ErrorAction SilentlyContinue }",
|
|
1309
|
+
' Write-ApplyLog "parent process ended (Wait-Process)"',
|
|
1310
|
+
" $stem = [System.IO.Path]::GetFileNameWithoutExtension($plan.exeName)",
|
|
1311
|
+
" $killNames = @($stem, ($stem + \" Helper\"), ($stem + \" Helper (GPU)\"), ($stem + \" Helper (Renderer)\"), ($stem + \" Helper (Plugin)\"))",
|
|
1312
|
+
" foreach ($kn in $killNames) {",
|
|
1313
|
+
" try { Get-Process -Name $kn -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } catch {}",
|
|
1314
|
+
" }",
|
|
1315
|
+
' Write-ApplyLog "stopped helpers by process name (fast; avoids Win32_Process scan of every OS process)"',
|
|
1316
|
+
" $src = $plan.stagingContent",
|
|
1317
|
+
" if ($plan.useVersionedLayout) {",
|
|
1318
|
+
" $dst = $plan.targetVersionDir",
|
|
1319
|
+
" $null = New-Item -ItemType Directory -Force -LiteralPath $dst",
|
|
1320
|
+
' Write-ApplyLog "mirror target (versioned): $dst"',
|
|
1321
|
+
" } else {",
|
|
1322
|
+
" $dst = $plan.installDir",
|
|
1323
|
+
' Write-ApplyLog "mirror target (flat): $dst"',
|
|
1324
|
+
" }",
|
|
1325
|
+
' Write-ApplyLog "copy staging -> dest (robocopy; versioned uses /MIR, flat uses non-destructive /E)"',
|
|
1326
|
+
" $robocopyExe = Join-Path $env:SystemRoot 'System32\\robocopy.exe'",
|
|
1327
|
+
" if ($plan.useVersionedLayout) {",
|
|
1328
|
+
" & $robocopyExe $src $dst /MIR /E /MT:64 /J /R:0 /W:0 /XD versions /NFL /NDL /NJH /NJS",
|
|
1329
|
+
" } else {",
|
|
1330
|
+
" # Flat install path: do not purge destination. Keep installer-managed files (e.g. Uninstall *.exe).",
|
|
1331
|
+
" & $robocopyExe $src $dst /E /MT:64 /J /R:0 /W:0 /XD versions /NFL /NDL /NJH /NJS",
|
|
1332
|
+
" }",
|
|
1333
|
+
" $mirrorExit = $LASTEXITCODE",
|
|
1334
|
+
" Write-ApplyLog (\"robocopy mirror exit=\" + $mirrorExit)",
|
|
1335
|
+
" if ($mirrorExit -gt 7) {",
|
|
1336
|
+
' Write-ApplyLog "robocopy mirror failed; Copy-Item full tree fallback"',
|
|
1337
|
+
" Copy-Item -Path (Join-Path $src '*') -Destination $dst -Recurse -Force",
|
|
1338
|
+
' Write-ApplyLog "Copy-Item fallback done"',
|
|
1339
|
+
" }",
|
|
1340
|
+
" if ($plan.useVersionedLayout) {",
|
|
1341
|
+
" if (Test-Path -LiteralPath $plan.currentLink) {",
|
|
1342
|
+
" Remove-Item -LiteralPath $plan.currentLink -Force",
|
|
1343
|
+
' Write-ApplyLog ("removed old current junction/link")',
|
|
1344
|
+
" }",
|
|
1345
|
+
" $null = New-Item -ItemType Junction -Path $plan.currentLink -Target $plan.targetVersionDir",
|
|
1346
|
+
' Write-ApplyLog ("junction: $($plan.currentLink) -> $($plan.targetVersionDir)")',
|
|
1347
|
+
" }",
|
|
1348
|
+
" $workDir = if ($plan.useVersionedLayout) { $plan.currentLink } else { $dst }",
|
|
1349
|
+
` $candidates = @($plan.exeName, ${brand.allKnownExeBaseNames().map((n) => `"${n}"`).join(", ")}) | Select-Object -Unique`,
|
|
1350
|
+
" $exePath = $null",
|
|
1351
|
+
" foreach ($c in $candidates) {",
|
|
1352
|
+
" $tryExe = Join-Path $workDir $c",
|
|
1353
|
+
" if (Test-Path -LiteralPath $tryExe) { $exePath = $tryExe; Write-ApplyLog (\"picked exe: \" + $c); break }",
|
|
1354
|
+
" }",
|
|
1355
|
+
" if (-not $exePath) { throw (\"main exe missing after apply under \" + $workDir + \" (tried \" + ($candidates -join \", \") + \")\") }",
|
|
1356
|
+
' Write-ApplyLog ("relaunch " + $exePath + " (wd=" + $workDir + ")")',
|
|
1357
|
+
" Start-Process -FilePath $exePath -WorkingDirectory $workDir",
|
|
1358
|
+
' Write-ApplyLog "Start-Process returned (GUI may take a moment)"',
|
|
1359
|
+
" if ($plan.stagingVersionDirToRemove -and (Test-Path -LiteralPath $plan.stagingVersionDirToRemove)) {",
|
|
1360
|
+
" $sdRm = $plan.stagingVersionDirToRemove",
|
|
1361
|
+
" $rdArg = 'rd /s /q \"' + $sdRm.Replace('\"', '\"\"') + '\"'",
|
|
1362
|
+
" Start-Process -FilePath $env:ComSpec -ArgumentList '/c', $rdArg -WindowStyle Hidden",
|
|
1363
|
+
' Write-ApplyLog "scheduled async staging dir cleanup (after relaunch)"',
|
|
1364
|
+
" }",
|
|
1365
|
+
" try { Remove-Item -LiteralPath $PlanPath -Force } catch {}",
|
|
1366
|
+
' Write-ApplyLog "apply done"',
|
|
1367
|
+
"} catch {",
|
|
1368
|
+
' $err = "FATAL: " + $_.Exception.Message',
|
|
1369
|
+
" if ($LogFile) { try { Write-ApplyLog $err } catch {} }",
|
|
1370
|
+
" elseif ($plan -and $plan.logPath) { try { Add-Content -LiteralPath $plan.logPath -Value (\"[\" + (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + \"] \" + $err) -Encoding UTF8 } catch {} }",
|
|
1371
|
+
" try { Remove-Item -LiteralPath $PlanPath -Force } catch {}",
|
|
1372
|
+
" exit 1",
|
|
1373
|
+
"}",
|
|
1374
|
+
"",
|
|
1375
|
+
].join("\r\n");
|
|
1376
|
+
// UTF-8 BOM so Windows PowerShell 5.1 parses multi-byte literals reliably in .ps1 files.
|
|
1377
|
+
fs.writeFileSync(ps1Path, `\uFEFF${ps1Body}`, "utf8");
|
|
1378
|
+
logUpdater(
|
|
1379
|
+
"apply",
|
|
1380
|
+
`wrote ps1 ${ps1Path} (Wait-Process; Stop-Process by name; robocopy mirror + Copy-Item fallback)`,
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
try {
|
|
1384
|
+
fs.appendFileSync(
|
|
1385
|
+
applyLogPath,
|
|
1386
|
+
`[${new Date().toISOString()}] [main] spawning apply via spawnSync Start-Process inner -File ps1=${ps1Path} plan=${planPath} log=${applyLogPath} trace=%TEMP%\\hsp-apply-trace.log\n`,
|
|
1387
|
+
"utf8",
|
|
1388
|
+
);
|
|
1389
|
+
} catch (_) {}
|
|
1390
|
+
|
|
1391
|
+
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || "C:\\Windows";
|
|
1392
|
+
const psExe = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
1393
|
+
// Async spawn (cmd or PowerShell) can lose the race: app.quit() runs before the apply script starts.
|
|
1394
|
+
// Run a one-shot outer PowerShell that Start-Process'es the real script synchronously (spawnSync) so
|
|
1395
|
+
// the inner process exists before we return and quit. Pass -PlanPath/-LogPath on argv (no env required).
|
|
1396
|
+
const psSq = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
1397
|
+
const argList = [
|
|
1398
|
+
"-NoProfile",
|
|
1399
|
+
"-ExecutionPolicy",
|
|
1400
|
+
"Bypass",
|
|
1401
|
+
"-File",
|
|
1402
|
+
ps1Path,
|
|
1403
|
+
"-PlanPath",
|
|
1404
|
+
planPath,
|
|
1405
|
+
"-LogPath",
|
|
1406
|
+
applyLogPath,
|
|
1407
|
+
]
|
|
1408
|
+
.map(psSq)
|
|
1409
|
+
.join(",");
|
|
1410
|
+
const startCmd = `Start-Process -WindowStyle Hidden -FilePath ${psSq(psExe)} -ArgumentList ${argList}`;
|
|
1411
|
+
|
|
1412
|
+
let syncResult;
|
|
1413
|
+
try {
|
|
1414
|
+
syncResult = spawnSync(
|
|
1415
|
+
psExe,
|
|
1416
|
+
["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", startCmd],
|
|
1417
|
+
{
|
|
1418
|
+
env: {
|
|
1419
|
+
...process.env,
|
|
1420
|
+
HSP_UPDATE_PLAN: planPath,
|
|
1421
|
+
HSP_UPDATE_LOG: applyLogPath,
|
|
1422
|
+
},
|
|
1423
|
+
windowsHide: true,
|
|
1424
|
+
timeout: 20000,
|
|
1425
|
+
encoding: "utf8",
|
|
1426
|
+
maxBuffer: 1024 * 1024,
|
|
1427
|
+
},
|
|
1428
|
+
);
|
|
1429
|
+
} catch (e) {
|
|
1430
|
+
const msg = e?.message || String(e);
|
|
1431
|
+
logUpdater("apply", `spawnSync launcher threw: ${msg}`);
|
|
1432
|
+
try {
|
|
1433
|
+
fs.appendFileSync(applyLogPath, `[${new Date().toISOString()}] [main] spawnSync launcher threw: ${msg}\n`, "utf8");
|
|
1434
|
+
} catch (_) {}
|
|
1435
|
+
throw e;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (syncResult.error) {
|
|
1439
|
+
const msg = syncResult.error.message || String(syncResult.error);
|
|
1440
|
+
logUpdater("apply", `spawnSync launcher error: ${msg}`);
|
|
1441
|
+
try {
|
|
1442
|
+
fs.appendFileSync(applyLogPath, `[${new Date().toISOString()}] [main] spawnSync launcher error: ${msg}\n`, "utf8");
|
|
1443
|
+
} catch (_) {}
|
|
1444
|
+
}
|
|
1445
|
+
const combinedOut = `${syncResult.stderr || ""}${syncResult.stdout || ""}`.trim();
|
|
1446
|
+
const exitCode = syncResult.status;
|
|
1447
|
+
if (exitCode !== 0) {
|
|
1448
|
+
logUpdater(
|
|
1449
|
+
"apply",
|
|
1450
|
+
`spawnSync Start-Process launcher exit=${exitCode} output=${combinedOut.slice(0, 2000)}`,
|
|
1451
|
+
);
|
|
1452
|
+
try {
|
|
1453
|
+
fs.appendFileSync(
|
|
1454
|
+
applyLogPath,
|
|
1455
|
+
`[${new Date().toISOString()}] [main] spawnSync launcher exit=${exitCode} ${combinedOut.slice(0, 1500)}\n`,
|
|
1456
|
+
"utf8",
|
|
1457
|
+
);
|
|
1458
|
+
} catch (_) {}
|
|
1459
|
+
} else {
|
|
1460
|
+
logUpdater("apply", "spawnSync Start-Process launcher ok (inner apply.ps1 should be running)");
|
|
1461
|
+
}
|
|
1462
|
+
if (syncResult.error || exitCode !== 0) {
|
|
1463
|
+
throw new Error(
|
|
1464
|
+
`apply launcher failed (exit=${exitCode}): ${syncResult.error?.message || combinedOut.slice(0, 600) || "unknown"}`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
/** True when versions/ staging is ready; preferred over NSIS even if installer also downloaded. */
|
|
1470
|
+
const canApplyVersionsStaging = () => {
|
|
1471
|
+
if (!useWinVersionsSidecar || !zipStagingContentPath || !zipReadyVersion) return false;
|
|
1472
|
+
if (compareSemverLike(zipReadyVersion, currentVersion) <= 0) return false;
|
|
1473
|
+
return stagingHasMainExe(zipStagingContentPath);
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
const requestInstallNow = () => {
|
|
1477
|
+
try {
|
|
1478
|
+
fs.appendFileSync(
|
|
1479
|
+
applyUserLogPath,
|
|
1480
|
+
`[${new Date().toISOString()}] [main] requestInstallNow (Update with reload clicked)\n`,
|
|
1481
|
+
"utf8",
|
|
1482
|
+
);
|
|
1483
|
+
} catch (_) {}
|
|
1484
|
+
|
|
1485
|
+
installRequested = true;
|
|
1486
|
+
log("[updater] user accepted update install");
|
|
1487
|
+
logUpdater("ipc", "requestInstallNow (Update with reload)");
|
|
1488
|
+
|
|
1489
|
+
suppressQuitForUpdateInstall = true;
|
|
1490
|
+
|
|
1491
|
+
const useVersionsApply = canApplyVersionsStaging();
|
|
1492
|
+
const semverNewer =
|
|
1493
|
+
zipReadyVersion && compareSemverLike(zipReadyVersion, currentVersion) > 0;
|
|
1494
|
+
const exeOk =
|
|
1495
|
+
Boolean(zipStagingContentPath) &&
|
|
1496
|
+
stagingHasMainExe(zipStagingContentPath);
|
|
1497
|
+
logUpdater(
|
|
1498
|
+
"ipc",
|
|
1499
|
+
`requestInstallNow useVersionsApply=${useVersionsApply} zipReady=${zipReadyVersion} path=${zipStagingContentPath}`,
|
|
1500
|
+
);
|
|
1501
|
+
|
|
1502
|
+
if (useVersionsApply) {
|
|
1503
|
+
closeUpdateDialog();
|
|
1504
|
+
try {
|
|
1505
|
+
applyVersionsStagedUpdate();
|
|
1506
|
+
} catch (e) {
|
|
1507
|
+
log(`[updater] applyVersionsStagedUpdate failed: ${e?.message || e}`);
|
|
1508
|
+
suppressQuitForUpdateInstall = false;
|
|
1509
|
+
const mw = focusMainWindowForDialog();
|
|
1510
|
+
const errOpts = {
|
|
1511
|
+
type: "error",
|
|
1512
|
+
title: brand.productDisplayName,
|
|
1513
|
+
message: `Could not apply update: ${e?.message || String(e)}`,
|
|
1514
|
+
buttons: ["OK"],
|
|
1515
|
+
};
|
|
1516
|
+
void (mw ? dialog.showMessageBox(mw, errOpts) : dialog.showMessageBox(errOpts));
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
for (const win of BrowserWindow.getAllWindows()) {
|
|
1520
|
+
try {
|
|
1521
|
+
win.removeAllListeners("close");
|
|
1522
|
+
win.destroy();
|
|
1523
|
+
} catch (_) {}
|
|
1524
|
+
}
|
|
1525
|
+
logUpdater("ipc", "requestInstallNow app.quit after staging apply spawn");
|
|
1526
|
+
app.quit();
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Windows packaged: only the staged-zip path — never launch the NSIS wizard from this button.
|
|
1531
|
+
if (useWinVersionsSidecar) {
|
|
1532
|
+
suppressQuitForUpdateInstall = false;
|
|
1533
|
+
logUpdater("ipc", "requestInstallNow blocked: no staged zip build ready");
|
|
1534
|
+
log(
|
|
1535
|
+
`[updater] Update click ignored: no staged build (ready=${zipReadyVersion} path=${zipStagingContentPath})`,
|
|
1536
|
+
);
|
|
1537
|
+
try {
|
|
1538
|
+
fs.appendFileSync(
|
|
1539
|
+
applyUserLogPath,
|
|
1540
|
+
`[${new Date().toISOString()}] [main] blocked: cannot apply zip staging ` +
|
|
1541
|
+
`(readyVer=${zipReadyVersion} stagingPath=${zipStagingContentPath} ` +
|
|
1542
|
+
`semverNewer=${Boolean(semverNewer)} exeOk=${Boolean(exeOk)} current=${currentVersion})\n`,
|
|
1543
|
+
"utf8",
|
|
1544
|
+
);
|
|
1545
|
+
} catch (_) {}
|
|
1546
|
+
const boxOpts = {
|
|
1547
|
+
type: "info",
|
|
1548
|
+
title: brand.productDisplayName,
|
|
1549
|
+
message:
|
|
1550
|
+
`The quick update is not ready yet. Keep the app open until download and unpack finish, or ensure the latest GitHub release includes zip-latest.yml and ${WIN_PORTABLE_ZIP_PREFIX}<version>.zip from your Windows build (cleanup folder).`,
|
|
1551
|
+
buttons: ["OK"],
|
|
1552
|
+
};
|
|
1553
|
+
try {
|
|
1554
|
+
if (updateDialogState.window && !updateDialogState.window.isDestroyed()) {
|
|
1555
|
+
updateDialogState.window.hide();
|
|
1556
|
+
}
|
|
1557
|
+
} catch (_) {}
|
|
1558
|
+
const mw = focusMainWindowForDialog();
|
|
1559
|
+
void (mw ? dialog.showMessageBox(mw, boxOpts) : dialog.showMessageBox(boxOpts));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
closeUpdateDialog();
|
|
1564
|
+
|
|
1565
|
+
try {
|
|
1566
|
+
if (process.platform === "win32" && Notification.isSupported()) {
|
|
1567
|
+
const n = new Notification({
|
|
1568
|
+
title: brand.productDisplayName,
|
|
1569
|
+
body: "Installing update… The app will restart when finished.",
|
|
1570
|
+
});
|
|
1571
|
+
n.show();
|
|
1572
|
+
}
|
|
1573
|
+
} catch (_) {}
|
|
1574
|
+
|
|
1575
|
+
for (const win of BrowserWindow.getAllWindows()) {
|
|
1576
|
+
try {
|
|
1577
|
+
win.removeAllListeners("close");
|
|
1578
|
+
win.destroy();
|
|
1579
|
+
} catch (_) {}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
try {
|
|
1583
|
+
log("[updater] invoking quitAndInstall(isSilent=false, isForceRunAfter=true)");
|
|
1584
|
+
autoUpdater.quitAndInstall(false, true);
|
|
1585
|
+
} catch (e) {
|
|
1586
|
+
log(`quitAndInstall failed: ${e?.message || e}`);
|
|
1587
|
+
suppressQuitForUpdateInstall = false;
|
|
1588
|
+
app.quit();
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
autoUpdater.on("update-downloaded", () => {
|
|
1593
|
+
log("[updater] update-downloaded");
|
|
1594
|
+
logUpdater("event", "update-downloaded (NSIS installer file ready on disk)");
|
|
1595
|
+
manualDownloadInProgress = false;
|
|
1596
|
+
// Windows uses zip sidecar only; ignore NSIS installer download for in-app UX.
|
|
1597
|
+
if (useWinVersionsSidecar) {
|
|
1598
|
+
log("[updater] update-downloaded: ignored on Windows (NSIS not used for Update button)");
|
|
1599
|
+
logUpdater("event", "update-downloaded ignored (Windows uses zip sidecar only)");
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
openOrFocusUpdateDialog();
|
|
1603
|
+
updateDialogUi({
|
|
1604
|
+
text: 'Update is ready. Click "Update with reload".',
|
|
1605
|
+
percent: 100,
|
|
1606
|
+
showProgress: true,
|
|
1607
|
+
showActions: true,
|
|
1608
|
+
installEnabled: true,
|
|
1609
|
+
});
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
autoUpdater.on("checking-for-update", () => {
|
|
1613
|
+
log("[updater] checking-for-update");
|
|
1614
|
+
logUpdater("event", `checking-for-update manual=${manualCheckInProgress}`);
|
|
1615
|
+
if (manualCheckInProgress) {
|
|
1616
|
+
log("[updater] manual check started");
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
autoUpdater.on("update-available", (info) => {
|
|
1621
|
+
log(`[updater] update-available version=${info?.version || "unknown"}`);
|
|
1622
|
+
logUpdater("event", `update-available ${safeJson({ version: info?.version, path: info?.path })}`);
|
|
1623
|
+
const wasManual = manualCheckInProgress;
|
|
1624
|
+
if (manualCheckInProgress) {
|
|
1625
|
+
manualCheckInProgress = false;
|
|
1626
|
+
manualDownloadInProgress = true;
|
|
1627
|
+
openOrFocusUpdateDialog();
|
|
1628
|
+
updateDialogUi({
|
|
1629
|
+
text: useWinVersionsSidecar
|
|
1630
|
+
? `Downloading and preparing version ${info?.version || "new"}…`
|
|
1631
|
+
: `Downloading version ${info?.version || "new"}...`,
|
|
1632
|
+
percent: 0,
|
|
1633
|
+
showProgress: true,
|
|
1634
|
+
showActions: true,
|
|
1635
|
+
installEnabled: false,
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
if (useWinVersionsSidecar) {
|
|
1639
|
+
void tryBeginVersionsPrepare(info, { uiManual: wasManual });
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
autoUpdater.on("update-not-available", () => {
|
|
1644
|
+
log("[updater] update-not-available");
|
|
1645
|
+
logUpdater("event", `update-not-available current=${currentVersion}`);
|
|
1646
|
+
if (manualCheckInProgress) {
|
|
1647
|
+
manualCheckInProgress = false;
|
|
1648
|
+
manualDownloadInProgress = false;
|
|
1649
|
+
openOrFocusUpdateDialog();
|
|
1650
|
+
updateDialogUi({
|
|
1651
|
+
text: "You are already on the latest version.",
|
|
1652
|
+
percent: 0,
|
|
1653
|
+
showProgress: false,
|
|
1654
|
+
showActions: true,
|
|
1655
|
+
installEnabled: false,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
let downloadProgressLoggedSample = false;
|
|
1660
|
+
autoUpdater.on("download-progress", (progress) => {
|
|
1661
|
+
if (useWinVersionsSidecar) return;
|
|
1662
|
+
if (!updateDialogState.window || updateDialogState.window.isDestroyed()) return;
|
|
1663
|
+
if (!downloadProgressLoggedSample) {
|
|
1664
|
+
downloadProgressLoggedSample = true;
|
|
1665
|
+
try {
|
|
1666
|
+
log(`[updater] download-progress sample: ${JSON.stringify(progress)}`);
|
|
1667
|
+
} catch (_) {}
|
|
1668
|
+
}
|
|
1669
|
+
const pct = progressPercent(progress);
|
|
1670
|
+
updateDialogUi({
|
|
1671
|
+
text: `Downloading update... ${Math.round(pct)}%`,
|
|
1672
|
+
percent: pct,
|
|
1673
|
+
showProgress: true,
|
|
1674
|
+
showActions: true,
|
|
1675
|
+
installEnabled: false,
|
|
1676
|
+
});
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
autoUpdater.on("error", (err) => {
|
|
1680
|
+
log(`[updater] error: ${err?.message || String(err)}`);
|
|
1681
|
+
if (updaterCheckRetrying && isTransientGithubUpdateError(err)) {
|
|
1682
|
+
logUpdater("event", `error suppressed (retry) ${err?.message || err}`);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
logUpdater("event", `error manualCheck=${manualCheckInProgress} download=${manualDownloadInProgress} ${err?.message || err}`);
|
|
1686
|
+
if (manualCheckInProgress || manualDownloadInProgress) {
|
|
1687
|
+
manualCheckInProgress = false;
|
|
1688
|
+
manualDownloadInProgress = false;
|
|
1689
|
+
openOrFocusUpdateDialog();
|
|
1690
|
+
updateDialogUi({
|
|
1691
|
+
text: `Update check failed: ${err?.message || String(err)}`,
|
|
1692
|
+
percent: 0,
|
|
1693
|
+
showProgress: false,
|
|
1694
|
+
showActions: false,
|
|
1695
|
+
installEnabled: false,
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
updaterMenuApi.checkNow = async () => {
|
|
1701
|
+
try {
|
|
1702
|
+
log("[updater] manual check requested from menu");
|
|
1703
|
+
logUpdater("ipc", "checkNow from menu");
|
|
1704
|
+
downloadProgressLoggedSample = false;
|
|
1705
|
+
if (
|
|
1706
|
+
useWinVersionsSidecar &&
|
|
1707
|
+
zipReadyVersion &&
|
|
1708
|
+
zipStagingContentPath &&
|
|
1709
|
+
stagingHasMainExe(zipStagingContentPath)
|
|
1710
|
+
) {
|
|
1711
|
+
logUpdater("ipc", `checkNow short-circuit already staged ${zipReadyVersion}`);
|
|
1712
|
+
openOrFocusUpdateDialog();
|
|
1713
|
+
syncZipReadyUi(zipReadyVersion);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
manualCheckInProgress = true;
|
|
1717
|
+
manualDownloadInProgress = false;
|
|
1718
|
+
openOrFocusUpdateDialog();
|
|
1719
|
+
updateDialogUi({
|
|
1720
|
+
text: "Checking for updates...",
|
|
1721
|
+
percent: 0,
|
|
1722
|
+
showProgress: false,
|
|
1723
|
+
showActions: false,
|
|
1724
|
+
installEnabled: false,
|
|
1725
|
+
});
|
|
1726
|
+
updaterCheckRetrying = true;
|
|
1727
|
+
try {
|
|
1728
|
+
await checkForUpdatesWithRetry();
|
|
1729
|
+
} finally {
|
|
1730
|
+
updaterCheckRetrying = false;
|
|
1731
|
+
}
|
|
1732
|
+
} catch (e) {
|
|
1733
|
+
manualCheckInProgress = false;
|
|
1734
|
+
manualDownloadInProgress = false;
|
|
1735
|
+
openOrFocusUpdateDialog();
|
|
1736
|
+
updateDialogUi({
|
|
1737
|
+
text: `Update check failed: ${e?.message || String(e)}`,
|
|
1738
|
+
percent: 0,
|
|
1739
|
+
showProgress: false,
|
|
1740
|
+
showActions: false,
|
|
1741
|
+
installEnabled: false,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
app.on("before-quit", () => {
|
|
1747
|
+
if (installRequested) {
|
|
1748
|
+
log("[updater] before-quit for update install");
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
autoUpdater.on("before-quit-for-update", () => {
|
|
1752
|
+
log("[updater] before-quit-for-update emitted");
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
let lastCheckAt = 0;
|
|
1756
|
+
const markAndCheck = () => {
|
|
1757
|
+
lastCheckAt = Date.now();
|
|
1758
|
+
log("[updater] scheduled checkForUpdates()");
|
|
1759
|
+
logUpdater("schedule", "periodic/startup checkForUpdates");
|
|
1760
|
+
void (async () => {
|
|
1761
|
+
updaterCheckRetrying = true;
|
|
1762
|
+
try {
|
|
1763
|
+
await checkForUpdatesWithRetry();
|
|
1764
|
+
} catch (e) {
|
|
1765
|
+
log(`[updater] checkForUpdates failed after retries: ${e?.message || e}`);
|
|
1766
|
+
logUpdater("schedule", `check failed after retries: ${e?.message || e}`);
|
|
1767
|
+
} finally {
|
|
1768
|
+
updaterCheckRetrying = false;
|
|
1769
|
+
}
|
|
1770
|
+
})();
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
// 1) On startup (each app launch)
|
|
1774
|
+
markAndCheck();
|
|
1775
|
+
|
|
1776
|
+
// 2) While running: every 1 minute (temporary aggressive polling)
|
|
1777
|
+
const periodicMs = 1 * 60 * 1000;
|
|
1778
|
+
setInterval(markAndCheck, periodicMs);
|
|
1779
|
+
|
|
1780
|
+
// 3) When user brings the app back to foreground (throttled: at most once per 30 min)
|
|
1781
|
+
const minFocusGapMs = 30 * 60 * 1000;
|
|
1782
|
+
app.on("browser-window-focus", () => {
|
|
1783
|
+
if (Date.now() - lastCheckAt < minFocusGapMs) return;
|
|
1784
|
+
log("[updater] check (window focus)");
|
|
1785
|
+
logUpdater("schedule", "window focus → checkForUpdates");
|
|
1786
|
+
markAndCheck();
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
scheduleVersionsFolderCleanup();
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
log(`autoUpdater failed: ${e?.message || e}`);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
function setupAppMenu() {
|
|
1796
|
+
const template = [
|
|
1797
|
+
{
|
|
1798
|
+
label: "File",
|
|
1799
|
+
submenu: [{ role: "quit", label: "Exit" }],
|
|
1800
|
+
},
|
|
1801
|
+
{
|
|
1802
|
+
label: "Edit",
|
|
1803
|
+
submenu: [
|
|
1804
|
+
{ role: "undo" },
|
|
1805
|
+
{ role: "redo" },
|
|
1806
|
+
{ type: "separator" },
|
|
1807
|
+
{ role: "cut" },
|
|
1808
|
+
{ role: "copy" },
|
|
1809
|
+
{ role: "paste" },
|
|
1810
|
+
{ role: "selectAll" },
|
|
1811
|
+
],
|
|
1812
|
+
},
|
|
1813
|
+
{
|
|
1814
|
+
label: "View",
|
|
1815
|
+
submenu: [{ role: "reload" }, { role: "togglefullscreen" }],
|
|
1816
|
+
},
|
|
1817
|
+
{
|
|
1818
|
+
label: "Updates",
|
|
1819
|
+
submenu: [
|
|
1820
|
+
{
|
|
1821
|
+
label: "Check for updates now",
|
|
1822
|
+
click: () => {
|
|
1823
|
+
if (typeof updaterMenuApi.checkNow === "function") {
|
|
1824
|
+
void updaterMenuApi.checkNow();
|
|
1825
|
+
} else {
|
|
1826
|
+
void dialog.showMessageBox({
|
|
1827
|
+
type: "info",
|
|
1828
|
+
title: "Updates unavailable",
|
|
1829
|
+
message: "Updater is not available in development mode.",
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
},
|
|
1833
|
+
},
|
|
1834
|
+
],
|
|
1835
|
+
},
|
|
1836
|
+
];
|
|
1837
|
+
const menu = Menu.buildFromTemplate(template);
|
|
1838
|
+
Menu.setApplicationMenu(menu);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
protocol.registerSchemesAsPrivileged([
|
|
1842
|
+
{ scheme: "app", privileges: { standard: true, supportFetchAPI: true } },
|
|
1843
|
+
]);
|
|
1844
|
+
|
|
1845
|
+
function log(msg) {
|
|
1846
|
+
try {
|
|
1847
|
+
const logPath = path.join(app.getPath("userData"), "main.log");
|
|
1848
|
+
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
1849
|
+
fs.appendFileSync(logPath, line + "\n");
|
|
1850
|
+
console.error(line);
|
|
1851
|
+
} catch (_) {}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/** Windows: resolve before the first show(); omit `icon` if empty so the shell can fall back to the exe. */
|
|
1855
|
+
async function resolveBrowserWindowIcon() {
|
|
1856
|
+
const fromFile = nativeImageFromAppIcon();
|
|
1857
|
+
if (fromFile && !fromFile.isEmpty()) return fromFile;
|
|
1858
|
+
if (process.platform !== "win32" || !app.isPackaged) return fromFile;
|
|
1859
|
+
|
|
1860
|
+
const thumbSize = { width: 256, height: 256 };
|
|
1861
|
+
const inAsarOnly = (p) => p.includes("app.asar") && !p.includes("app.asar.unpacked");
|
|
1862
|
+
|
|
1863
|
+
// Shell-backed extraction: often works for the packaged .exe (embedded rcedit icon) when ICO buffer decode fails.
|
|
1864
|
+
try {
|
|
1865
|
+
if (fs.existsSync(process.execPath)) {
|
|
1866
|
+
const img = await nativeImage.createThumbnailFromPath(process.execPath, thumbSize);
|
|
1867
|
+
if (img && !img.isEmpty()) return img;
|
|
1868
|
+
}
|
|
1869
|
+
} catch (e) {
|
|
1870
|
+
try {
|
|
1871
|
+
log(`createThumbnailFromPath(exe): ${e?.message || e}`);
|
|
1872
|
+
} catch (_) {}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
for (const p of collectAppIconIcoCandidates()) {
|
|
1876
|
+
if (!p || !fs.existsSync(p) || inAsarOnly(p)) continue;
|
|
1877
|
+
try {
|
|
1878
|
+
const img = await nativeImage.createThumbnailFromPath(p, thumbSize);
|
|
1879
|
+
if (img && !img.isEmpty()) return img;
|
|
1880
|
+
} catch (e) {
|
|
1881
|
+
try {
|
|
1882
|
+
log(`createThumbnailFromPath(${p}): ${e?.message || e}`);
|
|
1883
|
+
} catch (_) {}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const shellPaths = [process.execPath, ...collectAppIconIcoCandidates()].filter((p) => {
|
|
1888
|
+
if (!p || !fs.existsSync(p)) return false;
|
|
1889
|
+
return !inAsarOnly(p);
|
|
1890
|
+
});
|
|
1891
|
+
for (const p of shellPaths) {
|
|
1892
|
+
for (const size of ["large", "normal", "small"]) {
|
|
1893
|
+
try {
|
|
1894
|
+
const img = await app.getFileIcon(p, { size });
|
|
1895
|
+
if (!img.isEmpty()) return img;
|
|
1896
|
+
} catch (e) {
|
|
1897
|
+
try {
|
|
1898
|
+
log(`getFileIcon(${p}, ${size}): ${e?.message || e}`);
|
|
1899
|
+
} catch (_) {}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return fromFile;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
const WINDOWS_USERDATA_ICON = "window-icon.ico";
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Absolute path to a real .ico on disk for Windows shell APIs. Prefer loose/unpacked files; if the only
|
|
1910
|
+
* copy is inside app.asar, copy bytes with readFileSync/writeFileSync (copyFileSync can fail for asar).
|
|
1911
|
+
*/
|
|
1912
|
+
function ensureWindowsIcoFileOnDiskSync() {
|
|
1913
|
+
if (process.platform !== "win32" || !app.isPackaged) return null;
|
|
1914
|
+
const inAsarOnly = (p) => p.includes("app.asar") && !p.includes("app.asar.unpacked");
|
|
1915
|
+
const dest = path.join(app.getPath("userData"), WINDOWS_USERDATA_ICON);
|
|
1916
|
+
for (const p of collectAppIconIcoCandidates()) {
|
|
1917
|
+
if (!p || !fs.existsSync(p) || !/\.ico$/i.test(p)) continue;
|
|
1918
|
+
if (!inAsarOnly(p)) return path.resolve(p);
|
|
1919
|
+
}
|
|
1920
|
+
for (const p of collectAppIconIcoCandidates()) {
|
|
1921
|
+
if (!p || !fs.existsSync(p) || !/\.ico$/i.test(p)) continue;
|
|
1922
|
+
try {
|
|
1923
|
+
const buf = fs.readFileSync(p);
|
|
1924
|
+
if (buf.length < 32) continue;
|
|
1925
|
+
fs.writeFileSync(dest, buf);
|
|
1926
|
+
if (fs.existsSync(dest) && fs.statSync(dest).size > 0) return path.resolve(dest);
|
|
1927
|
+
} catch (e) {
|
|
1928
|
+
try {
|
|
1929
|
+
log(`ensureWindowsIcoFileOnDiskSync: ${e?.message || e}`);
|
|
1930
|
+
} catch (_) {}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* Set `HSP_DEBUG_ICON=1` before starting the app to append full `[icon:debug]` lines to userData/main.log
|
|
1938
|
+
* (candidate paths, setAppDetails). Deploy builds always log `[icon] probe(always): ...` without this flag.
|
|
1939
|
+
*/
|
|
1940
|
+
function iconDebugEnabled() {
|
|
1941
|
+
const v = process.env.HSP_DEBUG_ICON;
|
|
1942
|
+
return v === "1" || v === "true" || v === "yes";
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function logIconDebug(line) {
|
|
1946
|
+
if (!iconDebugEnabled()) return;
|
|
1947
|
+
log(`[icon:debug] ${line}`);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function icoPathStatLine(p) {
|
|
1951
|
+
if (!p) return "(null)";
|
|
1952
|
+
try {
|
|
1953
|
+
if (!fs.existsSync(p)) return `${p} (missing)`;
|
|
1954
|
+
return `${p} (size=${fs.statSync(p).size})`;
|
|
1955
|
+
} catch (e) {
|
|
1956
|
+
return `${p} (stat err: ${e?.message || e})`;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function describeWindowIconForLog(w) {
|
|
1961
|
+
if (w == null) return "none";
|
|
1962
|
+
if (typeof w === "string") return `path:${w}`;
|
|
1963
|
+
try {
|
|
1964
|
+
return `NativeImage isEmpty=${w.isEmpty ? w.isEmpty() : "?"}`;
|
|
1965
|
+
} catch (_) {
|
|
1966
|
+
return "NativeImage";
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/** Always logged on packaged Windows: Chromium decode of chosen .ico and of the .exe (deploy diagnostics). */
|
|
1971
|
+
function logWindowsIconProbeAlways(windowIcon) {
|
|
1972
|
+
if (process.platform !== "win32" || !app.isPackaged) return;
|
|
1973
|
+
if (typeof windowIcon === "string") {
|
|
1974
|
+
try {
|
|
1975
|
+
if (!fs.existsSync(windowIcon)) {
|
|
1976
|
+
log(`[icon] probe(always): chosen path missing: ${windowIcon}`);
|
|
1977
|
+
} else {
|
|
1978
|
+
const buf = fs.readFileSync(windowIcon);
|
|
1979
|
+
const niPath = nativeImage.createFromPath(windowIcon);
|
|
1980
|
+
const niBuf = nativeImage.createFromBuffer(buf);
|
|
1981
|
+
log(
|
|
1982
|
+
`[icon] probe(always): ico createFromPath isEmpty=${niPath.isEmpty()} createFromBuffer isEmpty=${niBuf.isEmpty()} bytes=${buf.length}`,
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
} catch (e) {
|
|
1986
|
+
log(`[icon] probe(always): ico ${e?.message || e}`);
|
|
1987
|
+
}
|
|
1988
|
+
} else {
|
|
1989
|
+
log(`[icon] probe(always): chosen=${describeWindowIconForLog(windowIcon)}`);
|
|
1990
|
+
}
|
|
1991
|
+
try {
|
|
1992
|
+
if (fs.existsSync(process.execPath)) {
|
|
1993
|
+
const niExe = nativeImage.createFromPath(process.execPath);
|
|
1994
|
+
log(`[icon] probe(always): execPath createFromPath isEmpty=${niExe.isEmpty()}`);
|
|
1995
|
+
}
|
|
1996
|
+
} catch (e) {
|
|
1997
|
+
log(`[icon] probe(always): execPath ${e?.message || e}`);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* Path for setAppDetails (taskbar / Jump List): prefer the .exe when Chromium can decode an embedded
|
|
2003
|
+
* icon — Windows shell uses the exe for the taskbar more reliably than a loose .ico in that case.
|
|
2004
|
+
* Otherwise use resources\\icon.ico (see embed-windows-exe-icon.cjs + afterSign).
|
|
2005
|
+
*/
|
|
2006
|
+
function resolveWindowsTaskbarDetailsIconPath() {
|
|
2007
|
+
if (process.platform !== "win32" || !app.isPackaged) return null;
|
|
2008
|
+
const exe = process.execPath;
|
|
2009
|
+
const ico = ensureWindowsIcoFileOnDiskSync();
|
|
2010
|
+
try {
|
|
2011
|
+
if (fs.existsSync(exe)) {
|
|
2012
|
+
const niFromExe = nativeImage.createFromPath(exe);
|
|
2013
|
+
if (!niFromExe.isEmpty()) return exe;
|
|
2014
|
+
}
|
|
2015
|
+
} catch (_) {}
|
|
2016
|
+
if (ico && fs.existsSync(ico)) return ico;
|
|
2017
|
+
return fs.existsSync(exe) ? exe : ico;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/** Logs once per main window: summary + probe(always); full dump when HSP_DEBUG_ICON=1. */
|
|
2021
|
+
function logWindowsIconEnvironment(windowIcon) {
|
|
2022
|
+
if (process.platform !== "win32" || !app.isPackaged) return;
|
|
2023
|
+
log(`[icon] win32 packaged: ${describeWindowIconForLog(windowIcon)} resourcesPath=${process.resourcesPath}`);
|
|
2024
|
+
logWindowsIconProbeAlways(windowIcon);
|
|
2025
|
+
if (!iconDebugEnabled()) return;
|
|
2026
|
+
logIconDebug(`__dirname=${__dirname}`);
|
|
2027
|
+
logIconDebug(`app.getAppPath=${app.getAppPath()}`);
|
|
2028
|
+
logIconDebug(`execPath=${process.execPath}`);
|
|
2029
|
+
const raw = [
|
|
2030
|
+
process.resourcesPath && path.join(process.resourcesPath, "icon.ico"),
|
|
2031
|
+
process.resourcesPath && path.join(process.resourcesPath, "app.asar.unpacked", "assets", "icon.ico"),
|
|
2032
|
+
process.resourcesPath && path.join(process.resourcesPath, "assets", "icon.ico"),
|
|
2033
|
+
app.getAppPath && path.join(app.getAppPath(), "assets", "icon.ico"),
|
|
2034
|
+
path.join(__dirname, "..", "assets", "icon.ico"),
|
|
2035
|
+
].filter(Boolean);
|
|
2036
|
+
for (const p of raw) {
|
|
2037
|
+
logIconDebug(`candidate ${icoPathStatLine(p)}`);
|
|
2038
|
+
}
|
|
2039
|
+
logIconDebug(`existing only: ${collectAppIconIcoCandidates().join(" | ") || "(none)"}`);
|
|
2040
|
+
const disk = ensureWindowsIcoFileOnDiskSync();
|
|
2041
|
+
logIconDebug(`ensureWindowsIcoFileOnDiskSync => ${disk || "null"}`);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/** NativeImage for the main window: prefer createFromPath on real disk .ico; buffer decode as fallback. */
|
|
2045
|
+
function windowsPackagedWindowNativeIcon() {
|
|
2046
|
+
const p = ensureWindowsIcoFileOnDiskSync();
|
|
2047
|
+
if (!p) return null;
|
|
2048
|
+
try {
|
|
2049
|
+
let img = nativeImage.createFromPath(p);
|
|
2050
|
+
if (!img.isEmpty()) return img;
|
|
2051
|
+
img = nativeImage.createFromBuffer(fs.readFileSync(p));
|
|
2052
|
+
return img.isEmpty() ? null : img;
|
|
2053
|
+
} catch (e) {
|
|
2054
|
+
try {
|
|
2055
|
+
log(`windowsPackagedWindowNativeIcon: ${e?.message || e}`);
|
|
2056
|
+
} catch (_) {}
|
|
2057
|
+
return null;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
async function createWindow() {
|
|
2062
|
+
const appPath = app.getAppPath();
|
|
2063
|
+
const distPath = path.join(appPath, "dist");
|
|
2064
|
+
const indexHtml = path.join(distPath, "index.html");
|
|
2065
|
+
|
|
2066
|
+
if (!isDev && !fs.existsSync(indexHtml)) {
|
|
2067
|
+
log(`ERROR: index.html not found at ${indexHtml}`);
|
|
2068
|
+
log(`appPath=${appPath}`);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** `string` = absolute .ico path (preferred on Windows packaged builds; Chromium loads reliably). Else NativeImage. */
|
|
2073
|
+
let windowIcon;
|
|
2074
|
+
if (process.platform === "win32" && app.isPackaged) {
|
|
2075
|
+
const icoPath = ensureWindowsIcoFileOnDiskSync();
|
|
2076
|
+
if (icoPath) {
|
|
2077
|
+
windowIcon = icoPath;
|
|
2078
|
+
} else {
|
|
2079
|
+
const native = windowsPackagedWindowNativeIcon();
|
|
2080
|
+
if (native && !native.isEmpty()) {
|
|
2081
|
+
windowIcon = native;
|
|
2082
|
+
} else {
|
|
2083
|
+
const img = await resolveBrowserWindowIcon();
|
|
2084
|
+
windowIcon = img && !img.isEmpty() ? img : undefined;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
} else {
|
|
2088
|
+
const img = await resolveBrowserWindowIcon();
|
|
2089
|
+
windowIcon = img && !img.isEmpty() ? img : undefined;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
if (process.platform === "win32" && app.isPackaged && !windowIcon) {
|
|
2093
|
+
try {
|
|
2094
|
+
log(
|
|
2095
|
+
`warn: window icon unresolved; resourcesPath=${process.resourcesPath} ico=${collectAppIconIcoCandidates().join(" | ")} exe=${process.execPath}`,
|
|
2096
|
+
);
|
|
2097
|
+
} catch (_) {}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
try {
|
|
2101
|
+
logWindowsIconEnvironment(windowIcon);
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
log(`[icon] logWindowsIconEnvironment failed: ${e?.message || e}`);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
const applyWindowIcon = () => {
|
|
2107
|
+
if (!windowIcon || mainWindow.isDestroyed()) return;
|
|
2108
|
+
try {
|
|
2109
|
+
mainWindow.setIcon(windowIcon);
|
|
2110
|
+
logIconDebug(`setIcon applied type=${typeof windowIcon}`);
|
|
2111
|
+
} catch (e) {
|
|
2112
|
+
log(`[icon] setIcon failed: ${e?.message || e}`);
|
|
2113
|
+
}
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
// NSIS close-app uses PRODUCT_NAME (package.json → build.productName). The window title must
|
|
2117
|
+
// match that string, not a URL — otherwise the installer cannot find/close the running app.
|
|
2118
|
+
// Keep in sync with app/package.json "build.productName".
|
|
2119
|
+
const windowTitle = isDev ? "http://www.hyperlinks.space/" : brand.productDisplayName;
|
|
2120
|
+
|
|
2121
|
+
const mainWindow = new BrowserWindow({
|
|
2122
|
+
width: 1200,
|
|
2123
|
+
height: 800,
|
|
2124
|
+
title: windowTitle,
|
|
2125
|
+
...(windowIcon ? { icon: windowIcon } : {}),
|
|
2126
|
+
// Match app dark background (theme.ts); reduces flash and helps menu/client seam blend on Windows.
|
|
2127
|
+
backgroundColor: "#111111",
|
|
2128
|
+
webPreferences: {
|
|
2129
|
+
nodeIntegration: false,
|
|
2130
|
+
contextIsolation: true,
|
|
2131
|
+
spellcheck: false,
|
|
2132
|
+
},
|
|
2133
|
+
show: false,
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
try {
|
|
2137
|
+
mainWindow.webContents.setIgnoreMenuShortcuts(false);
|
|
2138
|
+
} catch (_) {}
|
|
2139
|
+
|
|
2140
|
+
mainWindow.once("ready-to-show", () => {
|
|
2141
|
+
// Win32: ties this HWND to AppUserModelID + icon for the taskbar button (see Electron BrowserWindow.setAppDetails).
|
|
2142
|
+
if (process.platform === "win32" && fs.existsSync(process.execPath)) {
|
|
2143
|
+
try {
|
|
2144
|
+
const detailsIcon = resolveWindowsTaskbarDetailsIconPath();
|
|
2145
|
+
if (detailsIcon && fs.existsSync(detailsIcon)) {
|
|
2146
|
+
mainWindow.setAppDetails({
|
|
2147
|
+
appId: WIN_APP_USER_MODEL_ID,
|
|
2148
|
+
appIconPath: detailsIcon,
|
|
2149
|
+
appIconIndex: 0,
|
|
2150
|
+
});
|
|
2151
|
+
logIconDebug(`setAppDetails ok appIconPath=${detailsIcon}`);
|
|
2152
|
+
} else {
|
|
2153
|
+
logIconDebug(`setAppDetails skipped (missing path) detailsIcon=${detailsIcon || "null"}`);
|
|
2154
|
+
}
|
|
2155
|
+
} catch (e) {
|
|
2156
|
+
try {
|
|
2157
|
+
log(`setAppDetails: ${e?.message || e}`);
|
|
2158
|
+
} catch (_) {}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
applyWindowIcon();
|
|
2162
|
+
try {
|
|
2163
|
+
mainWindow.maximize();
|
|
2164
|
+
mainWindow.show();
|
|
2165
|
+
} catch (_) {}
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
mainWindow.webContents.once("did-finish-load", applyWindowIcon);
|
|
2169
|
+
|
|
2170
|
+
mainWindow.webContents.on("page-title-updated", (e) => {
|
|
2171
|
+
e.preventDefault();
|
|
2172
|
+
mainWindow.setTitle(windowTitle);
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
mainWindow.webContents.on("did-fail-load", (_event, code, errMsg, url) => {
|
|
2176
|
+
log(`did-fail-load: code=${code} ${errMsg} ${url}`);
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
mainWindow.webContents.on("did-start-loading", (_, url) => {
|
|
2180
|
+
if (!isDev && url && (url.endsWith("/index.html") || url.includes("/index.html"))) {
|
|
2181
|
+
const root = url.replace(/\/index\.html.*$/, "/");
|
|
2182
|
+
if (root !== url) {
|
|
2183
|
+
mainWindow.loadURL(root);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
if (isDev) {
|
|
2189
|
+
mainWindow.loadURL("http://localhost:8081");
|
|
2190
|
+
mainWindow.webContents.openDevTools();
|
|
2191
|
+
} else {
|
|
2192
|
+
mainWindow.loadURL("app://./");
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
mainWindow.on("closed", () => {
|
|
2196
|
+
if (suppressQuitForUpdateInstall) return;
|
|
2197
|
+
app.quit();
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
process.on("uncaughtException", (err) => {
|
|
2202
|
+
try {
|
|
2203
|
+
log(`uncaughtException: ${err.message}\n${err.stack}`);
|
|
2204
|
+
} catch (_) {}
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
app.whenReady().then(() => {
|
|
2208
|
+
if (process.platform === "win32") {
|
|
2209
|
+
// Dark native chrome (title bar / menu area) so the OS-drawn separator under the menu reads closer to #111111.
|
|
2210
|
+
nativeTheme.themeSource = "dark";
|
|
2211
|
+
}
|
|
2212
|
+
setupAppMenu();
|
|
2213
|
+
if (!isDev) {
|
|
2214
|
+
const appPath = app.getAppPath();
|
|
2215
|
+
const distPath = path.join(appPath, "dist");
|
|
2216
|
+
protocol.handle("app", (request) => {
|
|
2217
|
+
let urlPath = request.url.slice("app://".length).replace(/^\.?\//, "") || "index.html";
|
|
2218
|
+
const filePath = path.join(distPath, urlPath);
|
|
2219
|
+
const resolved = path.normalize(filePath);
|
|
2220
|
+
if (!resolved.startsWith(path.normalize(distPath)) || !fs.existsSync(resolved)) {
|
|
2221
|
+
return new Response("Not Found", { status: 404 });
|
|
2222
|
+
}
|
|
2223
|
+
return net.fetch(pathToFileURL(resolved).toString());
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
createWindow().catch((e) => {
|
|
2227
|
+
try {
|
|
2228
|
+
log(`createWindow: ${e?.message || e}`);
|
|
2229
|
+
} catch (_) {}
|
|
2230
|
+
});
|
|
2231
|
+
setupAutoUpdater();
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
app.on("window-all-closed", () => {
|
|
2235
|
+
if (suppressQuitForUpdateInstall) return;
|
|
2236
|
+
app.quit();
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
app.on("activate", () => {
|
|
2240
|
+
if (BrowserWindow.getAllWindows().length === 0) {
|
|
2241
|
+
createWindow().catch((e) => {
|
|
2242
|
+
try {
|
|
2243
|
+
log(`createWindow (activate): ${e?.message || e}`);
|
|
2244
|
+
} catch (_) {}
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
});
|