Skip to content

Commit 7bce8a9

Browse files
authored
Merge pull request #7 from AperturePlus/develop
Develop
2 parents 66da61c + f4cb8ff commit 7bce8a9

File tree

6 files changed

+919
-218
lines changed

6 files changed

+919
-218
lines changed

electron/main.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,44 @@
11
import {
22
app,
33
BrowserWindow,
4+
dialog,
45
ipcMain,
56
Menu,
67
nativeTheme,
78
shell,
89
} from "electron";
10+
import * as https from "https";
911
import path from "path";
1012

1113
const APP_ID = "com.lite.sqlearner";
1214
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
1315
const isWindows = process.platform === "win32";
1416
const isMac = process.platform === "darwin";
17+
const RELEASES_PAGE_URL = "https://github.com/AperturePlus/lite-sqlearner/releases";
18+
const RELEASES_API_URL =
19+
"https://api.github.com/repos/AperturePlus/lite-sqlearner/releases/latest";
20+
const AUTO_UPDATE_CHECK_DELAY_MS = 5000;
1521
let mainWindow: BrowserWindow | null = null;
22+
let updateCheckInProgress = false;
23+
let latestPromptedVersion = "";
1624

1725
type ThemeMode = "light" | "dark";
26+
type UpdateCheckStatus = "update-available" | "up-to-date" | "error" | "checking";
27+
28+
interface UpdateCheckResult {
29+
status: UpdateCheckStatus;
30+
currentVersion: string;
31+
latestVersion?: string;
32+
releaseUrl?: string;
33+
releaseName?: string;
34+
message?: string;
35+
}
36+
37+
interface GithubReleasePayload {
38+
tag_name?: string;
39+
html_url?: string;
40+
name?: string;
41+
}
1842

