11import { app , BrowserWindow , dialog , ipcMain , shell } from 'electron' ;
2- import { autoUpdater } from 'electron-updater' ;
2+ import { autoUpdater , type UpdateDownloadedEvent } from 'electron-updater' ;
33import path , { dirname } from 'path' ;
44import fs from 'fs/promises' ;
55import os , { platform } from 'os' ;
@@ -61,6 +61,16 @@ let mainWindow: BrowserWindow | null = null;
6161let logStream : fsSync . WriteStream | null = null ;
6262const taskLogStreams = new Map < string , fsSync . WriteStream > ( ) ;
6363
64+ type DownloadedUpdateValidation = {
65+ version : string ;
66+ filePath : string | null ;
67+ expectedFileName ?: string ;
68+ validated : boolean ;
69+ error ?: string ;
70+ } ;
71+
72+ let lastDownloadedUpdateValidation : DownloadedUpdateValidation | null = null ;
73+
6474// --- Main Process Logger ---
6575type LogLevelString = 'debug' | 'info' | 'warn' | 'error' ;
6676const mainLogger = {
@@ -132,6 +142,242 @@ async function readSettings(): Promise<GlobalSettings> {
132142 return defaults ;
133143}
134144
145+ const UPDATE_REPO_OWNER = 'beNative' ;
146+ const UPDATE_REPO_NAME = 'git-automation' ;
147+ const GITHUB_API_BASE = `https://api.github.com/repos/${ UPDATE_REPO_OWNER } /${ UPDATE_REPO_NAME } ` ;
148+ const releaseAssetNameCache = new Map < string , string [ ] > ( ) ;
149+
150+ type FileValidationSuccess = {
151+ success : true ;
152+ filePath : string ;
153+ expectedName : string ;
154+ renamed ?: boolean ;
155+ officialNames : string [ ] ;
156+ } ;
157+
158+ type FileValidationFailure = {
159+ success : false ;
160+ error : string ;
161+ downloadedName : string ;
162+ officialNames : string [ ] ;
163+ } ;
164+
165+ type FileValidationResult = FileValidationSuccess | FileValidationFailure ;
166+
167+ const buildGitHubApiHeaders = async ( ) : Promise < Record < string , string > > => {
168+ const headers : Record < string , string > = {
169+ 'Accept' : 'application/vnd.github+json' ,
170+ 'User-Agent' : 'GitAutomationDashboard-Updater' ,
171+ } ;
172+ try {
173+ const settings = await readSettings ( ) ;
174+ if ( settings . githubPat ) {
175+ headers [ 'Authorization' ] = `token ${ settings . githubPat } ` ;
176+ }
177+ } catch ( error ) {
178+ mainLogger . warn ( '[AutoUpdate] Unable to read settings while preparing GitHub headers.' , error ) ;
179+ }
180+ return headers ;
181+ } ;
182+
183+ const fetchJsonFromGitHub = async ( url : string , headers : Record < string , string > ) : Promise < any | null > => {
184+ try {
185+ const response = await fetch ( url , { headers } ) ;
186+ if ( response . status === 404 ) {
187+ return null ;
188+ }
189+ if ( ! response . ok ) {
190+ const body = await response . text ( ) ;
191+ throw new Error ( `GitHub API error ${ response . status } : ${ body } ` ) ;
192+ }
193+ return await response . json ( ) ;
194+ } catch ( error : any ) {
195+ mainLogger . warn ( `[AutoUpdate] Failed to fetch GitHub data from ${ url } .` , error instanceof Error ? error : { message : String ( error ) } ) ;
196+ return null ;
197+ }
198+ } ;
199+
200+ const fetchReleaseByTag = async ( tag : string , headers : Record < string , string > ) => {
201+ const url = `${ GITHUB_API_BASE } /releases/tags/${ encodeURIComponent ( tag ) } ` ;
202+ return await fetchJsonFromGitHub ( url , headers ) ;
203+ } ;
204+
205+ const filterAssetNamesByExtension = ( assets : any [ ] , extension : string ) : string [ ] => {
206+ if ( ! Array . isArray ( assets ) ) {
207+ return [ ] ;
208+ }
209+ const normalizedExt = extension . toLowerCase ( ) ;
210+ return assets
211+ . map ( asset => typeof asset ?. name === 'string' ? asset . name . trim ( ) : '' )
212+ . filter ( ( name ) : name is string => Boolean ( name ) && ( normalizedExt ? path . extname ( name ) . toLowerCase ( ) === normalizedExt : true ) ) ;
213+ } ;
214+
215+ const fetchOfficialReleaseAssetNames = async ( version : string , extension : string ) : Promise < string [ ] > => {
216+ const cacheKey = `${ version } |${ extension } ` ;
217+ const cached = releaseAssetNameCache . get ( cacheKey ) ;
218+ if ( cached ) {
219+ return cached ;
220+ }
221+
222+ const headers = await buildGitHubApiHeaders ( ) ;
223+ const candidateTags = new Set < string > ( ) ;
224+ candidateTags . add ( version ) ;
225+ candidateTags . add ( version . startsWith ( 'v' ) ? version . replace ( / ^ v / , '' ) : `v${ version } ` ) ;
226+
227+ for ( const tag of candidateTags ) {
228+ const release = await fetchReleaseByTag ( tag , headers ) ;
229+ if ( release ?. assets ) {
230+ const names = filterAssetNamesByExtension ( release . assets , extension ) ;
231+ releaseAssetNameCache . set ( cacheKey , names ) ;
232+ if ( names . length > 0 ) {
233+ return names ;
234+ }
235+ }
236+ }
237+
238+ const releasesList = await fetchJsonFromGitHub ( `${ GITHUB_API_BASE } /releases?per_page=30` , headers ) ;
239+ if ( Array . isArray ( releasesList ) ) {
240+ for ( const release of releasesList ) {
241+ if ( typeof release ?. tag_name === 'string' && candidateTags . has ( release . tag_name ) ) {
242+ const names = filterAssetNamesByExtension ( release . assets , extension ) ;
243+ releaseAssetNameCache . set ( cacheKey , names ) ;
244+ return names ;
245+ }
246+ }
247+ }
248+
249+ releaseAssetNameCache . set ( cacheKey , [ ] ) ;
250+ return [ ] ;
251+ } ;
252+
253+ const getFileNameFromUrlLike = ( input : string ) : string | null => {
254+ if ( ! input ) {
255+ return null ;
256+ }
257+ try {
258+ const parsed = new URL ( input ) ;
259+ return path . basename ( parsed . pathname ) ;
260+ } catch ( error ) {
261+ const sanitized = input . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] ;
262+ if ( ! sanitized ) {
263+ return null ;
264+ }
265+ return path . basename ( sanitized ) ;
266+ }
267+ } ;
268+
269+ const extractCandidateNamesFromUpdateInfo = ( info : UpdateDownloadedEvent , extension : string ) : string [ ] => {
270+ const names = new Set < string > ( ) ;
271+ const normalizedExt = extension . toLowerCase ( ) ;
272+ const considerName = ( name : string | null | undefined ) => {
273+ if ( ! name ) {
274+ return ;
275+ }
276+ if ( ! normalizedExt || path . extname ( name ) . toLowerCase ( ) === normalizedExt ) {
277+ names . add ( name ) ;
278+ }
279+ } ;
280+
281+ if ( Array . isArray ( info . files ) ) {
282+ for ( const file of info . files ) {
283+ if ( typeof file ?. url === 'string' ) {
284+ considerName ( getFileNameFromUrlLike ( file . url ) ) ;
285+ }
286+ }
287+ }
288+
289+ if ( typeof ( info as any ) . path === 'string' ) {
290+ considerName ( path . basename ( ( info as any ) . path ) ) ;
291+ }
292+
293+ if ( typeof info . downloadedFile === 'string' ) {
294+ considerName ( path . basename ( info . downloadedFile ) ) ;
295+ }
296+
297+ return Array . from ( names ) ;
298+ } ;
299+
300+ const safeRenameDownloadedUpdate = async ( currentPath : string , desiredPath : string ) : Promise < void > => {
301+ if ( currentPath === desiredPath ) {
302+ return ;
303+ }
304+ try {
305+ await fs . unlink ( desiredPath ) ;
306+ } catch ( error : any ) {
307+ if ( error ?. code !== 'ENOENT' ) {
308+ throw error ;
309+ }
310+ }
311+ await fs . rename ( currentPath , desiredPath ) ;
312+ } ;
313+
314+ const updateCachedDownloadedUpdateMetadata = async ( expectedFileName : string , directory : string ) : Promise < void > => {
315+ const updateInfoPath = path . join ( directory , 'update-info.json' ) ;
316+ try {
317+ const raw = await fs . readFile ( updateInfoPath , 'utf-8' ) ;
318+ const parsed = JSON . parse ( raw ) ;
319+ if ( parsed && typeof parsed === 'object' ) {
320+ parsed . fileName = expectedFileName ;
321+ await fs . writeFile ( updateInfoPath , JSON . stringify ( parsed , null , 2 ) ) ;
322+ }
323+ } catch ( error : any ) {
324+ if ( error ?. code !== 'ENOENT' ) {
325+ mainLogger . warn ( '[AutoUpdate] Unable to update cached update metadata with corrected filename.' , error instanceof Error ? error : { message : String ( error ) } ) ;
326+ }
327+ }
328+ } ;
329+
330+ const ensureDownloadedFileMatchesOfficialRelease = async ( info : UpdateDownloadedEvent ) : Promise < FileValidationResult > => {
331+ if ( ! info . downloadedFile ) {
332+ return { success : false , error : 'Auto-updater did not provide a downloaded file path.' , downloadedName : '' , officialNames : [ ] } ;
333+ }
334+
335+ const downloadedPath = info . downloadedFile ;
336+ const downloadedName = path . basename ( downloadedPath ) ;
337+ const downloadedExt = path . extname ( downloadedName ) . toLowerCase ( ) ;
338+
339+ let officialNames : string [ ] = [ ] ;
340+ try {
341+ officialNames = await fetchOfficialReleaseAssetNames ( info . version , downloadedExt ) ;
342+ } catch ( error : any ) {
343+ mainLogger . warn ( '[AutoUpdate] Failed to retrieve official release filenames from GitHub.' , error instanceof Error ? error : { message : String ( error ) } ) ;
344+ }
345+
346+ if ( officialNames . length === 0 ) {
347+ officialNames = extractCandidateNamesFromUpdateInfo ( info , downloadedExt ) ;
348+ }
349+
350+ if ( officialNames . length === 0 ) {
351+ return { success : false , error : 'No official release filenames available for comparison.' , downloadedName, officialNames : [ ] } ;
352+ }
353+
354+ if ( officialNames . includes ( downloadedName ) ) {
355+ return { success : true , filePath : downloadedPath , expectedName : downloadedName , officialNames } ;
356+ }
357+
358+ const caseInsensitiveMatch = officialNames . find ( name => name . toLowerCase ( ) === downloadedName . toLowerCase ( ) ) ;
359+ if ( caseInsensitiveMatch ) {
360+ return { success : true , filePath : downloadedPath , expectedName : caseInsensitiveMatch , officialNames } ;
361+ }
362+
363+ const expectedName = officialNames [ 0 ] ;
364+ const expectedPath = path . join ( path . dirname ( downloadedPath ) , expectedName ) ;
365+ try {
366+ await safeRenameDownloadedUpdate ( downloadedPath , expectedPath ) ;
367+ await updateCachedDownloadedUpdateMetadata ( expectedName , path . dirname ( expectedPath ) ) ;
368+ const helper = ( autoUpdater as any ) ?. downloadedUpdateHelper ;
369+ if ( helper && typeof helper === 'object' ) {
370+ helper . _file = expectedPath ;
371+ if ( helper . _downloadedFileInfo ) {
372+ helper . _downloadedFileInfo . fileName = expectedName ;
373+ }
374+ }
375+ return { success : true , filePath : expectedPath , expectedName, renamed : true , officialNames } ;
376+ } catch ( error : any ) {
377+ return { success : false , error : error ?. message || String ( error ) , downloadedName, officialNames } ;
378+ }
379+ } ;
380+
135381const createWindow = ( ) => {
136382 // Create the browser window.
137383 mainWindow = new BrowserWindow ( {
@@ -191,13 +437,15 @@ app.on('ready', async () => {
191437 mainWindow ?. webContents . send ( 'update-status-change' , { status : 'checking' , message : 'Checking for updates...' } ) ;
192438 } ) ;
193439 autoUpdater . on ( 'update-available' , ( info ) => {
440+ lastDownloadedUpdateValidation = null ;
194441 mainLogger . info ( 'Update available.' , info ) ;
195442 mainWindow ?. webContents . send ( 'update-status-change' , { status : 'available' , message : `Update v${ info . version } available. Downloading...` } ) ;
196443 } ) ;
197444 autoUpdater . on ( 'update-not-available' , ( info ) => {
198445 mainLogger . info ( 'Update not available.' , info ) ;
199446 } ) ;
200447 autoUpdater . on ( 'error' , ( err ) => {
448+ lastDownloadedUpdateValidation = null ;
201449 mainLogger . error ( 'Error in auto-updater.' , err ) ;
202450 mainWindow ?. webContents . send ( 'update-status-change' , { status : 'error' , message : `Error in auto-updater: ${ err . message } ` } ) ;
203451 } ) ;
@@ -206,8 +454,50 @@ app.on('ready', async () => {
206454 mainLogger . debug ( log_message ) ;
207455 } ) ;
208456 autoUpdater . on ( 'update-downloaded' , ( info ) => {
209- mainLogger . info ( 'Update downloaded.' , info ) ;
210- mainWindow ?. webContents . send ( 'update-status-change' , { status : 'downloaded' , message : `Update v${ info . version } downloaded. Restart to install.` } ) ;
457+ void ( async ( ) => {
458+ try {
459+ const validationResult = await ensureDownloadedFileMatchesOfficialRelease ( info ) ;
460+ if ( ! validationResult . success ) {
461+ lastDownloadedUpdateValidation = { version : info . version , filePath : info . downloadedFile ?? null , validated : false , error : validationResult . error } ;
462+ mainLogger . error ( 'Downloaded update failed filename validation.' , {
463+ version : info . version ,
464+ downloadedName : validationResult . downloadedName ,
465+ officialNames : validationResult . officialNames ,
466+ error : validationResult . error ,
467+ } ) ;
468+ mainWindow ?. webContents . send ( 'update-status-change' , { status : 'error' , message : `Downloaded update failed validation: ${ validationResult . error } ` } ) ;
469+ return ;
470+ }
471+
472+ if ( validationResult . filePath !== info . downloadedFile ) {
473+ info . downloadedFile = validationResult . filePath ;
474+ }
475+
476+ lastDownloadedUpdateValidation = {
477+ version : info . version ,
478+ filePath : validationResult . filePath ,
479+ expectedFileName : validationResult . expectedName ,
480+ validated : true ,
481+ } ;
482+
483+ mainLogger . info ( 'Update downloaded.' , {
484+ version : info . version ,
485+ filePath : validationResult . filePath ,
486+ alignedWithOfficialName : validationResult . renamed === true ,
487+ } ) ;
488+
489+ const messageSuffix = validationResult . renamed ? ' and aligned with official filename' : '' ;
490+ mainWindow ?. webContents . send ( 'update-status-change' , {
491+ status : 'downloaded' ,
492+ message : `Update v${ info . version } downloaded${ messageSuffix } . Restart to install.` ,
493+ } ) ;
494+ } catch ( error : any ) {
495+ const message = error ?. message || String ( error ) ;
496+ lastDownloadedUpdateValidation = { version : info . version , filePath : info . downloadedFile ?? null , validated : false , error : message } ;
497+ mainLogger . error ( 'Error while validating downloaded update file.' , error ) ;
498+ mainWindow ?. webContents . send ( 'update-status-change' , { status : 'error' , message : `Failed to validate downloaded update: ${ message } ` } ) ;
499+ }
500+ } ) ( ) ;
211501 } ) ;
212502
213503 // Check for updates
@@ -262,6 +552,21 @@ ipcMain.handle('get-app-version', () => {
262552
263553// --- IPC handler to trigger restart & update ---
264554ipcMain . on ( 'restart-and-install-update' , ( ) => {
555+ if ( ! lastDownloadedUpdateValidation ?. validated ) {
556+ const errorMessage = lastDownloadedUpdateValidation ?. error || 'Update filename validation has not completed successfully.' ;
557+ mainLogger . error ( 'Preventing installation because the downloaded update failed filename validation.' , {
558+ version : lastDownloadedUpdateValidation ?. version ,
559+ error : errorMessage ,
560+ } ) ;
561+ mainWindow ?. webContents . send ( 'update-status-change' , { status : 'error' , message : `Cannot install update: ${ errorMessage } ` } ) ;
562+ return ;
563+ }
564+
565+ mainLogger . info ( 'Proceeding with quitAndInstall after successful filename validation.' , {
566+ version : lastDownloadedUpdateValidation . version ,
567+ filePath : lastDownloadedUpdateValidation . filePath ,
568+ expectedFileName : lastDownloadedUpdateValidation . expectedFileName ,
569+ } ) ;
265570 autoUpdater . quitAndInstall ( ) ;
266571} ) ;
267572
0 commit comments