Skip to content

Commit d5d3a9c

Browse files
authored
Merge pull request #70 from beNative/codex/fix-naming-mismatch-for-windows-build
Validate auto-update downloads against release filenames
2 parents d5aef12 + f85308e commit d5d3a9c

1 file changed

Lines changed: 308 additions & 3 deletions

File tree

electron/main.ts

Lines changed: 308 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
2-
import { autoUpdater } from 'electron-updater';
2+
import { autoUpdater, type UpdateDownloadedEvent } from 'electron-updater';
33
import path, { dirname } from 'path';
44
import fs from 'fs/promises';
55
import os, { platform } from 'os';
@@ -61,6 +61,16 @@ let mainWindow: BrowserWindow | null = null;
6161
let logStream: fsSync.WriteStream | null = null;
6262
const 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 ---
6575
type LogLevelString = 'debug' | 'info' | 'warn' | 'error';
6676
const 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+
135381
const 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 ---
264554
ipcMain.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

Comments
 (0)