1943
const getWindowPalette = (theme: ThemeMode) => {
2044
if (theme === "dark") {
@@ -47,6 +71,248 @@ const applyWindowTheme = (window: BrowserWindow, theme: ThemeMode) => {
4771
}
4872
};
4973

74+
const normalizeVersion = (version: string): string =>
75+
version.trim().replace(/^v/i, "");
76+
77+
const parseVersion = (version: string) => {
78+
const normalized = normalizeVersion(version);
79+
const [corePart, prereleasePart] = normalized.split("-", 2);
80+
const coreSegments = corePart
81+
.split(".")
82+
.filter(Boolean)
83+
.map((segment) => {
84+
const value = Number.parseInt(segment, 10);
85+
return Number.isNaN(value) ? 0 : value;
86+
});
87+
const prereleaseSegments = prereleasePart
88+
? prereleasePart
89+
.split(".")
90+
.filter(Boolean)
91+
.map((segment) => {
92+
if (/^\d+$/.test(segment)) {
93+
return Number.parseInt(segment, 10);
94+
}
95+
return segment.toLowerCase();
96+
})
97+
: [];
98+
return { coreSegments, prereleaseSegments };
99+
};
100+
101+
const comparePrerelease = (left: Array<number | string>, right: Array<number | string>) => {
102+
if (left.length === 0 && right.length === 0) {
103+
return 0;
104+
}
105+
if (left.length === 0) {
106+
return 1;
107+
}
108+
if (right.length === 0) {
109+
return -1;
110+
}
111+
112+
const maxLength = Math.max(left.length, right.length);
113+
for (let index = 0; index < maxLength; index += 1) {
114+
const leftSegment = left[index];
115+
const rightSegment = right[index];
116+
117+
if (leftSegment == null && rightSegment == null) {
118+
return 0;
119+
}
120+
if (leftSegment == null) {
121+
return -1;
122+
}
123+
if (rightSegment == null) {
124+
return 1;
125+
}
126+
if (leftSegment === rightSegment) {
127+
continue;
128+
}
129+
130+
if (typeof leftSegment === "number" && typeof rightSegment === "number") {
131+
return leftSegment > rightSegment ? 1 : -1;
132+
}
133+
if (typeof leftSegment === "number" && typeof rightSegment === "string") {
134+
return -1;
135+
}
136+
if (typeof leftSegment === "string" && typeof rightSegment === "number") {
137+
return 1;
138+
}
139+
if (leftSegment > rightSegment) {
140+
return 1;
141+
}
142+
if (leftSegment < rightSegment) {
143+
return -1;
144+
}
145+
}
146+
147+
return 0;
148+
};
149+
150+
const compareVersions = (leftVersion: string, rightVersion: string) => {
151+
const left = parseVersion(leftVersion);
152+
const right = parseVersion(rightVersion);
153+
const maxLength = Math.max(left.coreSegments.length, right.coreSegments.length, 3);
154+
155+
for (let index = 0; index < maxLength; index += 1) {
156+
const leftValue = left.coreSegments[index] ?? 0;
157+
const rightValue = right.coreSegments[index] ?? 0;
158+
if (leftValue === rightValue) {
159+
continue;
160+
}
161+
return leftValue > rightValue ? 1 : -1;
162+
}
163+
164+
return comparePrerelease(left.prereleaseSegments, right.prereleaseSegments);
165+
};
166+
167+
const getErrorMessage = (error: unknown) => {
168+
if (error instanceof Error) {
169+
return error.message;
170+
}
171+
return String(error ?? "Unknown error");
172+
};
173+
174+
const fetchLatestRelease = (): Promise<GithubReleasePayload> =>
175+
new Promise((resolve, reject) => {
176+
const request = https.request(
177+
RELEASES_API_URL,
178+
{
179+
method: "GET",
180+
headers: {
181+
"User-Agent": `Lite-SQLearner/${app.getVersion()}`,
182+
Accept: "application/vnd.github+json",
183+
},
184+
},
185+
(response) => {
186+
const statusCode = response.statusCode ?? 0;
187+
let rawData = "";
188+
response.setEncoding("utf8");
189+
response.on("data", (chunk: string) => {
190+
rawData += chunk;
191+
});
192+
response.on("end", () => {
193+
if (statusCode < 200 || statusCode >= 300) {
194+
reject(new Error(`GitHub API request failed (${statusCode})`));
195+
return;
196+
}
197+
try {
198+
const parsed = JSON.parse(rawData) as GithubReleasePayload;
199+
resolve(parsed);
200+
} catch (_error) {
201+
reject(new Error("Failed to parse release metadata"));
202+
}
203+
});
204+
}
205+
);
206+
207+
request.setTimeout(12000, () => {
208+
request.destroy(new Error("Update check timed out"));
209+
});
210+
211+
request.on("error", (error) => {
212+
reject(error);
213+
});
214+
215+
request.end();
216+
});
217+
218+
const isZhLocale = () => app.getLocale().toLowerCase().startsWith("zh");
219+
220+
const promptUpdateDialog = async (update: UpdateCheckResult) => {
221+
if (update.status !== "update-available") {
222+
return;
223+
}
224+
if (!mainWindow || mainWindow.isDestroyed()) {
225+
return;
226+
}
227+
228+
const zhLocale = isZhLocale();
229+
const { response } = await dialog.showMessageBox(mainWindow, {
230+
type: "info",
231+
title: zhLocale ? "发现新版本" : "Update Available",
232+
message: zhLocale
233+
? `检测到新版本 v${update.latestVersion}`
234+
: `A new version (v${update.latestVersion}) is available`,
235+
detail: zhLocale
236+
? `当前版本:v${update.currentVersion}\n最新版本:v${update.latestVersion}\n是否前往发布页下载更新?`
237+
: `Current version: v${update.currentVersion}\nLatest version: v${update.latestVersion}\nOpen the releases page to download the update?`,
238+
buttons: zhLocale ? ["前往下载", "稍后"] : ["Open Releases", "Later"],
239+
defaultId: 0,
240+
cancelId: 1,
241+
noLink: true,
242+
});
243+
244+
if (response === 0 && update.releaseUrl) {
245+
void shell.openExternal(update.releaseUrl);
246+
}
247+
};
248+
249+
const runUpdateCheck = async (manual: boolean): Promise<UpdateCheckResult> => {
250+
if (updateCheckInProgress) {
251+
return {
252+
status: "checking",
253+
currentVersion: app.getVersion(),
254+
message: "Update check is already running",
255+
};
256+
}
257+
258+
updateCheckInProgress = true;
259+
try {
260+
const currentVersion = normalizeVersion(app.getVersion());
261+
const latestRelease = await fetchLatestRelease();
262+
const latestVersion = normalizeVersion(latestRelease.tag_name || "");
263+
const releaseUrl = latestRelease.html_url || RELEASES_PAGE_URL;
264+
265+
if (!latestVersion) {
266+
throw new Error("Latest release version is missing");
267+
}
268+
269+
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
270+
if (!hasUpdate) {
271+
return {
272+
status: "up-to-date",
273+
currentVersion,
274+
latestVersion,
275+
releaseUrl,
276+
releaseName: latestRelease.name,
277+
};
278+
}
279+
280+
const result: UpdateCheckResult = {
281+
status: "update-available",
282+
currentVersion,
283+
latestVersion,
284+
releaseUrl,
285+
releaseName: latestRelease.name,
286+
};
287+
288+
const shouldPrompt = manual || latestPromptedVersion !== latestVersion;
289+
if (shouldPrompt) {
290+
latestPromptedVersion = latestVersion;
291+
await promptUpdateDialog(result);
292+
}
293+
294+
return result;
295+
} catch (error) {
296+
return {
297+
status: "error",
298+
currentVersion: normalizeVersion(app.getVersion()),
299+
message: getErrorMessage(error),
300+
};
301+
} finally {
302+
updateCheckInProgress = false;
303+
}
304+
};
305+
306+
const scheduleAutoUpdateCheck = () => {
307+
if (isDev || !app.isPackaged) {
308+
return;
309+
}
310+
311+
setTimeout(() => {
312+
void runUpdateCheck(false);
313+
}, AUTO_UPDATE_CHECK_DELAY_MS);
314+
};
315+
50316
if (isWindows) {
51317
app.setAppUserModelId(APP_ID);
52318
}
@@ -145,6 +411,18 @@ if (gotTheLock) {
145411
const firstPreferred = preferredLanguages[0];
146412
return firstPreferred || app.getLocale() || "en-US";
147413
});
414+
ipcMain.handle("app:check-for-updates", () => runUpdateCheck(true));
415+
ipcMain.handle("app:open-external", (_event, targetUrl: string) => {
416+
if (typeof targetUrl !== "string") {
417+
return false;
418+
}
419+
const normalizedUrl = targetUrl.trim();
420+
if (!/^https?:\/\//i.test(normalizedUrl)) {
421+
return false;
422+
}
423+
void shell.openExternal(normalizedUrl);
424+
return true;
425+
});
148426
ipcMain.on("app:set-window-theme", (_event, theme: ThemeMode) => {
149427
if (!mainWindow || mainWindow.isDestroyed()) {
150428
return;
@@ -156,6 +434,7 @@ if (gotTheLock) {
156434
});
157435

158436
createWindow();
437+
scheduleAutoUpdateCheck();
159438
app.on("activate", () => {
160439
if (BrowserWindow.getAllWindows().length === 0) {
161440
createWindow();

electron/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { contextBridge, ipcRenderer } from "electron";
33
contextBridge.exposeInMainWorld("electron", {
44
platform: process.platform,
55
getSystemLocale: () => ipcRenderer.invoke("app:get-system-locale"),
6+
checkForUpdates: () => ipcRenderer.invoke("app:check-for-updates"),
7+
openExternal: (url: string) => ipcRenderer.invoke("app:open-external", url),
68
setWindowTheme: (theme: "light" | "dark") =>
79
ipcRenderer.send("app:set-window-theme", theme),
810
});

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "lite-sqlearner",
33
"private": true,
4-
"version": "0.0.1",
4+
"version": "0.2.0",
55
"description": "An interactive, gamified SQL learning desktop application",
66
"author": "Lite-SQLearner Team",
77
"main": "dist-electron/main.js",
@@ -101,10 +101,11 @@
101101
"vue-router": "4"
102102
},
103103
"devDependencies": {
104+
"@types/node": "^22.0.0",
104105
"@types/sql.js": "^1.4.3",
105106
"@typescript-eslint/eslint-plugin": "^5.23.0",
106107
"@typescript-eslint/parser": "^5.23.0",
107-
"@vitejs/plugin-vue": "^3.0.3",
108+
"@vitejs/plugin-vue": "^6.0.4",
108109
"concurrently": "^8.2.2",
109110
"cross-env": "^7.0.3",
110111
"electron": "^31.0.2",
@@ -121,8 +122,8 @@
121122
"prettier": "^2.7.1",
122123
"sharp": "^0.34.5",
123124
"typescript": "^5.9.3",
124-
"vite": "^3.0.7",
125-
"vue-tsc": "^0.39.5",
125+
"vite": "^7.3.1",
126+
"vue-tsc": "^3.2.5",
126127
"wait-on": "^7.2.0"
127128
}
128129
}

0 commit comments

Comments
 (0)