diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index c48feb33..14a931c8 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -41,30 +41,23 @@ jobs: - name: Stage release assets working-directory: desktop-app run: | - VERSION="${GITHUB_REF_NAME}" STAGING="release-assets" mkdir -p "$STAGING" - # Windows executable from the portable Neutralino bundle - WIN_EXE="dist/markdown-viewer/markdown-viewer-win_x64.exe" - if [ -f "$WIN_EXE" ]; then - cp "$WIN_EXE" "$STAGING/markdown-viewer-win_x64.exe" - fi - - # Portable bundle directory from neu build --release - (cd dist && zip -r "../$STAGING/markdown-viewer-${VERSION}-portable.zip" markdown-viewer) - - # Source archive - cd .. - tar -czf "desktop-app/$STAGING/source.tar.gz" \ - --exclude='desktop-app/dist' \ - --exclude='desktop-app/bin' \ - --exclude='desktop-app/node_modules' \ - --exclude='desktop-app/output' \ - --exclude="desktop-app/$STAGING" \ - --exclude='.git' \ - desktop-app/ - cd desktop-app + # Publish every platform as a separate self-contained download. + EXPECTED_BINARIES=( + markdown-viewer-linux_arm64 + markdown-viewer-linux_armhf + markdown-viewer-linux_x64 + markdown-viewer-mac_arm64 + markdown-viewer-mac_universal + markdown-viewer-mac_x64 + markdown-viewer-win_x64.exe + ) + for filename in "${EXPECTED_BINARIES[@]}"; do + test -f "dist/markdown-viewer/$filename" + cp "dist/markdown-viewer/$filename" "$STAGING/$filename" + done # SHA256 checksums cd "$STAGING" diff --git a/desktop-app/build-standalone.js b/desktop-app/build-standalone.js new file mode 100644 index 00000000..856ba857 --- /dev/null +++ b/desktop-app/build-standalone.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +/** + * Build each Neutralino target as a self-contained executable. + * + * Embedding every target in one neu invocation can exhaust Node.js's heap when + * the application has many offline resources. Each target is therefore built + * separately, then copied into one flat dist/markdown-viewer directory. + */ + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const APP_DIR = __dirname; +const BIN_DIR = path.join(APP_DIR, "bin"); +const CONFIG_FILE = path.join(APP_DIR, "neutralino.config.json"); +const DIST_DIR = path.join(APP_DIR, "dist"); +const FINAL_DIR = path.join(DIST_DIR, "markdown-viewer"); +const TEMP_BUILD_ROOT = path.join(DIST_DIR, ".standalone-build"); +const NEU_CLI = "@neutralinojs/neu@11.7.0"; + +const TARGETS = [ + "linux_arm64", + "linux_armhf", + "linux_x64", + "mac_arm64", + "mac_universal", + "mac_x64", + "win_x64.exe", +].map((platform) => ({ + sourceName: `neutralino-${platform}`, + outputName: `markdown-viewer-${platform}`, + buildKey: platform.replace(/\.exe$/, ""), +})); + +function createConfigOverride(distributionPath, buildKey) { + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); + config.cli = config.cli || {}; + config.cli.distributionPath = path + .relative(APP_DIR, distributionPath) + .replace(/\\/g, "/"); + + const tmpDir = path.join(APP_DIR, ".tmp"); + fs.mkdirSync(tmpDir, { recursive: true }); + const configFile = path.join( + tmpDir, + `neutralino.${buildKey}.${process.pid}.config.json`, + ); + fs.writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); + return configFile; +} + +function hideOtherBinaries(selectedSourceName, holdingDir) { + const hidden = []; + fs.mkdirSync(holdingDir, { recursive: true }); + + for (const entry of fs.readdirSync(BIN_DIR, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.startsWith("neutralino-")) continue; + if (entry.name === selectedSourceName) continue; + + const from = path.join(BIN_DIR, entry.name); + const to = path.join(holdingDir, entry.name); + fs.renameSync(from, to); + hidden.push({ from, to }); + } + + return hidden; +} + +function restoreBinaries(hidden) { + for (let i = hidden.length - 1; i >= 0; i -= 1) { + const item = hidden[i]; + if (fs.existsSync(item.to)) { + fs.renameSync(item.to, item.from); + } + } +} + +function buildTarget(target) { + const sourcePath = path.join(BIN_DIR, target.sourceName); + if (!fs.existsSync(sourcePath)) { + throw new Error(`Missing ${target.sourceName}. Run npm run setup first.`); + } + + const targetDist = path.join(TEMP_BUILD_ROOT, target.buildKey); + const holdingDir = path.join( + BIN_DIR, + `.non-target-disabled-${target.buildKey}-${process.pid}`, + ); + const configFile = createConfigOverride(targetDist, target.buildKey); + const hidden = hideOtherBinaries(target.sourceName, holdingDir); + + try { + console.log(`\nBuilding standalone ${target.outputName}...`); + const result = spawnSync( + "npx", + [ + "-y", + NEU_CLI, + "build", + "--embed-resources", + "--clean", + "--config-file", + configFile, + ], + { + cwd: APP_DIR, + stdio: "inherit", + env: process.env, + shell: process.platform === "win32", + }, + ); + + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${target.outputName} build exited with code ${result.status}`); + } + + const builtFile = path.join( + targetDist, + "markdown-viewer", + target.outputName, + ); + if (!fs.existsSync(builtFile)) { + throw new Error(`Build succeeded but ${target.outputName} was not generated.`); + } + + const finalFile = path.join(FINAL_DIR, target.outputName); + fs.copyFileSync(builtFile, finalFile); + fs.chmodSync(finalFile, 0o755); + } finally { + restoreBinaries(hidden); + fs.rmSync(holdingDir, { recursive: true, force: true }); + fs.rmSync(configFile, { force: true }); + } +} + +function main() { + fs.rmSync(DIST_DIR, { recursive: true, force: true }); + fs.mkdirSync(FINAL_DIR, { recursive: true }); + + try { + for (const target of TARGETS) buildTarget(target); + } finally { + fs.rmSync(TEMP_BUILD_ROOT, { recursive: true, force: true }); + } + + const outputs = fs.readdirSync(FINAL_DIR).sort(); + if (outputs.length !== TARGETS.length) { + throw new Error(`Expected ${TARGETS.length} binaries, generated ${outputs.length}.`); + } + + console.log(`\nGenerated ${outputs.length} standalone binaries in ${FINAL_DIR}:`); + for (const output of outputs) console.log(` ${output}`); +} + +try { + main(); +} catch (error) { + console.error(`\nStandalone build failed: ${error.message}`); + process.exit(1); +} diff --git a/desktop-app/package.json b/desktop-app/package.json index a6f3de41..ddf1175d 100644 --- a/desktop-app/package.json +++ b/desktop-app/package.json @@ -9,7 +9,7 @@ "predev": "npm run setup", "dev": "npx -y @neutralinojs/neu@11.7.0 run", "prebuild": "npm run setup", - "build": "npx -y @neutralinojs/neu@11.7.0 build --release --clean && node -e \"const fs = require('fs'); if (fs.existsSync('dist/markdown-viewer-release.zip')) fs.unlinkSync('dist/markdown-viewer-release.zip');\"" + "build": "node build-standalone.js" }, "dependencies": {} } diff --git a/wiki/Desktop-App.md b/wiki/Desktop-App.md index b7922c34..0505f2f2 100644 --- a/wiki/Desktop-App.md +++ b/wiki/Desktop-App.md @@ -54,7 +54,11 @@ npm run dev npm run build ``` -The current package script runs Neutralino release build with `--clean` and removes `dist/markdown-viewer-release.zip` if it exists. Generated files appear under `desktop-app/dist/`. +The build generates seven platform-specific executables under +`desktop-app/dist/markdown-viewer/`. Each executable is built separately with +`--embed-resources`, so every download is self-contained and does not require a +neighboring `resources.neu` file. Release publishing uploads the seven binaries +individually rather than creating a portable application ZIP. ## Runtime Configuration diff --git a/wiki/Installation.md b/wiki/Installation.md index a8331bce..db1435ae 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -106,12 +106,16 @@ npm run setup npm run dev ``` -Build release resources and binaries with: +Build the seven self-contained platform binaries with: ```bash npm run build ``` +The results are written to `desktop-app/dist/markdown-viewer/`. Each Windows, +Linux, and macOS binary embeds the application resources, so users can download +only the file matching their operating system and architecture. + What setup does: - Downloads Neutralino binaries.