mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
* feat(app-update): add in-app update flow with native e2e coverage * docs(ai-workflow): infer closed-issue labels automatically * fix(app-update): address native updater review findings * fix(android): validate updater redirect hosts * fix(electron): harden updater file names
293 lines
8.5 KiB
JavaScript
293 lines
8.5 KiB
JavaScript
import { app, shell } from 'electron';
|
|
import fs, { createWriteStream } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
const isTrustedGitHubHost = (hostname) => hostname === 'github.com' || hostname === 'githubusercontent.com' || hostname.endsWith('.githubusercontent.com');
|
|
|
|
const LOCAL_TEST_HOSTS = new Set(
|
|
`${process.env.APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS || ''}`
|
|
.split(',')
|
|
.map((host) => host.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
);
|
|
|
|
const logUpdateDebug = (...args) => {
|
|
if (process.env.APP_UPDATE_DEBUG === '1') {
|
|
const message = `[app-updater] ${args.map((value) => (typeof value === 'string' ? value : JSON.stringify(value))).join(' ')}`;
|
|
console.log(message);
|
|
const debugLogPath = process.env.APP_UPDATE_DEBUG_LOG_PATH;
|
|
if (debugLogPath) {
|
|
try {
|
|
fs.appendFileSync(debugLogPath, `${message}\n`, 'utf8');
|
|
} catch {
|
|
// Ignore debug log write failures so they never affect the updater flow.
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const sanitizeFileName = (fileName) => {
|
|
const safeName = `${fileName || ''}`.split(/[\\/]/).pop()?.trim();
|
|
if (!safeName || safeName === '.' || safeName === '..') {
|
|
return '5chan-update';
|
|
}
|
|
|
|
return safeName;
|
|
};
|
|
|
|
const runDetachedCommand = (command, args) => {
|
|
const child = spawn(command, args, {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
});
|
|
child.unref();
|
|
};
|
|
|
|
const runCommand = (command, args) =>
|
|
new Promise((resolve, reject) => {
|
|
let stderr = '';
|
|
const child = spawn(command, args, {
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
child.on('error', reject);
|
|
child.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
reject(new Error(stderr.trim() || `${command} exited with code ${code}`));
|
|
});
|
|
});
|
|
|
|
const isAllowedDownloadHost = (parsedUrl) => {
|
|
const hostname = parsedUrl.hostname.toLowerCase();
|
|
if (parsedUrl.protocol === 'https:' && isTrustedGitHubHost(hostname)) {
|
|
return true;
|
|
}
|
|
|
|
return LOCAL_TEST_HOSTS.has(hostname) && (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:');
|
|
};
|
|
|
|
const validateDownloadUrl = (url) => {
|
|
const parsedUrl = new URL(url);
|
|
|
|
if (!isAllowedDownloadHost(parsedUrl)) {
|
|
throw new Error('Only approved release asset hosts are supported');
|
|
}
|
|
|
|
return parsedUrl;
|
|
};
|
|
|
|
const downloadReleaseAsset = async ({ url, fileName }) => {
|
|
logUpdateDebug('starting asset download', { url, fileName });
|
|
validateDownloadUrl(url);
|
|
|
|
const updatesDirectory = path.join(app.getPath('temp'), '5chan-updates');
|
|
const targetPath = path.join(updatesDirectory, sanitizeFileName(fileName));
|
|
const tempPath = `${targetPath}.download`;
|
|
|
|
await fs.promises.mkdir(updatesDirectory, { recursive: true });
|
|
await fs.promises.rm(tempPath, { force: true });
|
|
|
|
const response = await fetch(url, {
|
|
redirect: 'follow',
|
|
});
|
|
validateDownloadUrl(response.url);
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Failed to download update (${response.status})`);
|
|
}
|
|
|
|
await pipeline(Readable.fromWeb(response.body), createWriteStream(tempPath));
|
|
await fs.promises.rm(targetPath, { force: true });
|
|
await fs.promises.rename(tempPath, targetPath);
|
|
logUpdateDebug('downloaded release asset', { url: response.url, targetPath });
|
|
|
|
return targetPath;
|
|
};
|
|
|
|
const findMacAppBundlePath = () => {
|
|
let currentPath = process.execPath;
|
|
|
|
while (currentPath && currentPath !== path.dirname(currentPath)) {
|
|
if (currentPath.endsWith('.app')) {
|
|
return currentPath;
|
|
}
|
|
|
|
currentPath = path.dirname(currentPath);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const findExtractedAppBundle = async (directoryPath) => {
|
|
const entries = await fs.promises.readdir(directoryPath, {
|
|
withFileTypes: true,
|
|
});
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(directoryPath, entry.name);
|
|
|
|
if (entry.isDirectory() && entry.name.endsWith('.app')) {
|
|
return entryPath;
|
|
}
|
|
|
|
if (entry.isDirectory()) {
|
|
const nestedMatch = await findExtractedAppBundle(entryPath);
|
|
if (nestedMatch) {
|
|
return nestedMatch;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const scheduleMacAppBundleInstall = async (zipPath) => {
|
|
const currentAppBundlePath = findMacAppBundlePath();
|
|
if (!currentAppBundlePath) {
|
|
throw new Error('Could not resolve the current macOS app bundle');
|
|
}
|
|
|
|
const stagingRoot = path.join(app.getPath('temp'), '5chan-updates', `staged-mac-${Date.now()}`);
|
|
await fs.promises.mkdir(stagingRoot, { recursive: true });
|
|
logUpdateDebug('extracting mac update zip', { zipPath, stagingRoot });
|
|
await runCommand('/usr/bin/ditto', ['-x', '-k', zipPath, stagingRoot]);
|
|
|
|
const stagedAppBundlePath = await findExtractedAppBundle(stagingRoot);
|
|
if (!stagedAppBundlePath) {
|
|
throw new Error('Downloaded update does not contain a macOS app bundle');
|
|
}
|
|
logUpdateDebug('resolved staged mac app bundle', { stagedAppBundlePath });
|
|
|
|
const installerScriptPath = path.join(stagingRoot, 'install-update.sh');
|
|
const script = `#!/bin/sh
|
|
set -eu
|
|
TARGET_APP="$1"
|
|
SOURCE_APP="$2"
|
|
CURRENT_PID="$3"
|
|
|
|
for _ in $(seq 1 120); do
|
|
if ! kill -0 "$CURRENT_PID" 2>/dev/null; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
STAGED_TARGET="${TARGET_APP}.new"
|
|
PREVIOUS_TARGET="${TARGET_APP}.old"
|
|
rm -rf "$STAGED_TARGET" "$PREVIOUS_TARGET"
|
|
/usr/bin/ditto "$SOURCE_APP" "$STAGED_TARGET"
|
|
if [ -e "$TARGET_APP" ]; then
|
|
mv "$TARGET_APP" "$PREVIOUS_TARGET"
|
|
fi
|
|
if ! mv "$STAGED_TARGET" "$TARGET_APP"; then
|
|
if [ -e "$PREVIOUS_TARGET" ]; then
|
|
mv "$PREVIOUS_TARGET" "$TARGET_APP"
|
|
fi
|
|
exit 1
|
|
fi
|
|
/usr/bin/open -n "$TARGET_APP"
|
|
rm -rf "$PREVIOUS_TARGET" "$(dirname "$SOURCE_APP")"
|
|
`;
|
|
await fs.promises.writeFile(installerScriptPath, script, 'utf8');
|
|
await fs.promises.chmod(installerScriptPath, 0o755);
|
|
logUpdateDebug('wrote mac installer script', { installerScriptPath });
|
|
|
|
runDetachedCommand('/bin/sh', [installerScriptPath, currentAppBundlePath, stagedAppBundlePath, `${process.pid}`]);
|
|
logUpdateDebug('spawned mac installer helper', { currentAppBundlePath, stagedAppBundlePath, currentPid: process.pid });
|
|
};
|
|
|
|
const resolveCurrentLinuxAppImagePath = () => {
|
|
if (typeof process.env.APPIMAGE === 'string' && process.env.APPIMAGE.trim().length > 0) {
|
|
return process.env.APPIMAGE.trim();
|
|
}
|
|
|
|
return process.execPath.endsWith('.AppImage') ? process.execPath : null;
|
|
};
|
|
|
|
const scheduleLinuxAppImageInstall = async (installerPath) => {
|
|
const currentAppImagePath = resolveCurrentLinuxAppImagePath();
|
|
if (!currentAppImagePath) {
|
|
throw new Error('Could not resolve the current AppImage path');
|
|
}
|
|
|
|
await fs.promises.chmod(installerPath, 0o755);
|
|
|
|
const installerScriptPath = path.join(path.dirname(installerPath), `install-update-${Date.now()}.sh`);
|
|
const script = `#!/bin/sh
|
|
set -eu
|
|
TARGET_APPIMAGE="$1"
|
|
DOWNLOADED_APPIMAGE="$2"
|
|
CURRENT_PID="$3"
|
|
|
|
for _ in $(seq 1 120); do
|
|
if ! kill -0 "$CURRENT_PID" 2>/dev/null; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
mv "$DOWNLOADED_APPIMAGE" "$TARGET_APPIMAGE"
|
|
chmod 755 "$TARGET_APPIMAGE"
|
|
"$TARGET_APPIMAGE" >/dev/null 2>&1 &
|
|
rm -f "$0"
|
|
`;
|
|
await fs.promises.writeFile(installerScriptPath, script, 'utf8');
|
|
await fs.promises.chmod(installerScriptPath, 0o755);
|
|
|
|
runDetachedCommand('/bin/sh', [installerScriptPath, currentAppImagePath, installerPath, `${process.pid}`]);
|
|
};
|
|
|
|
const openDownloadedUpdate = async (installerPath) => {
|
|
if (process.platform === 'darwin' && installerPath.toLowerCase().endsWith('.zip')) {
|
|
await scheduleMacAppBundleInstall(installerPath);
|
|
return 'quit-and-relaunch';
|
|
}
|
|
|
|
if (installerPath.toLowerCase().endsWith('.appimage')) {
|
|
await scheduleLinuxAppImageInstall(installerPath);
|
|
return 'quit-and-relaunch';
|
|
}
|
|
|
|
const shellResult = await shell.openPath(installerPath);
|
|
if (shellResult) {
|
|
throw new Error(shellResult);
|
|
}
|
|
|
|
return 'external-installer';
|
|
};
|
|
|
|
const downloadAndInstallUpdate = async ({ url, fileName }) => {
|
|
logUpdateDebug('received update install request', { url, fileName });
|
|
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
throw new Error('Update url is required');
|
|
}
|
|
|
|
const installerPath = await downloadReleaseAsset({
|
|
url: url.trim(),
|
|
fileName,
|
|
});
|
|
|
|
const installMode = await openDownloadedUpdate(installerPath);
|
|
logUpdateDebug('prepared update install', { installMode, installerPath });
|
|
|
|
if (installMode === 'quit-and-relaunch') {
|
|
setTimeout(() => {
|
|
logUpdateDebug('quitting app for update');
|
|
app.quit();
|
|
}, 200);
|
|
}
|
|
};
|
|
|
|
export { downloadAndInstallUpdate, sanitizeFileName };
|