diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 9d85734..aaaf5d4 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -1,89 +1,81 @@ -name: Build Desktop App Binaries - -on: - push: - tags: - - "desktop-v*" - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "lts/*" - - # PERF-022: Cache Neutralino binaries and downloaded CDN libraries - - name: Cache Neutralino binaries - uses: actions/cache@v4 - with: - path: | - desktop-app/bin - desktop-app/resources/libs - key: neutralino-${{ hashFiles('desktop-app/neutralino.config.json') }} - - - name: Setup Neutralinojs binaries - working-directory: desktop-app - run: npm run setup - - - name: Build all binaries (embedded + portable) - working-directory: desktop-app - run: npm run build:all - - - name: Stage release assets - working-directory: desktop-app - run: | - VERSION="${GITHUB_REF_NAME#desktop-}" - STAGING="release-assets" - mkdir -p "$STAGING" - - # --- Embedded single-file executables --- - for bin in dist/markdown-viewer/markdown-viewer-*; do - [ -f "$bin" ] || continue - filename=$(basename "$bin") - - if [[ "$filename" == *.exe ]]; then - # Windows .exe — ship as-is - cp "$bin" "$STAGING/$filename" - else - # Linux/macOS — tar.gz for Unix convention - tar -czf "$STAGING/${filename}.tar.gz" -C "$(dirname "$bin")" "$filename" - fi - done - - # --- Portable ZIP bundle (from neu build --release) --- - for zip in dist/*.zip; do - [ -f "$zip" ] || continue - cp "$zip" "$STAGING/" - done - - # --- Source archives --- - 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 - - # --- SHA256 checksums --- - cd "$STAGING" - sha256sum * > SHA256SUMS.txt - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: "Markdown Viewer Desktop ${{ github.ref_name }}" - generate_release_notes: true - files: desktop-app/release-assets/* +name: Build Desktop App Binaries + +on: + push: + tags: + - "desktop-v*" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + # PERF-022: Cache Neutralino binaries and downloaded CDN libraries + - name: Cache Neutralino binaries + uses: actions/cache@v4 + with: + path: | + desktop-app/bin + desktop-app/resources/libs + key: neutralino-${{ hashFiles('desktop-app/neutralino.config.json') }} + + - name: Setup Neutralinojs binaries + working-directory: desktop-app + run: npm run setup + + - name: Build desktop bundles + working-directory: desktop-app + run: npm run build:all + + - name: Stage release assets + working-directory: desktop-app + run: | + VERSION="${GITHUB_REF_NAME#desktop-}" + STAGING="release-assets" + mkdir -p "$STAGING" + + # Windows embedded single-file executable + WIN_EMBEDDED="dist/windows-embedded/markdown-viewer/markdown-viewer-win_x64.exe" + if [ -f "$WIN_EMBEDDED" ]; then + cp "$WIN_EMBEDDED" "$STAGING/markdown-viewer-win_x64.exe" + fi + + # Portable ZIP bundle from neu build --release + for zip in dist/*.zip; do + [ -f "$zip" ] || continue + cp "$zip" "$STAGING/" + done + + # 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 + + # SHA256 checksums + cd "$STAGING" + sha256sum * > SHA256SUMS.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: "Markdown Viewer Desktop ${{ github.ref_name }}" + generate_release_notes: true + files: desktop-app/release-assets/* diff --git a/desktop-app/README.md b/desktop-app/README.md index 210d881..dad9ee7 100644 --- a/desktop-app/README.md +++ b/desktop-app/README.md @@ -45,25 +45,33 @@ For more information, see the [Neutralinojs documentation](https://neutralino.js ### Building the app -**Default** — Single-file executables with embedded resources: +**Default / Windows** - Single-file Windows executable with embedded resources: ```bash npm run build ``` -**Portable** — ZIP bundle with separate `resources.neu` file: +**Portable** - ZIP bundle with separate `resources.neu` file: ```bash npm run build:portable ``` -**Both** — Build embedded + portable in one step: +**Both** - Build the portable bundle and a Windows embedded EXE in one step: ```bash npm run build:all ``` -Build output is placed in `dist/`. +Build output is placed in `dist/`. + +Note: `npm run build` now uses the Windows-only embedded helper and writes +`dist/markdown-viewer/markdown-viewer-win_x64.exe`. The helper temporarily hides +non-Windows Neutralino binaries so the CLI does not run out of memory while +embedding every platform before it reaches the Windows target. Use +`npm run build:portable` for the all-platform portable ZIP with `resources.neu`; +`npm run build:all` writes that ZIP plus a Windows embedded EXE at +`dist/windows-embedded/markdown-viewer/markdown-viewer-win_x64.exe`. For more information, see the [Neutralinojs documentation](https://neutralino.js.org/docs/cli/neu-cli#neu-build). @@ -83,15 +91,12 @@ Prebuilt binaries are automatically built and published as GitHub Releases when Each release includes: -| Asset | Description | -| ----- | ----------- | -| `markdown-viewer-win_x64.exe` | Windows x64 executable | -| `markdown-viewer-linux_x64.tar.gz` | Linux x64 executable (tarball) | -| `markdown-viewer-linux_arm64.tar.gz` | Linux ARM64 executable (tarball) | -| `markdown-viewer-mac_*.tar.gz` | macOS executables (tarball) | -| `markdown-viewer-release.zip` | Portable bundle with `resources.neu` (all platforms) | -| `source.tar.gz` | Desktop app source archive | -| `SHA256SUMS.txt` | Checksums for all release assets | +| Asset | Description | +| ----- | ----------- | +| `markdown-viewer-win_x64.exe` | Windows x64 executable | +| `markdown-viewer-release.zip` | Portable bundle with `resources.neu` (all platforms) | +| `source.tar.gz` | Desktop app source archive | +| `SHA256SUMS.txt` | Checksums for all release assets | ## License diff --git a/desktop-app/build-windows.js b/desktop-app/build-windows.js new file mode 100644 index 0000000..0142dcd --- /dev/null +++ b/desktop-app/build-windows.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +/** + * Build a Windows-only embedded Neutralino executable. + * + * `neu build --embed-resources` embeds every platform binary it finds in bin/. + * With this app's offline libraries, embedding all platforms can exhaust Node's + * heap before the Windows binary is reached, leaving a stale Windows EXE in + * dist/. Temporarily hiding non-Windows binaries makes the CLI embed only the + * target users actually run on Windows. + */ + +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 WIN_BINARY = "neutralino-win_x64.exe"; +const NEU_CLI = "@neutralinojs/neu@11.7.0"; + +function getArgValue(name) { + const prefix = `${name}=`; + for (let i = 2; i < process.argv.length; i += 1) { + const arg = process.argv[i]; + if (arg === name) return process.argv[i + 1] || ""; + if (arg.startsWith(prefix)) return arg.slice(prefix.length); + } + return ""; +} + +function createConfigOverride(distributionPath) { + if (!distributionPath) return CONFIG_FILE; + + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); + config.cli = config.cli || {}; + config.cli.distributionPath = distributionPath.replace(/\\/g, "/"); + + const tmpDir = path.join(APP_DIR, ".tmp"); + fs.mkdirSync(tmpDir, { recursive: true }); + + const tempConfigFile = path.join(tmpDir, `neutralino.windows.${process.pid}.config.json`); + fs.writeFileSync(tempConfigFile, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); + return tempConfigFile; +} + +function hideNonWindowsBinaries(tempDir) { + const hidden = []; + fs.mkdirSync(tempDir, { 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 === WIN_BINARY) continue; + + const from = path.join(BIN_DIR, entry.name); + const to = path.join(tempDir, entry.name); + fs.renameSync(from, to); + hidden.push({ from, to }); + } + + return hidden; +} + +function restoreHiddenBinaries(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 main() { + const windowsBinaryPath = path.join(BIN_DIR, WIN_BINARY); + if (!fs.existsSync(windowsBinaryPath)) { + console.error(`Missing ${WIN_BINARY}. Run npm run setup before building.`); + process.exit(1); + } + + const distributionPath = getArgValue("--dist"); + const tempDir = path.join(BIN_DIR, `.nonwin-disabled-${process.pid}`); + const configFile = createConfigOverride(distributionPath); + const hidden = hideNonWindowsBinaries(tempDir); + let exitCode = 0; + + try { + const npx = "npx"; + const args = [ + "-y", + NEU_CLI, + "build", + "--embed-resources", + "--clean", + "--config-file", + configFile, + ]; + + const result = spawnSync(npx, args, { + cwd: APP_DIR, + stdio: "inherit", + env: process.env, + shell: process.platform === "win32", + }); + + if (result.error) { + console.error(result.error.message); + exitCode = 1; + } else { + exitCode = result.status || 0; + } + } finally { + restoreHiddenBinaries(hidden); + fs.rmSync(tempDir, { recursive: true, force: true }); + if (configFile !== CONFIG_FILE) { + fs.rmSync(configFile, { force: true }); + } + } + + process.exit(exitCode); +} + +main(); diff --git a/desktop-app/package.json b/desktop-app/package.json index 4694adb..ab68f6e 100644 --- a/desktop-app/package.json +++ b/desktop-app/package.json @@ -9,9 +9,13 @@ "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 --embed-resources", - "build:portable": "npx -y @neutralinojs/neu@11.7.0 build --release", - "build:all": "npm run build && npm run build:portable" + "build": "node build-windows.js", + "prebuild:windows": "npm run setup", + "build:windows": "node build-windows.js", + "prebuild:portable": "npm run setup", + "build:portable": "npx -y @neutralinojs/neu@11.7.0 build --release --clean", + "prebuild:all": "npm run setup", + "build:all": "npx -y @neutralinojs/neu@11.7.0 build --release --clean && node build-windows.js --dist dist/windows-embedded" }, "dependencies": {} } diff --git a/desktop-app/resources/js/main.js b/desktop-app/resources/js/main.js index 861f510..3a634fb 100644 --- a/desktop-app/resources/js/main.js +++ b/desktop-app/resources/js/main.js @@ -68,8 +68,20 @@ async function onWindowClose() { } } +function isNeutralinoRuntime() { + if (typeof Neutralino === 'undefined' || typeof NL_PORT === 'undefined') { + return false; + } + + try { + return typeof NL_TOKEN !== 'undefined' || Boolean(sessionStorage.getItem('NL_TOKEN')); + } catch (e) { + return typeof NL_TOKEN !== 'undefined'; + } +} + // Initialize Neutralino if in native environment -if (typeof Neutralino !== 'undefined') { +if (isNeutralinoRuntime()) { Neutralino.init(); // Register event listeners @@ -85,7 +97,7 @@ if (typeof Neutralino !== 'undefined') { // Open file passed as command-line argument (e.g. when double-clicking a .md file) (async function loadInitialFile() { - if (typeof Neutralino === 'undefined' || typeof NL_ARGS === 'undefined') return; + if (!isNeutralinoRuntime() || typeof NL_ARGS === 'undefined') return; const args = Array.isArray(NL_ARGS) ? NL_ARGS : (() => { try { return JSON.parse(NL_ARGS); } catch(e) { return []; } })(); const filePath = args.find(a => typeof a === 'string' && /\.(md|markdown)$/i.test(a)); if (!filePath) return; diff --git a/wiki/Configuration.md b/wiki/Configuration.md index df31e9c..c0f15bb 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -200,9 +200,10 @@ Located at `desktop-app/package.json`. |--------|---------|-------------| | `setup` | `node setup-binaries.js` | Download Neutralinojs binaries | | `dev` | `npx @neutralinojs/neu@11.7.0 run` | Start with hot-reload | -| `build` | `npx @neutralinojs/neu@11.7.0 build --embed-resources` | Build embedded single-file binaries | -| `build:portable` | `npx @neutralinojs/neu@11.7.0 build --release` | Build portable (resource-separated) binaries | -| `build:all` | `npm run build && npm run build:portable` | Build both embedded and portable | +| `build` | `node build-windows.js` | Build a Windows embedded single-file binary | +| `build:windows` | `node build-windows.js` | Build a Windows embedded single-file binary | +| `build:portable` | `npx @neutralinojs/neu@11.7.0 build --release --clean` | Build portable (resource-separated) binaries | +| `build:all` | `npx @neutralinojs/neu@11.7.0 build --release --clean && node build-windows.js --dist dist/windows-embedded` | Build portable bundle plus Windows embedded binary | --- diff --git a/wiki/Desktop-App.md b/wiki/Desktop-App.md index 70037cb..dd4246b 100644 --- a/wiki/Desktop-App.md +++ b/wiki/Desktop-App.md @@ -104,9 +104,9 @@ This starts the app with **hot-reload**: editing source files in the `resources/ Three build modes are available: -### Embedded (single-file executable) +### Embedded (single-file Windows executable) -All resources are embedded inside the binary. No additional files are needed at runtime. +All resources are embedded inside the Windows binary. No additional files are needed at runtime. ```bash npm run build @@ -126,22 +126,25 @@ npm run build:portable npm run build:all ``` +This creates the portable ZIP and a Windows embedded EXE. + --- ## Build Output After building, output files are placed in `desktop-app/dist/`: +Current builds write the portable bundle to `dist/markdown-viewer-release.zip`. +When using `npm run build:all`, the Windows embedded executable is written to +`dist/windows-embedded/markdown-viewer/markdown-viewer-win_x64.exe`. + ``` dist/ -├── markdown-viewer-win_x64.exe # Windows embedded binary -├── markdown-viewer-linux_x64 # Linux x64 embedded binary -├── markdown-viewer-linux_arm64 # Linux ARM64 embedded binary -├── markdown-viewer-mac_universal # macOS universal binary -└── portable/ # Portable builds (if built) - ├── markdown-viewer-win_x64/ - ├── markdown-viewer-linux_x64/ - └── … +|-- markdown-viewer/ # Portable app folder +|-- markdown-viewer-release.zip # Portable ZIP with resources.neu +`-- windows-embedded/ + `-- markdown-viewer/ + `-- markdown-viewer-win_x64.exe # Windows embedded binary ``` --- @@ -173,7 +176,7 @@ This triggers a GitHub Actions workflow that: 1. Sets up Node.js 2. Runs `npm install` and `node setup-binaries.js` 3. Runs `node prepare.js` -4. Builds embedded binaries for all platforms +4. Builds the portable bundle and Windows embedded binary 5. Computes SHA-256 checksums 6. Creates a GitHub Release with the binaries and checksums as assets @@ -182,9 +185,7 @@ This triggers a GitHub Actions workflow that: | File | Platform | |------|----------| | `markdown-viewer-win_x64.exe` | Windows x64 | -| `markdown-viewer-linux_x64` | Linux x64 | -| `markdown-viewer-linux_arm64` | Linux ARM64 | -| `markdown-viewer-mac_universal` | macOS (Apple Silicon + Intel) | +| `markdown-viewer-release.zip` | Portable bundle with resources.neu | | `checksums.sha256` | SHA-256 verification file | ---