refactor: migrate from electron-rebuild to electron-forge

This commit is contained in:
plebeius
2026-01-30 12:28:41 +08:00
parent 7b4d7b6b9a
commit 815f618320
15 changed files with 1867 additions and 825 deletions
-41
View File
@@ -1,41 +0,0 @@
const fs = require('fs-extra');
const path = require('path');
const { execSync } = require('child_process');
const packageJson = require('../package.json');
const rootPath = path.resolve(__dirname, '..');
const distFolderPath = path.resolve(rootPath, 'dist');
function addPortableToPortableExecutableFileName() {
const files = fs.readdirSync(distFolderPath);
for (const file of files) {
if (file.endsWith('.exe') && !file.match('Setup')) {
const filePath = path.resolve(distFolderPath, file);
const renamedFilePath = path.resolve(distFolderPath, file.replace('5chan', '5chan Portable'));
fs.moveSync(filePath, renamedFilePath);
}
}
}
function createHtmlArchive() {
if (process.platform !== 'linux') {
return;
}
const zipBinPath = path.resolve(rootPath, 'node_modules', '7zip-bin', 'linux', 'x64', '7za');
const fivechanHtmlFolderName = `5chan-html-${packageJson.version}`;
const outputFile = path.resolve(distFolderPath, `${fivechanHtmlFolderName}.zip`);
// Vite outputs to 'dist', not 'build' (CRA default)
const inputFolder = path.resolve(rootPath, 'dist');
try {
// Exclude Electron builder artifacts from HTML archive
const excludes = '-xr!*.AppImage -xr!*.exe -xr!*.dmg -xr!*.blockmap -xr!*.yml -xr!*.yaml -xr!win-unpacked -xr!mac -xr!mac-arm64 -xr!linux-unpacked -xr!builder-*';
execSync(`${zipBinPath} a ${outputFile} ${inputFolder} ${excludes}`);
execSync(`${zipBinPath} rn -r ${outputFile} dist ${fivechanHtmlFolderName}`);
} catch (e) {
console.error('electron build createHtmlArchive error:', e);
}
}
module.exports = async function afterAllArtifactBuild(buildResult) {
addPortableToPortableExecutableFileName();
createHtmlArchive();
};
+38 -45
View File
@@ -19,9 +19,26 @@ const ipfsClientLinuxPath = path.join(ipfsClientsPath, 'linux');
// official kubo download links https://docs.ipfs.tech/install/command-line/#install-official-binary-distributions
const ipfsClientVersion = '0.32.1';
const ipfsClientWindowsUrl = `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_windows-amd64.zip`;
const ipfsClientMacUrl = `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_darwin-amd64.tar.gz`;
const ipfsClientLinuxUrl = `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_linux-amd64.tar.gz`;
// Resolve desired build arch: allow overriding via env (so cross-arch builds pick correct binary)
const resolveBuildArch = () => {
const envArch = process.env.BUILD_ARCH;
if (envArch === 'arm64' || envArch === 'x64') return envArch;
// fallback to host arch
if (process.arch === 'arm64') return 'arm64';
return 'x64';
};
const toKuboArch = (arch) => (arch === 'arm64' ? 'arm64' : 'amd64');
const getKuboUrl = (platform) => {
const arch = toKuboArch(resolveBuildArch());
if (platform === 'win32') return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_windows-${arch}.zip`;
if (platform === 'darwin') return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_darwin-${arch}.tar.gz`;
if (platform === 'linux') return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_linux-${arch}.tar.gz`;
// default to linux
return `https://dist.ipfs.io/kubo/v${ipfsClientVersion}/kubo_v${ipfsClientVersion}_linux-${arch}.tar.gz`;
};
const downloadWithProgress = (url) =>
new Promise((resolve, reject) => {
@@ -79,25 +96,6 @@ const downloadWithRetry = async (url, retries = 3) => {
}
};
// plebbit kubo downloads dont need to be extracted
const download = async (url, destinationPath) => {
let binName = 'ipfs';
if (destinationPath.endsWith('win')) {
binName += '.exe';
}
const binPath = path.join(destinationPath, binName);
// already downloaded, don't download again
if (fs.pathExistsSync(binPath)) {
return;
}
const split = url.split('/');
const fileName = split[split.length - 1];
const downloadPath = path.join(destinationPath, fileName);
const file = await downloadWithRetry(url);
fs.ensureDirSync(destinationPath);
await fs.writeFile(binPath, file);
};
// official kubo downloads need to be extracted
const downloadAndExtract = async (url, destinationPath) => {
let binName = 'ipfs';
@@ -111,14 +109,14 @@ const downloadAndExtract = async (url, destinationPath) => {
console.log(`Downloading IPFS client from ${url} to ${destinationPath}`);
const split = url.split('/');
const fileName = split[split.length - 1];
const downloadPath = path.join(destinationPath, fileName);
const archivePath = path.join(destinationPath, fileName);
const file = await downloadWithRetry(url);
fs.ensureDirSync(destinationPath);
await fs.writeFile(downloadPath, file);
console.log(`Downloaded archive to ${downloadPath}`);
console.log(`Extracting ${downloadPath} to ${destinationPath}`);
await fs.writeFile(archivePath, file);
console.log(`Downloaded archive to ${archivePath}`);
console.log(`Extracting ${archivePath} to ${destinationPath}`);
try {
await decompress(downloadPath, destinationPath);
await decompress(archivePath, destinationPath);
console.log('Decompression complete');
} catch (err) {
console.error('Error during decompression:', err);
@@ -130,31 +128,26 @@ const downloadAndExtract = async (url, destinationPath) => {
fs.moveSync(extractedBinPath, binPath);
console.log('Binary moved');
console.log('Cleaning up temporary files');
fs.removeSync(downloadPath);
fs.removeSync(archivePath);
console.log('Cleanup complete');
};
export const downloadIpfsClients = async () => {
const platform = process.platform;
console.log(`Starting IPFS client download for platform: ${platform}`);
switch (platform) {
case 'win32':
await downloadAndExtract(ipfsClientWindowsUrl, ipfsClientWindowsPath);
break;
case 'darwin':
await downloadAndExtract(ipfsClientMacUrl, ipfsClientMacPath);
break;
case 'linux':
await downloadAndExtract(ipfsClientLinuxUrl, ipfsClientLinuxPath);
break;
default:
console.warn(`Unknown platform: ${platform}, downloading all IPFS clients`);
await downloadAndExtract(ipfsClientWindowsUrl, ipfsClientWindowsPath);
await downloadAndExtract(ipfsClientMacUrl, ipfsClientMacPath);
await downloadAndExtract(ipfsClientLinuxUrl, ipfsClientLinuxPath);
console.log(`Starting IPFS client download for platform: ${platform}, targetArch: ${resolveBuildArch()}`);
const url = getKuboUrl(platform);
if (platform === 'win32') {
await downloadAndExtract(url, ipfsClientWindowsPath);
} else if (platform === 'darwin') {
await downloadAndExtract(url, ipfsClientMacPath);
} else if (platform === 'linux') {
await downloadAndExtract(url, ipfsClientLinuxPath);
} else {
console.warn(`Unknown platform: ${platform}, defaulting to linux path`);
await downloadAndExtract(url, ipfsClientLinuxPath);
}
};
export default async (context) => {
export default async (_context) => {
await downloadIpfsClients();
};
+15 -18
View File
@@ -11,7 +11,7 @@ fi
node electron/download-ipfs || { echo "Error: failed script 'node electron/download-ipfs'" ; exit 1; }
dockerfile='
FROM electronuserland/builder:16
FROM node:22
# install node_modules
WORKDIR /usr/src/5chan
@@ -19,44 +19,41 @@ COPY ./package.json .
COPY ./yarn.lock .
RUN yarn
# build native dependencies like sqlite3
RUN electron-builder install-app-deps
# copy source files
# copy source files and configs
COPY ./bin ./bin
COPY ./electron ./electron
COPY ./src ./src
COPY ./public ./public
# required or yarn build fails
COPY ./.eslintrc.json ./.eslintrc.json
COPY ./.prettierrc ./.prettierrc
COPY ./forge.config.js ./forge.config.js
COPY ./vite.config.js ./vite.config.js
COPY ./tsconfig.json ./tsconfig.json
COPY ./index.html ./index.html
# react build
RUN yarn build
'
# build electron-builder docker image
# build electron-forge docker image
# temporary .dockerignore to save build time
echo $'node_modules\ndist' > .dockerignore
echo $'node_modules\nbuild\nout' > .dockerignore
echo "$dockerfile" | sudo docker build \
. \
--tag 5chan-electron-builder \
--tag 5chan-electron-forge \
--file -
rm .dockerignore
# build linux binary
sudo docker run \
--name 5chan-electron-builder \
--volume "$root_path"/dist:/usr/src/5chan/dist \
--name 5chan-electron-forge \
--volume "$root_path"/out:/usr/src/5chan/out \
--rm \
5chan-electron-builder \
5chan-electron-forge \
yarn electron:build:linux
# build windows binary
sudo docker run \
--name 5chan-electron-builder \
--volume "$root_path"/dist:/usr/src/5chan/dist \
--name 5chan-electron-forge \
--volume "$root_path"/out:/usr/src/5chan/out \
--rm \
5chan-electron-builder \
5chan-electron-forge \
yarn electron:build:windows
+3 -3
View File
@@ -94,7 +94,7 @@ const createMainWindow = () => {
nodeIntegration: false,
contextIsolation: true,
devTools: true, // TODO: change to isDev when no bugs left
preload: path.join(dirname, '../dist/electron/preload.cjs'),
preload: path.join(dirname, '../build/electron/preload.cjs'),
},
});
@@ -140,7 +140,7 @@ const createMainWindow = () => {
callback({ responseHeaders: details.responseHeaders });
});
const startURL = isDev ? 'http://localhost:3000' : `file://${path.join(dirname, '../dist/index.html')}`;
const startURL = isDev ? 'http://localhost:3000' : `file://${path.join(dirname, '../build/index.html')}`;
mainWindow.loadURL(startURL);
@@ -244,7 +244,7 @@ const createMainWindow = () => {
if (process.platform !== 'darwin') {
// tray
const trayIconPath = path.join(dirname, '..', isDev ? 'public' : 'dist', 'electron-tray-icon.png');
const trayIconPath = path.join(dirname, '..', isDev ? 'public' : 'build', 'electron-tray-icon.png');
const tray = new Tray(trayIconPath);
tray.setToolTip('5chan');
const trayMenu = Menu.buildFromTemplate([
+85 -36
View File
@@ -6,43 +6,92 @@ import ps from 'node:process';
import proxyServer from './proxy-server.js';
import tcpPortUsed from 'tcp-port-used';
import EnvPaths from 'env-paths';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';
const dirname = path.join(path.dirname(fileURLToPath(import.meta.url)));
const envPaths = EnvPaths('plebbit', { suffix: false });
// Get platform-specific binary name
const getIpfsBinaryName = () => (process.platform === 'win32' ? 'ipfs.exe' : 'ipfs');
// Get platform subdirectory name for bin/ folder
const getPlatformDir = () => {
if (process.platform === 'win32') return 'win';
if (process.platform === 'darwin') return 'mac';
return 'linux';
};
// Resolve kubo binary path
const getKuboPath = async () => {
if (isDev) {
// In dev, use kubo from node_modules
const { path: getKuboBinaryPath } = await import('kubo');
return getKuboBinaryPath();
} else {
// In production, the binary is downloaded to bin/<platform>/ipfs by generateAssets hook
// With asar: false, files are at resources/app/ instead of resources/app.asar.unpacked
const appPath = process.resourcesPath;
const binaryName = getIpfsBinaryName();
const platformDir = getPlatformDir();
// Try the bin/ directory first (where generateAssets downloads binaries)
const binDirPath = path.join(appPath, 'app', 'bin', platformDir, binaryName);
if (fs.existsSync(binDirPath)) {
return binDirPath;
}
// Fallback: try app.asar.unpacked for ASAR builds (if we ever re-enable ASAR)
const unpackedPath = path.join(appPath, 'app.asar.unpacked');
const kuboModulePath = path.join(unpackedPath, 'node_modules', 'kubo');
// Try to import kubo from unpacked location
try {
const kuboUrl = pathToFileURL(path.resolve(kuboModulePath)).href;
const kuboModule = await import(kuboUrl);
const { path: getKuboBinaryPath } = kuboModule;
return getKuboBinaryPath();
} catch (err) {
// Fallback: try to find the binary directly in kubo module
const kuboBinPath = path.join(kuboModulePath, 'kubo', binaryName);
if (fs.existsSync(kuboBinPath)) {
return kuboBinPath;
}
// Last resort: check in resources/app/node_modules/kubo for non-ASAR builds
const appModulePath = path.join(appPath, 'app', 'node_modules', 'kubo');
const appKuboBinPath = path.join(appModulePath, 'kubo', binaryName);
if (fs.existsSync(appKuboBinPath)) {
return appKuboBinPath;
}
throw new Error(`Could not find kubo binary. Checked: ${binDirPath}, ${kuboBinPath}, ${appKuboBinPath}`);
}
}
};
// use this custom function instead of spawnSync for better logging
// also spawnSync might have been causing crash on start on windows
const spawnAsync = (...args) =>
new Promise((resolve, reject) => {
const spawedProcess = spawn(...args);
spawedProcess.on('exit', (exitCode, signal) => {
const spawnedProcess = spawn(...args);
spawnedProcess.on('exit', (exitCode, signal) => {
if (exitCode === 0) resolve();
else reject(Error(`spawnAsync process '${spawedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`));
else reject(Error(`spawnAsync process '${spawnedProcess.pid}' exited with code '${exitCode}' signal '${signal}'`));
});
spawedProcess.stderr.on('data', (data) => console.error(data.toString()));
spawedProcess.stdin.on('data', (data) => console.log(data.toString()));
spawedProcess.stdout.on('data', (data) => console.log(data.toString()));
spawedProcess.on('error', (data) => console.error(data.toString()));
// Always surface errors from short-lived commands
spawnedProcess.stderr.on('data', (data) => console.error(data.toString()));
// Short-lived command stdout can be useful in dev, but is noisy in prod
if (isDev) {
spawnedProcess.stdout.on('data', (data) => console.log(data.toString()));
} else {
// Drain to avoid backpressure without logging
spawnedProcess.stdout.on('data', () => {});
}
spawnedProcess.on('error', (data) => console.error(data.toString?.() || String(data)));
});
const startIpfs = async () => {
const ipfsFileName = process.platform == 'win32' ? 'ipfs.exe' : 'ipfs';
let ipfsPath = path.join(process.resourcesPath, 'bin', ipfsFileName);
let ipfsDataPath = path.join(envPaths.data, 'ipfs');
// test launching the ipfs binary in dev mode
// they must be downloaded first using `yarn electron:build`
if (isDev) {
let binFolderName = 'win';
if (process.platform === 'linux') {
binFolderName = 'linux';
}
if (process.platform === 'darwin') {
binFolderName = 'mac';
}
ipfsPath = path.join(dirname, '..', 'bin', binFolderName, ipfsFileName);
ipfsDataPath = path.join(dirname, '..', '.plebbit', 'ipfs');
}
const ipfsPath = await getKuboPath();
const ipfsDataPath = isDev ? path.join(dirname, '..', '.plebbit', 'ipfs') : path.join(envPaths.data, 'ipfs');
if (!fs.existsSync(ipfsPath)) {
throw Error(`ipfs binary '${ipfsPath}' doesn't exist`);
@@ -51,16 +100,17 @@ const startIpfs = async () => {
console.log({ ipfsPath, ipfsDataPath });
fs.ensureDirSync(ipfsDataPath);
const env = { IPFS_PATH: ipfsDataPath };
// Reduce IPFS daemon log verbosity in production to avoid UI lag from excessive logging
const env = { ...process.env, IPFS_PATH: ipfsDataPath, ...(isDev ? {} : { GOLOG_LOG_LEVEL: 'error' }) };
// init ipfs client on first launch
try {
await spawnAsync(ipfsPath, ['init'], { env, hideWindows: true });
} catch (e) {}
} catch {}
// make sure repo is migrated
try {
await spawnAsync(ipfsPath, ['repo', 'migrate'], { env, hideWindows: true });
} catch (e) {}
} catch {}
// dont use 8080 port because it's too common
await spawnAsync(ipfsPath, ['config', '--json', 'Addresses.Gateway', '"/ip4/127.0.0.1/tcp/6473"'], {
@@ -83,17 +133,16 @@ const startIpfs = async () => {
let lastError;
ipfsProcess.stderr.on('data', (data) => {
lastError = data.toString();
console.error(data.toString());
if (isDev) console.error(lastError);
});
ipfsProcess.stdin.on('data', (data) => console.log(data.toString()));
ipfsProcess.stdout.on('data', (data) => {
data = data.toString();
console.log(data);
if (data.includes('Daemon is ready')) {
ipfsProcess.stdout.on('data', (chunk) => {
const text = chunk.toString();
if (isDev) console.log(text);
if (text.includes('Daemon is ready')) {
resolve();
}
});
ipfsProcess.on('error', (data) => console.error(data.toString()));
ipfsProcess.on('error', (err) => console.error(err?.toString?.() || String(err)));
ipfsProcess.on('exit', () => {
console.error(`ipfs process with pid ${ipfsProcess.pid} exited`);
reject(Error(lastError));
@@ -135,7 +184,7 @@ const startIpfsAutoRestart = async () => {
try {
// try to run exported onError callback, can be undefined
DefaultExport.onError(e)?.catch?.(console.log);
} catch (e) {}
} catch {}
}
pendingStart = false;
};
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
fileName: () => 'preload.cjs',
formats: ['cjs'],
},
outDir: resolve(__dirname, '../dist/electron'),
outDir: resolve(__dirname, '../build/electron'),
emptyOutDir: true,
rollupOptions: {
external: ['electron'],