11import {
22 app ,
33 BrowserWindow ,
4+ dialog ,
45 ipcMain ,
56 Menu ,
67 nativeTheme ,
78 shell ,
89} from "electron" ;
10+ import * as https from "https" ;
911import path from "path" ;
1012
1113const APP_ID = "com.lite.sqlearner" ;
1214const isDev = Boolean ( process . env . VITE_DEV_SERVER_URL ) ;
1315const isWindows = process . platform === "win32" ;
1416const 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 ;
1521let mainWindow : BrowserWindow | null = null ;
22+ let updateCheckInProgress = false ;
23+ let latestPromptedVersion = "" ;
1624
1725type 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
1943const 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+
50316if ( 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 ( ! / ^ h t t p s ? : \/ \/ / 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 ( ) ;
0 commit comments