Files
5chan/electron/app-updater.js
T

251 lines
6.7 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 LOCAL_TEST_HOSTS = new Set(
`${process.env.APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS || ''}`
.split(',')
.map((host) => host.trim().toLowerCase())
.filter(Boolean),
);
const sanitizeFileName = (fileName) => {
const safeName = `${fileName || ''}`.split(/[\\/]/).pop()?.trim();
return safeName || '5chan-update';
};
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:' && hostname === 'github.com') {
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 }) => {
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',
});
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);
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 });
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');
}
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
rm -rf "$TARGET_APP"
/usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
/usr/bin/open -n "$TARGET_APP"
rm -rf "$(dirname "$SOURCE_APP")"
`;
await fs.promises.writeFile(installerScriptPath, script, 'utf8');
await fs.promises.chmod(installerScriptPath, 0o755);
runDetachedCommand('/bin/sh', [installerScriptPath, currentAppBundlePath, stagedAppBundlePath, `${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 }) => {
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);
if (installMode === 'quit-and-relaunch') {
setTimeout(() => {
app.exit(0);
}, 200);
}
};
export { downloadAndInstallUpdate };