From e4c42a4b6052903429f5b2e88e89fd41c855a158 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:16:35 +0000 Subject: [PATCH 1/6] feat(desktop): add macOS 12+ and Ubuntu installers --- .github/workflows/desktop-build.yml | 254 ++++++++++++++----- desktop-app/.gitignore | 1 + desktop-app/README.md | 4 +- desktop-app/build-standalone.js | 15 +- desktop-app/packaging/README.md | 99 ++++++++ desktop-app/packaging/RELEASE-DOWNLOADS.md | 16 ++ desktop-app/packaging/UNSIGNED-MACOS.txt | 16 ++ desktop-app/packaging/build-macos-runtime.sh | 32 +++ desktop-app/packaging/package-linux.js | 63 +++++ desktop-app/packaging/package-macos.sh | 81 ++++++ desktop-app/packaging/release-manifest.js | 17 ++ desktop-app/packaging/verify-macos.py | 42 +++ tests/native/desktop-package-smoke.py | 81 ++++++ tests/native/test_macos_load_commands.py | 45 ++++ tests/unit/desktop-packaging.test.mjs | 43 ++++ wiki/Desktop-App.md | 48 ++-- wiki/Installation.md | 34 ++- wiki/Troubleshooting.md | 4 +- 18 files changed, 791 insertions(+), 104 deletions(-) create mode 100644 desktop-app/packaging/README.md create mode 100644 desktop-app/packaging/RELEASE-DOWNLOADS.md create mode 100644 desktop-app/packaging/UNSIGNED-MACOS.txt create mode 100644 desktop-app/packaging/build-macos-runtime.sh create mode 100644 desktop-app/packaging/package-linux.js create mode 100644 desktop-app/packaging/package-macos.sh create mode 100644 desktop-app/packaging/release-manifest.js create mode 100644 desktop-app/packaging/verify-macos.py create mode 100644 tests/native/desktop-package-smoke.py create mode 100644 tests/native/test_macos_load_commands.py create mode 100644 tests/unit/desktop-packaging.test.mjs diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 91c67616..060a7831 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -1,76 +1,216 @@ -name: Build Desktop App Binaries +name: Build Desktop Installers on: push: - tags: - - "v*" + tags: ['v*'] + pull_request: + paths: + - '.github/workflows/desktop-build.yml' + - 'desktop-app/**' + - 'tests/native/desktop-package-smoke.py' + - 'tests/native/test_macos_load_commands.py' + - 'tests/unit/desktop-packaging.test.mjs' + workflow_dispatch: permissions: - contents: write + contents: read + +concurrency: + group: desktop-installers-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - build: - runs-on: ubuntu-latest + linux-windows: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm ci --prefix desktop-app + - run: npm run build && npm run test:unit + - run: python3 tests/native/test_macos_load_commands.py + - uses: actions/cache@v4 + with: + path: desktop-app/resources/libs + key: desktop-resources-${{ runner.os }}-${{ hashFiles('desktop-app/prepare.js', 'index.html', 'package-lock.json') }} + - name: Prepare and embed Windows and Linux resources + run: npm --prefix desktop-app run build -- linux_x64 linux_arm64 linux_armhf win_x64 + - name: Package Linux installers and portable archives + run: | + node desktop-app/packaging/package-linux.js + cp desktop-app/dist/markdown-viewer/markdown-viewer-win_x64.exe desktop-app/dist/installers/ + - uses: actions/upload-artifact@v4 + with: + name: desktop-linux-windows + path: desktop-app/dist/installers/* + if-no-files-found: error + retention-days: 30 + macos: + runs-on: macos-15 + timeout-minutes: 40 steps: - - name: Checkout repository + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm ci --prefix desktop-app + - uses: actions/cache@v4 + with: + path: desktop-app/resources/libs + key: desktop-resources-${{ runner.os }}-${{ hashFiles('desktop-app/prepare.js', 'index.html', 'package-lock.json') }} + - run: npm --prefix desktop-app run setup + - name: Checkout pinned Neutralino 6.5.0 source 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 + repository: neutralinojs/neutralinojs + ref: d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5 + path: desktop-app/.macos-runtime + persist-credentials: false + - name: Build Intel and Apple Silicon runtimes with a macOS 12 minimum + run: bash desktop-app/packaging/build-macos-runtime.sh desktop-app/.macos-runtime + # Do not run setup again after replacing the incompatible upstream runtimes. + - run: node desktop-app/build-standalone.js mac_x64 mac_arm64 mac_universal + - name: Import optional Developer ID identity (tag releases only) + if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' + env: + CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + if [[ -z "$CERTIFICATE" && -z "$SIGNING_IDENTITY" && -z "$APPLE_ID" ]]; then + echo '::warning::No Developer ID configured; creating explicitly documented ad-hoc signed builds.' + exit 0 + fi + : "${CERTIFICATE:?}" "${CERTIFICATE_PASSWORD:?}" "${SIGNING_IDENTITY:?}" + : "${APPLE_ID:?}" "${APPLE_TEAM_ID:?}" "${APPLE_APP_PASSWORD:?}" + KEYCHAIN="$RUNNER_TEMP/desktop-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 32)" + echo "::add-mask::$KEYCHAIN_PASSWORD" + printf '%s' "$CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/desktop-signing.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$RUNNER_TEMP/desktop-signing.p12" -P "$CERTIFICATE_PASSWORD" -k "$KEYCHAIN" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + xcrun notarytool store-credentials markdown-viewer --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD" + printf 'MACOS_SIGNING_IDENTITY=%s\nMACOS_NOTARY_PROFILE=markdown-viewer\n' "$SIGNING_IDENTITY" >> "$GITHUB_ENV" + - name: Package, sign and verify DMGs + run: bash desktop-app/packaging/package-macos.sh + - uses: actions/upload-artifact@v4 with: - path: | - desktop-app/bin - desktop-app/resources/libs - key: neutralino-${{ hashFiles('desktop-app/neutralino.config.json') }} - - - name: Install desktop packaging dependencies - working-directory: desktop-app - run: npm ci - - - name: Setup Neutralinojs binaries - working-directory: desktop-app - run: npm run setup - - - name: Build desktop bundles - working-directory: desktop-app - run: npm run build - - - name: Stage release assets - working-directory: desktop-app + name: desktop-macos + path: desktop-app/dist/installers/* + if-no-files-found: error + retention-days: 30 + - name: Remove temporary signing credentials + if: always() run: | - STAGING="release-assets" - mkdir -p "$STAGING" + if [[ -f "$RUNNER_TEMP/desktop-signing.keychain-db" ]]; then + security delete-keychain "$RUNNER_TEMP/desktop-signing.keychain-db" + fi + rm -f "$RUNNER_TEMP/desktop-signing.p12" - # 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 + test-linux: + needs: linux-windows + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: desktop-linux-windows + path: installers + - name: Install the actual Debian package and its dependencies + run: | + sudo apt-get update + sudo apt-get install -y ./installers/markdown-viewer-linux_amd64.deb xvfb desktop-file-utils + desktop-file-validate /usr/share/applications/com.markdownviewer.desktop.desktop + - name: Launch native WebKit GUI, import a file and reopen the vault + run: xvfb-run -a python3 tests/native/desktop-package-smoke.py /usr/bin/markdown-viewer - # SHA256 checksums - cd "$STAGING" - sha256sum * > SHA256SUMS.txt + test-macos: + needs: macos + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + arch: arm64 + - os: macos-15-intel + arch: x64 + - os: macos-15 + arch: universal + # Exercise the same macOS 12-minimum binaries on the newest stable + # hosted image too; macos-latest advances as GitHub rolls out releases. + - os: macos-latest + arch: arm64 + - os: macos-26-intel + arch: x64 + - os: macos-latest + arch: universal + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: desktop-macos + path: installers + - name: Install the app from its disk image + env: + ARCH: ${{ matrix.arch }} + run: | + sw_vers + uname -m + mkdir -p "$RUNNER_TEMP/dmg" "$RUNNER_TEMP/installed" + hdiutil attach "installers/markdown-viewer-mac_$ARCH.dmg" -nobrowse -mountpoint "$RUNNER_TEMP/dmg" + ditto "$RUNNER_TEMP/dmg/Markdown Viewer.app" "$RUNNER_TEMP/installed/Markdown Viewer.app" + hdiutil detach "$RUNNER_TEMP/dmg" + codesign --verify --deep --strict "$RUNNER_TEMP/installed/Markdown Viewer.app" + python3 desktop-app/packaging/verify-macos.py "$RUNNER_TEMP/installed/Markdown Viewer.app/Contents/MacOS/markdown-viewer" + - name: Launch via LaunchServices, import a file and reopen the vault + run: python3 tests/native/desktop-package-smoke.py "$RUNNER_TEMP/installed/Markdown Viewer.app" - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + release: + if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' + needs: [test-linux, test-macos] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: desktop-* + path: release-assets + merge-multiple: true + - name: Verify asset set and write checksums + run: node desktop-app/packaging/release-manifest.js release-assets + - uses: softprops/action-gh-release@v2 with: - name: "Markdown Viewer ${{ github.ref_name }}" + name: Markdown Viewer ${{ github.ref_name }} + body_path: desktop-app/packaging/RELEASE-DOWNLOADS.md generate_release_notes: true prerelease: ${{ contains(github.ref_name, '-') }} - files: desktop-app/release-assets/* + files: release-assets/* diff --git a/desktop-app/.gitignore b/desktop-app/.gitignore index 31b48557..5ffd532d 100644 --- a/desktop-app/.gitignore +++ b/desktop-app/.gitignore @@ -33,3 +33,4 @@ resources/js/desktop-runtime-features.js # Custom runtime source is separate from neu-managed .tmp downloads /.native-runtime/ +/.macos-runtime/ diff --git a/desktop-app/README.md b/desktop-app/README.md index 516fb0dc..b23026c4 100644 --- a/desktop-app/README.md +++ b/desktop-app/README.md @@ -74,7 +74,7 @@ npm run build The build script runs `build-standalone.js`. It invokes Neutralino once per target with embedded resources so Node.js does not need to package every platform in one memory-heavy pass. -Seven self-contained executables are written to `desktop-app/dist/markdown-viewer/`: Linux ARM64, ARMHF, and x64; macOS ARM64, universal, and x64; and Windows x64. They do not need a neighboring `resources.neu` file. +The default developer build writes seven raw executables to `desktop-app/dist/markdown-viewer/`. End-user releases package macOS as `.dmg` installers containing `Markdown Viewer.app`, Linux as `.deb` installers and portable `.tar.gz` archives, and Windows as the existing `.exe`. macOS runtimes are rebuilt for Monterey 12.0 before packaging. See [installer build, signing and testing instructions](packaging/README.md); a raw Mac developer binary is not a Finder application. ## Configuration Highlights @@ -110,7 +110,7 @@ The compose build copies the generated files to `desktop-app/output/`. Prebuilt desktop assets are published through GitHub Releases. Release workflows run setup, preparation, build, and checksum generation. -Unsigned desktop binaries may trigger Windows SmartScreen or macOS quarantine prompts. See [../wiki/Desktop-App.md](../wiki/Desktop-App.md) for platform launch notes. +Windows SmartScreen may warn about unsigned executables. Mac test builds are ad-hoc signed; trusted public distribution requires Apple Developer ID signing and notarization, supported by the release workflow. See `MACOS-SIGNING.txt` alongside the DMGs for the actual signing status. See [../wiki/Desktop-App.md](../wiki/Desktop-App.md) for platform launch notes. ## License diff --git a/desktop-app/build-standalone.js b/desktop-app/build-standalone.js index 7a57436e..5e76596c 100644 --- a/desktop-app/build-standalone.js +++ b/desktop-app/build-standalone.js @@ -139,18 +139,25 @@ async function buildTarget(target) { } async function main() { - fs.rmSync(DIST_DIR, { recursive: true, force: true }); + const requested = process.argv.slice(2); + const targets = requested.length ? requested.map(key => { + const target = TARGETS.find(item => item.buildKey === key); + if (!target) throw new Error(`Unknown target: ${key}`); + return target; + }) : TARGETS; + // Keep sibling installers when building another platform in the same tree. + fs.rmSync(FINAL_DIR, { recursive: true, force: true }); fs.mkdirSync(FINAL_DIR, { recursive: true }); try { - for (const target of TARGETS) await buildTarget(target); + for (const target of targets) await 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}.`); + 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}:`); diff --git a/desktop-app/packaging/README.md b/desktop-app/packaging/README.md new file mode 100644 index 00000000..26eef9d3 --- /dev/null +++ b/desktop-app/packaging/README.md @@ -0,0 +1,99 @@ +# Desktop installers for macOS 12 and newer, Windows and Linux + +## macOS version support + +The Mac packages target **macOS 12 Monterey and later**, including Ventura 13, +Sonoma 14, Sequoia 15 and Tahoe 26. Monterey is the minimum deployment target; +the app is not restricted to Monterey and has no configured upper-version limit. +Use the same architecture-matching DMG on newer macOS versions. Versions before +macOS 12 are outside this build's compatibility target. + +CI launches the packaged app on Intel and Apple Silicon macOS 15 and the latest +available stable hosted image (currently macOS 26). The ARM and universal checks +also use `macos-latest` to follow GitHub's stable-image rollout; Intel currently +uses `macos-26-intel`. Keep the explicit Intel label current when GitHub retires +an image. macOS 12–14 require manual acceptance because hosted runners no longer +provide them. A minimum deployment target is not evidence that every OS version, +patch release or future release has been tested. + +## Why v3.10.5 failed on macOS + +The release workflow uploaded raw Mach-O binaries as extensionless HTTP downloads. A browser download does not preserve Unix execute permissions, and the files had no Finder-recognized `.app` bundle, `Info.plist`, or installation flow. Finder could therefore treat them as documents. `chmod` alone was not a complete fix: the pinned Neutralino 6.5.0 x64 and arm64 binaries both contain `LC_BUILD_VERSION minos 14.0`, and link ScreenCaptureKit unconditionally. Monterey cannot load that runtime. ARM64 also cannot run on an Intel/AMD VMware guest; use x64 there. + +The source of the runtime is pinned to Neutralino 6.5.0 commit `d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5`. `build-macos-runtime.sh` compiles both CPU architectures with `CMAKE_OSX_DEPLOYMENT_TARGET=12.0`. This selects upstream's existing CoreGraphics snapshot fallback; the unused ScreenCaptureKit link is removed because the framework was only introduced in 12.3. The application does not allow the native snapshot API. No new native permissions are granted. + +`verify-macos.py` rejects newer minimum versions and mandatory ScreenCaptureKit links in **every** Mach-O slice, both before and after resource embedding. This checks binary compatibility metadata; it does not substitute for running on Monterey. + +## Download and install + +See [release download instructions](RELEASE-DOWNLOADS.md). Mac users download a DMG, drag the app to Applications, eject the image and launch the app. Ubuntu users install a `.deb`, which preserves permissions, installs WebKitGTK/GTK and tray dependencies, and registers a menu entry. Current supported Linux package targets are Ubuntu 22.04/24.04 and compatible Debian installations. ARM packages are built but require hardware acceptance testing. Portable Linux `.tar.gz` files preserve executable permissions; their system dependencies are the same as the `.deb` and must be installed separately. + +All packages use the same application id, native API allowlist, shared application code and fixed `Documents/Markdown Viewer Vault` as Windows. Installation never creates, replaces or deletes a user's vault. The running application creates it and saves normal documents under `Workspace`. Removing/replacing the installed app leaves the vault intact. + +## Build locally + +Windows/Linux raw executables (Node 24, npm, Linux packaging also needs `dpkg-deb` and `tar`): + +```sh +npm ci --prefix desktop-app +npm --prefix desktop-app run build -- linux_x64 linux_arm64 linux_armhf win_x64 +node desktop-app/packaging/package-linux.js +``` + +Mac installers must be built on macOS with Xcode command-line tools and CMake: + +```sh +npm ci --prefix desktop-app +npm --prefix desktop-app run setup +git clone https://github.com/neutralinojs/neutralinojs.git desktop-app/.macos-runtime +git -C desktop-app/.macos-runtime checkout d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5 +bash desktop-app/packaging/build-macos-runtime.sh desktop-app/.macos-runtime +node desktop-app/build-standalone.js mac_x64 mac_arm64 mac_universal +bash desktop-app/packaging/package-macos.sh +``` + +Use a clean pinned runtime checkout for each rebuild. Do not run `neu update` between replacing the Mac runtime and embedding resources. Final installers are in `desktop-app/dist/installers`. `npm run build` without target arguments still builds all seven raw developer executables; these are **not** the end-user Mac installers and use the downloaded runtime unless explicitly rebuilt as above. The Docker helper only builds raw developer executables, not DMGs. + +The app bundle contains the final embedded executable, icon and plist. It is signed **after** resource injection, which invalidates/removes any original Mach-O signature. The universal build embeds resources for both architectures. Installer packages do not require Node, npm, or an adjacent `resources.neu` file on users' computers. + +## Apple signing and notarization + +PR and manual CI builds are ad-hoc signed and **not notarized**. `MACOS-SIGNING.txt` beside the installers and `READ-ME-FIRST.txt` in unsigned DMGs explain the warning and Monterey's per-app Open Anyway flow. Ad-hoc signing makes the binary structurally valid (including Apple Silicon); it does not establish a trusted developer identity. Never tell users to disable Gatekeeper globally. + +For the normal trusted public-download experience, the repository owner needs an Apple Developer ID Application certificate and notarization credentials. Configure these GitHub Actions repository secrets: + +| Secret | Value | +| --- | --- | +| `MACOS_CERTIFICATE_P12_BASE64` | Base64-encoded exported Developer ID Application `.p12` including its private key | +| `MACOS_CERTIFICATE_PASSWORD` | Password protecting that export | +| `MACOS_SIGNING_IDENTITY` | Full `Developer ID Application: … (TEAMID)` identity | +| `APPLE_ID` | Apple developer account email | +| `APPLE_TEAM_ID` | Developer team id | +| `APPLE_APP_SPECIFIC_PASSWORD` | Apple app-specific password for notarization | + +Only tag-push release jobs read these secrets. With no identity configured, releases are explicitly identified as ad-hoc signed; partially configured credentials or signing/notarization failures fail the build. PR code never receives these credentials. The workflow imports a temporary keychain, signs with hardened runtime and a timestamp, notarizes and staples the app and DMG, and removes the temporary keychain/certificate. For a local signed build, set `MACOS_SIGNING_IDENTITY` and `MACOS_NOTARY_PROFILE` to an existing `notarytool` keychain profile. No Apple credentials were available to validate that optional path while developing this change. + +## CI and testing downloads + +The **Build Desktop Installers** workflow runs for packaging PRs, manual dispatches and version tags. The `desktop-macos` artifact contains the three DMGs and signing status. GitHub wraps it in a ZIP: extract it first, then open the appropriate DMG. Artifacts are kept for 30 days; a tag release publishes durable assets only after native smoke checks pass. No tag/release is needed to test a PR. + +CI checks: + +- Shared build and unit suite; Debian package metadata, permissions and extraction. +- Regression tests rejecting the original macOS 14 load commands, including a bad universal slice. +- Runtime compilation and final Mach-O, plist, signature and DMG validation. +- On ARM and Intel Mac runners for macOS 15 and the latest stable hosted macOS: mount the actual DMG, copy the app, eject the image, launch through LaunchServices, import a Unicode Markdown filename, verify its bytes in the actual Documents vault, and reopen the app. The workflow logs `sw_vers` and CPU architecture to record the actual OS tested. +- On Ubuntu 22.04 and 24.04: install the actual `.deb` with `apt`, validate its desktop entry, and run the same native GUI/vault check under Xvfb. + +GitHub-hosted runners do not provide macOS 12. Monterey UI acceptance must be performed on a physical Mac or the user's VM. CI also does not reproduce browser quarantine or an end user's Documents-permission prompt. The native smoke script refuses to run outside disposable GitHub Actions runners or against an existing vault. + +## macOS/Ubuntu manual acceptance + +1. Download the architecture-matching installer in the guest OS. For VMware on an Intel/AMD Windows host, choose `markdown-viewer-mac_x64.dmg`. +2. Install and open it using the platform instructions. On Mac, allow Documents access if prompted; approve only this app through Open Anyway for an ad-hoc test build. Record the exact macOS and Safari versions and install available OS/browser updates (for Monterey, prefer 12.7.6). Repeat on the macOS versions being qualified for release. +3. Create a document, type Markdown, check Split/Preview, save, and inspect `Documents/Markdown Viewer Vault/Workspace` for the `.md` file. +4. Quit and reopen. Verify content, tabs and settings persist. Replace the installed app with another build and check the same vault is reused. +5. Open/save through native dialogs, link an external Markdown file/folder, and test explicit Save and backup export. Test offline editing and bundled renderers, plus HTML/PNG/PDF exports. Network features still need a connection. +6. On Ubuntu, launch from the application menu as an ordinary user; no `chmod` or terminal launch should be needed after `.deb` installation. + +If a VMware window is blank, record whether 3D acceleration is enabled and capture the exact symptom. A VM graphics problem is distinct from an incompatible executable or Gatekeeper warning; do not assume changing permissions fixes it. diff --git a/desktop-app/packaging/RELEASE-DOWNLOADS.md b/desktop-app/packaging/RELEASE-DOWNLOADS.md new file mode 100644 index 00000000..79dbe9b3 --- /dev/null +++ b/desktop-app/packaging/RELEASE-DOWNLOADS.md @@ -0,0 +1,16 @@ +### Desktop downloads + +| Your computer | Download | +| --- | --- | +| Windows x64 | `markdown-viewer-win_x64.exe` | +| Intel Mac / Monterey VMware on Intel or AMD Windows | `markdown-viewer-mac_x64.dmg` | +| Apple Silicon Mac | `markdown-viewer-mac_arm64.dmg` | +| Either Mac architecture | `markdown-viewer-mac_universal.dmg` | +| Ubuntu/Debian on Intel or AMD | `markdown-viewer-linux_amd64.deb` | +| Ubuntu/Debian on ARM64 / ARMHF | The matching `linux_arm64.deb` / `linux_armhf.deb` | + +**Mac:** macOS 12.0 or newer, including Ventura, Sonoma, Sequoia and Tahoe. Monterey is the minimum, not the only supported version; use the same architecture-matching DMG on newer macOS versions. Open the DMG, drag **Markdown Viewer.app** to **Applications**, eject the disk image, then open the app. Allow Documents access for the vault. See `MACOS-SIGNING.txt` for this build's signing status. Ad-hoc builds show an unidentified-developer warning; on Monterey, Control-click the installed app → Open, or use Security & Privacy → Open Anyway for a build you trust. On newer macOS, use System Settings → Privacy & Security → Open Anyway. Developer ID signed and notarized builds use Apple's normal first-open confirmation. + +**Ubuntu:** open the `.deb` with Software Install and install, then launch **Markdown Viewer** from Applications. If your Ubuntu installation has no graphical package installer, run `sudo apt install ./markdown-viewer-linux_amd64.deb`. The package installs WebKitGTK and other runtime dependencies. Portable `.tar.gz` downloads preserve execute permission but require those libraries to be installed separately. + +Documents remain in **Documents/Markdown Viewer Vault** when replacing or uninstalling the app. `SHA256SUMS.txt` covers the files above. GitHub's automatically generated **Source code** archives are for developers, not application installers. diff --git a/desktop-app/packaging/UNSIGNED-MACOS.txt b/desktop-app/packaging/UNSIGNED-MACOS.txt new file mode 100644 index 00000000..dff5f6ec --- /dev/null +++ b/desktop-app/packaging/UNSIGNED-MACOS.txt @@ -0,0 +1,16 @@ +This build is locally (ad-hoc) signed, not Apple Developer ID signed or notarized. +It is intended for testing; macOS may show an unidentified-developer warning. + +1. Open the .dmg and drag Markdown Viewer.app to Applications. +2. Eject the disk image and open the app from Applications. +3. On Monterey, if blocked, Control-click the app, choose Open, then Open again. + You can also use System Preferences > Security & Privacy > General > Open Anyway. + On newer macOS use System Settings > Privacy & Security > Open Anyway. + Only approve a build you downloaded from the project's own release or CI run. +4. Allow Documents folder access if macOS asks; this is where the Vault is saved. + +Intel Macs / VMware on Intel or AMD Windows: mac_x64.dmg. +Apple Silicon (M1/M2/M3/etc.): mac_arm64.dmg. Universal works on either architecture. +Minimum macOS: 12.0. Updating Monterey to 12.7.6 and its latest Safari is recommended. +The Vault stays in Documents/Markdown Viewer Vault when you replace the app. +Never disable Gatekeeper globally to run this test. diff --git a/desktop-app/packaging/build-macos-runtime.sh b/desktop-app/packaging/build-macos-runtime.sh new file mode 100644 index 00000000..1fad27a3 --- /dev/null +++ b/desktop-app/packaging/build-macos-runtime.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Rebuild the pinned runtime: upstream 6.5.0 binaries require macOS 14. +set -euo pipefail +APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SOURCE_DIR="${1:?Usage: build-macos-runtime.sh /path/to/neutralinojs}" +EXPECTED_COMMIT=d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5 +test "$(git -C "$SOURCE_DIR" rev-parse HEAD)" = "$EXPECTED_COMMIT" +test "$(uname -s)" = Darwin + +# With a 12.0 deployment target upstream selects its CoreGraphics fallback. +# ScreenCaptureKit only exists from 12.3; do not link an unused mandatory framework. +python3 - "$SOURCE_DIR/CMakeLists.txt" <<'PY' +import pathlib, sys +p = pathlib.Path(sys.argv[1]) +source = p.read_text() +line = ' ${SCREENCAPTUREKIT_FRAMEWORK}\n' +assert source.count(line) == 1, 'Pinned upstream link configuration changed' +p.write_text(source.replace(line, '')) +PY + +mkdir -p "$APP_DIR/bin" +for arch in x86_64 arm64; do + cmake -S "$SOURCE_DIR" -B "$SOURCE_DIR/build-$arch" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES="$arch" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 + cmake --build "$SOURCE_DIR/build-$arch" --parallel 3 +done +cp "$SOURCE_DIR/bin/neutralino-mac_x64" "$APP_DIR/bin/" +cp "$SOURCE_DIR/bin/neutralino-mac_arm64" "$APP_DIR/bin/" +lipo -create "$APP_DIR/bin/neutralino-mac_x64" "$APP_DIR/bin/neutralino-mac_arm64" \ + -output "$APP_DIR/bin/neutralino-mac_universal" +python3 "$APP_DIR/packaging/verify-macos.py" "$APP_DIR/bin/neutralino-mac_universal" diff --git a/desktop-app/packaging/package-linux.js b/desktop-app/packaging/package-linux.js new file mode 100644 index 00000000..99e2dd32 --- /dev/null +++ b/desktop-app/packaging/package-linux.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const appDir = path.resolve(__dirname, '..'); +const config = require('../neutralino.config.json'); +const architectures = { x64: 'amd64', arm64: 'arm64', armhf: 'armhf' }; + +function stagePackage(root, arch, binary, icon, version = config.version) { + if (!architectures[arch]) throw new Error(`Unsupported Linux architecture: ${arch}`); + if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) throw new Error('Invalid package version'); + const executable = path.join(root, 'usr/lib/markdown-viewer/markdown-viewer'); + fs.mkdirSync(path.dirname(executable), { recursive: true }); + fs.copyFileSync(binary, executable); + fs.chmodSync(executable, 0o755); + const files = { + 'usr/bin/markdown-viewer': '#!/bin/sh\nexec /usr/lib/markdown-viewer/markdown-viewer "$@"\n', + 'usr/share/applications/com.markdownviewer.desktop.desktop': `[Desktop Entry]\nType=Application\nName=Markdown Viewer\nComment=Edit and preview Markdown documents\nExec=markdown-viewer %F\nTryExec=markdown-viewer\nIcon=markdown-viewer\nTerminal=false\nCategories=Office;TextEditor;\nMimeType=text/markdown;text/x-markdown;\nStartupNotify=true\n`, + 'DEBIAN/control': `Package: markdown-viewer\nVersion: ${version.replace('-', '~')}\nSection: editors\nPriority: optional\nArchitecture: ${architectures[arch]}\nMaintainer: Markdown Viewer contributors \nHomepage: https://github.com/ThisIs-Developer/Markdown-Viewer\nDepends: libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, libgtk-3-0 (>= 3.24) | libgtk-3-0t64, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, libayatana-appindicator3-1 | libappindicator3-1, libx11-6, libxcb1, libxrandr2, libpng16-16 | libpng16-16t64\nDescription: Markdown editor and viewer with a local document vault\n Includes offline preview resources and native file dialogs.\n`, + }; + for (const [name, content] of Object.entries(files)) { + const destination = path.join(root, name); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, content, { mode: name.startsWith('usr/bin/') ? 0o755 : 0o644 }); + } + const iconPath = path.join(root, 'usr/share/icons/hicolor/256x256/apps/markdown-viewer.png'); + fs.mkdirSync(path.dirname(iconPath), { recursive: true }); + fs.copyFileSync(icon, iconPath); + fs.chmodSync(iconPath, 0o644); + const licenses = path.join(root, 'usr/share/doc/markdown-viewer'); + fs.mkdirSync(licenses, { recursive: true }); + fs.copyFileSync(path.join(appDir, '../LICENSE'), path.join(licenses, 'copyright')); + fs.copyFileSync(path.join(appDir, 'LICENSE'), path.join(licenses, 'neutralino-LICENSE')); + // Build hosts may use umask 077. Installed apps must be readable by all users. + function normalizePermissions(directory) { + fs.chmodSync(directory, 0o755); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) normalizePermissions(file); + else fs.chmodSync(file, file === executable || file === path.join(root, 'usr/bin/markdown-viewer') ? 0o755 : 0o644); + } + } + normalizePermissions(root); +} + +function main() { + const output = path.join(appDir, 'dist/installers'); + fs.mkdirSync(output, { recursive: true }); + for (const arch of Object.keys(architectures)) { + const root = fs.mkdtempSync(path.join(appDir, 'dist/.deb-')); + try { + stagePackage(root, arch, path.join(appDir, `dist/markdown-viewer/markdown-viewer-linux_${arch}`), + path.join(appDir, 'resources/assets/icon.png')); + execFileSync('dpkg-deb', ['--root-owner-group', '--build', root, + path.join(output, `markdown-viewer-linux_${architectures[arch]}.deb`)], { stdio: 'inherit' }); + // Archive permissions survive browser download; raw HTTP executable modes do not. + execFileSync('tar', ['-czf', path.join(output, `markdown-viewer-linux_${arch}.tar.gz`), + '-C', path.join(appDir, 'dist/markdown-viewer'), `markdown-viewer-linux_${arch}`]); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + } +} +if (require.main === module) main(); +module.exports = { stagePackage }; diff --git a/desktop-app/packaging/package-macos.sh b/desktop-app/packaging/package-macos.sh new file mode 100644 index 00000000..5bac4158 --- /dev/null +++ b/desktop-app/packaging/package-macos.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -euo pipefail +APP_DIR="$(cd "$(dirname "$0")/.." && pwd)" +test "$(uname -s)" = Darwin +VERSION="$(node -p "require('$APP_DIR/neutralino.config.json').version")" +OUTPUT="$APP_DIR/dist/installers" +mkdir -p "$OUTPUT" +IDENTITY="${MACOS_SIGNING_IDENTITY:--}" +if [[ "$IDENTITY" != - ]]; then + : "${MACOS_NOTARY_PROFILE:?Developer ID builds must also be notarized}" +fi + +for arch in x64 arm64 universal; do + INPUT="$APP_DIR/dist/markdown-viewer/markdown-viewer-mac_$arch" + python3 "$APP_DIR/packaging/verify-macos.py" "$INPUT" + STAGING="$(mktemp -d)" + trap 'rm -rf "$STAGING"' EXIT + APP="$STAGING/Markdown Viewer.app" + mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + cp "$INPUT" "$APP/Contents/MacOS/markdown-viewer" + chmod 755 "$APP/Contents/MacOS/markdown-viewer" + python3 - "$APP/Contents/Info.plist" "$VERSION" <<'PY' +import plistlib, sys +with open(sys.argv[1], 'wb') as target: + plistlib.dump({ + 'CFBundleExecutable': 'markdown-viewer', + 'CFBundleIdentifier': 'com.markdownviewer.desktop', + 'CFBundleName': 'Markdown Viewer', + 'CFBundleDisplayName': 'Markdown Viewer', + 'CFBundlePackageType': 'APPL', + 'CFBundleShortVersionString': sys.argv[2], + 'CFBundleVersion': sys.argv[2].split('-')[0], + 'CFBundleIconFile': 'MarkdownViewer', + 'LSMinimumSystemVersion': '12.0', + 'NSHighResolutionCapable': True, + 'NSDocumentsFolderUsageDescription': 'Markdown Viewer saves your documents in the Markdown Viewer Vault in Documents.', + }, target) +PY + ICONSET="$STAGING/MarkdownViewer.iconset" + mkdir "$ICONSET" + for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$APP_DIR/resources/assets/icon.png" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z "$double" "$double" "$APP_DIR/resources/assets/icon.png" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null + done + iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/MarkdownViewer.icns" + rm -rf "$ICONSET" + chmod -R u+rwX,go+rX "$APP" + plutil -lint "$APP/Contents/Info.plist" + # Resource embedding changes Mach-O bytes. Sign the FINAL bundle, never the input. + if [[ "$IDENTITY" == - ]]; then + codesign --force --sign - "$APP" + cp "$APP_DIR/packaging/UNSIGNED-MACOS.txt" "$STAGING/READ-ME-FIRST.txt" + else + codesign --force --options runtime --timestamp --sign "$IDENTITY" "$APP" + ditto -c -k --keepParent "$APP" "$STAGING/notarize.zip" + xcrun notarytool submit "$STAGING/notarize.zip" --keychain-profile "$MACOS_NOTARY_PROFILE" --wait + rm "$STAGING/notarize.zip" + xcrun stapler staple "$APP" + spctl --assess --type execute --verbose "$APP" + fi + codesign --verify --deep --strict --verbose=2 "$APP" + ln -s /Applications "$STAGING/Applications" + DMG="$OUTPUT/markdown-viewer-mac_$arch.dmg" + rm -f "$DMG" + hdiutil create -volname 'Markdown Viewer' -srcfolder "$STAGING" -ov -format UDZO "$DMG" + if [[ "$IDENTITY" != - ]]; then + codesign --sign "$IDENTITY" --timestamp "$DMG" + xcrun notarytool submit "$DMG" --keychain-profile "$MACOS_NOTARY_PROFILE" --wait + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + fi + hdiutil verify "$DMG" + rm -rf "$STAGING" + trap - EXIT +done +if [[ "$IDENTITY" == - ]]; then + cp "$APP_DIR/packaging/UNSIGNED-MACOS.txt" "$OUTPUT/MACOS-SIGNING.txt" +else + printf 'Developer ID signed and Apple notarized. Minimum macOS: 12.0.\n' > "$OUTPUT/MACOS-SIGNING.txt" +fi diff --git a/desktop-app/packaging/release-manifest.js b/desktop-app/packaging/release-manifest.js new file mode 100644 index 00000000..50e7ea17 --- /dev/null +++ b/desktop-app/packaging/release-manifest.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const directory = path.resolve(process.argv[2]); +const expected = [ + 'markdown-viewer-win_x64.exe', 'MACOS-SIGNING.txt', + ...['x64', 'arm64', 'universal'].map(arch => `markdown-viewer-mac_${arch}.dmg`), + ...['amd64', 'arm64', 'armhf'].map(arch => `markdown-viewer-linux_${arch}.deb`), + ...['x64', 'arm64', 'armhf'].map(arch => `markdown-viewer-linux_${arch}.tar.gz`), +].sort(); +const actual = fs.readdirSync(directory).filter(name => name !== 'SHA256SUMS.txt').sort(); +if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error(`Unexpected release assets: ${actual.join(', ')}`); +fs.writeFileSync(path.join(directory, 'SHA256SUMS.txt'), expected.map(name => { + const digest = crypto.createHash('sha256').update(fs.readFileSync(path.join(directory, name))).digest('hex'); + return `${digest} ${name}\n`; +}).join('')); diff --git a/desktop-app/packaging/verify-macos.py b/desktop-app/packaging/verify-macos.py new file mode 100644 index 00000000..18e6cad6 --- /dev/null +++ b/desktop-app/packaging/verify-macos.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Check every Mach-O slice, including embedded-resource outputs, before shipping.""" +import pathlib +import struct +import sys + + +def inspect(data): + if data[:4] == b'\xca\xfe\xba\xbe': + count = struct.unpack_from('>I', data, 4)[0] + slices = [] + for index in range(count): + _, _, offset, size, _ = struct.unpack_from('>IIIII', data, 8 + index * 20) + slices.extend(inspect(data[offset:offset + size])) + assert len(slices) == count + return slices + assert data[:4] == b'\xcf\xfa\xed\xfe', 'Expected a 64-bit Mach-O executable' + cpu = struct.unpack_from('= 8 and offset + size <= len(data) + if command == 0x32: # LC_BUILD_VERSION + platform, minimum = struct.unpack_from('II', 0xcafebabe, 2) + header += struct.pack('>IIIII', 0x1000007, 3, 48, len(first), 0) + header += struct.pack('>IIIII', 0x100000c, 0, 48 + len(first), len(second), 0) + return header + first + second + self.assertEqual(verifier.inspect(universal(macho(0x0c0000, 0x100000c))), [0x1000007, 0x100000c]) + with self.assertRaises(AssertionError): + verifier.inspect(universal(macho(0x0e0000, 0x100000c))) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/desktop-packaging.test.mjs b/tests/unit/desktop-packaging.test.mjs new file mode 100644 index 00000000..570d38a3 --- /dev/null +++ b/tests/unit/desktop-packaging.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { stagePackage } = require('../../desktop-app/packaging/package-linux.js'); + +test('Debian packages preserve launcher permissions, desktop integration and runtime dependencies', t => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-package-')); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const binary = path.join(temp, 'input'); + fs.writeFileSync(binary, 'binary fixture', { mode: 0o644 }); + for (const [arch, expected] of Object.entries({ x64: 'amd64', arm64: 'arm64', armhf: 'armhf' })) { + const root = path.join(temp, arch); + stagePackage(root, arch, binary, binary, '3.10.5-test.1'); + const control = fs.readFileSync(path.join(root, 'DEBIAN/control'), 'utf8'); + assert.match(control, new RegExp(`Architecture: ${expected}\\n`)); + assert.match(control, /Version: 3\.10\.5~test\.1/); + assert.match(control, /libwebkit2gtk-4\.1-0 \| libwebkit2gtk-4\.0-37/); + assert.match(control, /libayatana-appindicator3-1 \| libappindicator3-1/); + const desktop = fs.readFileSync(path.join(root, 'usr/share/applications/com.markdownviewer.desktop.desktop'), 'utf8'); + assert.match(desktop, /Exec=markdown-viewer %F/); + assert.match(desktop, /Terminal=false/); + assert.equal(fs.readFileSync(path.join(root, 'usr/lib/markdown-viewer/markdown-viewer'), 'utf8'), 'binary fixture'); + if (process.platform !== 'win32') { + for (const file of ['usr/bin/markdown-viewer', 'usr/lib/markdown-viewer/markdown-viewer']) { + assert.equal(fs.statSync(path.join(root, file)).mode & 0o777, 0o755); + } + } + if (process.platform === 'linux') { + const deb = path.join(temp, `${arch}.deb`); + execFileSync('dpkg-deb', ['--root-owner-group', '--build', root, deb]); + assert.equal(execFileSync('dpkg-deb', ['--field', deb, 'Architecture'], { encoding: 'utf8' }).trim(), expected); + const extracted = path.join(temp, `${arch}-extracted`); + execFileSync('dpkg-deb', ['--extract', deb, extracted]); + assert.equal(fs.statSync(path.join(extracted, 'usr/lib/markdown-viewer/markdown-viewer')).mode & 0o777, 0o755); + } + } + assert.throws(() => stagePackage(path.join(temp, 'invalid'), 'mips', binary, binary), /Unsupported/); +}); diff --git a/wiki/Desktop-App.md b/wiki/Desktop-App.md index d7c4a3c1..6ef3ce88 100644 --- a/wiki/Desktop-App.md +++ b/wiki/Desktop-App.md @@ -116,11 +116,13 @@ npm run dev npm run build ``` -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. +The default developer build generates seven raw executables under +`desktop-app/dist/markdown-viewer/`, each with embedded resources. Releases add +platform installers: a macOS `.dmg` containing `Markdown Viewer.app`, Ubuntu/Debian +`.deb` packages with system dependencies and a menu launcher, and portable Linux +`.tar.gz` archives preserving permissions. Windows keeps the existing `.exe`. +Mac runtimes are rebuilt for macOS 12.0; the upstream 6.5.0 binaries require 14.0. +See [installer build and signing instructions](../desktop-app/packaging/README.md). The shared web/desktop application code includes slash commands, natural Explorer sorting, middle-click tab closing, pane-scoped Select All, rich Preview copying, smooth Page Up/Down navigation, and native fullscreen or Expanded view fallback behavior. @@ -173,22 +175,26 @@ This is why the prepared desktop application can load core bundled renderer libr ## Platform Notes -Windows may show SmartScreen warnings because binaries are unsigned. - -Linux: - -```bash -chmod +x markdown-viewer-linux_x64 -./markdown-viewer-linux_x64 -``` - -macOS: - -```bash -xattr -d com.apple.quarantine markdown-viewer-mac_universal -chmod +x markdown-viewer-mac_universal -./markdown-viewer-mac_universal -``` +See [download choices and installation steps](../desktop-app/packaging/RELEASE-DOWNLOADS.md). + +- **Windows:** download the x64 `.exe`. Unsigned builds may show SmartScreen. +- **Mac:** choose `mac_x64.dmg` for Intel Macs or VMware on Intel/AMD Windows; + `mac_arm64.dmg` for Apple Silicon; `mac_universal.dmg` for either. Open the DMG, + drag the app to Applications, eject the image, and launch the installed app. + macOS 12.0 or newer is required, including Ventura, Sonoma, Sequoia and Tahoe. + Monterey is the minimum version; the same DMG supports newer macOS releases. + Allow Documents access for the vault. +- **Mac signing:** check `MACOS-SIGNING.txt`. Ad-hoc test builds are not notarized; + on Monterey, Control-click the app and choose Open, or use Security & Privacy → + Open Anyway for a trusted download. Do not disable Gatekeeper globally. +- **Ubuntu/Debian:** install the matching `.deb` with Software Install, then launch + from Applications. If no graphical installer is present, use + `sudo apt install ./markdown-viewer-linux_amd64.deb` (Intel/AMD). The installer + resolves WebKitGTK and other dependencies. Portable `.tar.gz` archives need the + same system libraries installed separately. + +v3.10.5 raw Mac binaries are not suitable for Monterey: they lack app bundles and +require macOS 14. Changing permissions alone cannot fix that release. ## Data Handling diff --git a/wiki/Installation.md b/wiki/Installation.md index cef67184..8c6f3a96 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -133,15 +133,16 @@ npm install npm run dev ``` -Build the seven self-contained platform binaries with: +Build raw developer executables 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. +The raw results are written to `desktop-app/dist/markdown-viewer/`. End-user Mac +and Linux downloads need the additional [platform packaging steps](../desktop-app/packaging/README.md). +Mac installers include a runtime rebuilt for Monterey; Ubuntu installers include +dependency declarations and application-menu integration. The development and build commands run setup automatically. Setup: @@ -174,22 +175,19 @@ Desktop: Windows desktop binaries may trigger SmartScreen because they are unsigned. Choose More info, then Run anyway if you trust the build. -Linux binaries may need execute permission: +For macOS, download the appropriate `.dmg`, drag **Markdown Viewer.app** to +**Applications**, eject the image and launch the app. On an Intel/AMD VMware host, +choose `mac_x64.dmg`. Check `MACOS-SIGNING.txt` for notarization status and the +per-app Open Anyway instructions for test builds. Allow Documents access for the vault. -```bash -chmod +x markdown-viewer-linux_x64 -./markdown-viewer-linux_x64 -``` - -macOS binaries may need quarantine removal: - -```bash -xattr -d com.apple.quarantine markdown-viewer-mac_universal -chmod +x markdown-viewer-mac_universal -./markdown-viewer-mac_universal -``` +For Ubuntu/Debian, install the matching `.deb` with Software Install, then open +**Markdown Viewer** from Applications. If needed, use +`sudo apt install ./markdown-viewer-linux_amd64.deb`. Dependencies and executable +permissions are handled by the package. Portable `.tar.gz` builds preserve +permissions but require system libraries separately. -Run quarantine-removal commands only for a binary you trust. +See [all download choices](../desktop-app/packaging/RELEASE-DOWNLOADS.md). +The old v3.10.5 raw Mac downloads require macOS 14 and cannot run on Monterey. ## Official Platform Documentation diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 629ba748..bdfd1d4d 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -247,8 +247,8 @@ Clearing site data removes both the cache and locally saved Documents for that o - Run `npm install` and allow setup to download Neutralino binaries and libraries. - Confirm that preparation generated `desktop-app/resources/libs/`. - On Windows, review SmartScreen only if you trust the downloaded binary. -- On Linux, add execute permission. -- On macOS, use the documented quarantine command only if you trust the binary. +- On Ubuntu/Debian, install the `.deb` with Software Install or `apt` so runtime dependencies and permissions are configured. +- On macOS, use the architecture-matching `.dmg` and install the app in Applications. Check the signing-status file for the per-app Open Anyway instructions for test builds. The v3.10.5 raw Mac binaries require macOS 14 and cannot run on Monterey. - Check that the executable matches the operating system and architecture. - Re-run `npm run build` when prepared resources and root source are out of sync. From 19b69eb1ba988424040b76e1101157e44fe606ed Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:50:02 +0000 Subject: [PATCH 2/6] fix(desktop): broaden Mac compatibility to native dependency baselines --- .github/workflows/desktop-build.yml | 6 +- desktop-app/README.md | 2 +- desktop-app/packaging/README.md | 65 +++++++++++------ desktop-app/packaging/RELEASE-DOWNLOADS.md | 2 +- desktop-app/packaging/UNSIGNED-MACOS.txt | 4 +- desktop-app/packaging/build-macos-runtime.sh | 20 +++++- desktop-app/packaging/macos_support.py | 48 +++++++++++++ desktop-app/packaging/package-macos.sh | 26 ++----- desktop-app/packaging/verify-macos.py | 27 +++++-- tests/native/macos-filesystem-probe.cpp | 6 ++ tests/native/test_macos_load_commands.py | 74 +++++++++++++++----- wiki/Desktop-App.md | 9 +-- wiki/Installation.md | 2 +- 13 files changed, 211 insertions(+), 80 deletions(-) create mode 100644 desktop-app/packaging/macos_support.py create mode 100644 tests/native/macos-filesystem-probe.cpp diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 060a7831..51682769 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -75,7 +75,7 @@ jobs: ref: d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5 path: desktop-app/.macos-runtime persist-credentials: false - - name: Build Intel and Apple Silicon runtimes with a macOS 12 minimum + - name: Build Intel and Apple Silicon runtimes at their native dependency baselines run: bash desktop-app/packaging/build-macos-runtime.sh desktop-app/.macos-runtime # Do not run setup again after replacing the incompatible upstream runtimes. - run: node desktop-app/build-standalone.js mac_x64 mac_arm64 mac_universal @@ -159,7 +159,7 @@ jobs: arch: x64 - os: macos-15 arch: universal - # Exercise the same macOS 12-minimum binaries on the newest stable + # Exercise the same backwards-compatible binaries on the newest stable # hosted image too; macos-latest advances as GitHub rolls out releases. - os: macos-latest arch: arm64 @@ -188,7 +188,7 @@ jobs: ditto "$RUNNER_TEMP/dmg/Markdown Viewer.app" "$RUNNER_TEMP/installed/Markdown Viewer.app" hdiutil detach "$RUNNER_TEMP/dmg" codesign --verify --deep --strict "$RUNNER_TEMP/installed/Markdown Viewer.app" - python3 desktop-app/packaging/verify-macos.py "$RUNNER_TEMP/installed/Markdown Viewer.app/Contents/MacOS/markdown-viewer" + python3 desktop-app/packaging/verify-macos.py --arch "$ARCH" "$RUNNER_TEMP/installed/Markdown Viewer.app/Contents/MacOS/markdown-viewer" - name: Launch via LaunchServices, import a file and reopen the vault run: python3 tests/native/desktop-package-smoke.py "$RUNNER_TEMP/installed/Markdown Viewer.app" diff --git a/desktop-app/README.md b/desktop-app/README.md index b23026c4..1155ad79 100644 --- a/desktop-app/README.md +++ b/desktop-app/README.md @@ -74,7 +74,7 @@ npm run build The build script runs `build-standalone.js`. It invokes Neutralino once per target with embedded resources so Node.js does not need to package every platform in one memory-heavy pass. -The default developer build writes seven raw executables to `desktop-app/dist/markdown-viewer/`. End-user releases package macOS as `.dmg` installers containing `Markdown Viewer.app`, Linux as `.deb` installers and portable `.tar.gz` archives, and Windows as the existing `.exe`. macOS runtimes are rebuilt for Monterey 12.0 before packaging. See [installer build, signing and testing instructions](packaging/README.md); a raw Mac developer binary is not a Finder application. +The default developer build writes seven raw executables to `desktop-app/dist/markdown-viewer/`. End-user releases package macOS as `.dmg` installers containing `Markdown Viewer.app`, Linux as `.deb` installers and portable `.tar.gz` archives, and Windows as the existing `.exe`. Mac runtimes are rebuilt for their native dependency baselines (Intel 10.15; Apple Silicon 11), with no Monterey-only restriction. Older OS/WebKit combinations still need manual acceptance. See [installer build, signing and testing instructions](packaging/README.md); a raw Mac developer binary is not a Finder application. ## Configuration Highlights diff --git a/desktop-app/packaging/README.md b/desktop-app/packaging/README.md index 26eef9d3..5df21251 100644 --- a/desktop-app/packaging/README.md +++ b/desktop-app/packaging/README.md @@ -1,28 +1,51 @@ -# Desktop installers for macOS 12 and newer, Windows and Linux - -## macOS version support - -The Mac packages target **macOS 12 Monterey and later**, including Ventura 13, -Sonoma 14, Sequoia 15 and Tahoe 26. Monterey is the minimum deployment target; -the app is not restricted to Monterey and has no configured upper-version limit. -Use the same architecture-matching DMG on newer macOS versions. Versions before -macOS 12 are outside this build's compatibility target. - -CI launches the packaged app on Intel and Apple Silicon macOS 15 and the latest -available stable hosted image (currently macOS 26). The ARM and universal checks -also use `macos-latest` to follow GitHub's stable-image rollout; Intel currently -uses `macos-26-intel`. Keep the explicit Intel label current when GitHub retires -an image. macOS 12–14 require manual acceptance because hosted runners no longer -provide them. A minimum deployment target is not evidence that every OS version, -patch release or future release has been tested. +# Desktop installers for Mac, Windows and Linux + +## Broad Mac compatibility + +The goal is to let as many users as possible run the desktop app. There is no +application-imposed macOS 12 cutoff or upper-version limit. The build targets the +oldest native dependencies of the current runtime, separately for each CPU: + +| Download | Native dependency baseline | Reason | +| --- | --- | --- | +| Intel x64 | macOS 10.15 Catalina | Neutralino's file and vault APIs use Apple's C++ `std::filesystem`, introduced in 10.15. | +| Apple Silicon ARM64 | macOS 11 Big Sur | Apple Silicon Macs and their native ABI start with macOS 11. | +| Universal | 10.15 on Intel; 11 on Apple Silicon | Both slices retain their own baseline and Finder metadata. | + +`macos_support.py` is the shared source for compilation targets, per-architecture +plist metadata and binary verification. CI also compiles a filesystem probe at +these targets and confirms that the Apple SDK rejects the same dependency at +10.14. It never disables compiler availability checks or simply edits a newer +binary's version label. Removing all deployment-target flags would default to +the build machine's newer OS and reduce compatibility again. + +These are native **build targets**, not proof of testing on every older OS. CI +launches the real packages on Intel and Apple Silicon macOS 15 and 26, including +`macos-latest`. Catalina, Big Sur, Monterey and other versions without hosted +runners need manual acceptance. Install the latest OS and Safari/WebKit updates +available for the user's Mac; on Catalina, use Safari 15.6.1. The app uses the +system WebKit rather than shipping a separate browser. Older unpatched WebKit +engines can lack JavaScript/DOM APIs even when the native executable loads. + +For example, the shared UI uses optional chaining and `replaceChildren`, and +workspace maintenance uses `inert`. These are available in updated Catalina's +Safari 15.6, but not all are available in its original Safari 13. Core startup +scripts and their pinned Marked/DOMPurify/Bootstrap dependencies were inspected +against the Safari 15.6 syntax target; this is not a substitute for testing all +renderers, exports and dialogs in the older native WebKit. + +A literal promise to run on **every historical and future macOS release** is not +technically supportable. Pre-Catalina support would require replacing/backporting +native dependencies and qualifying the older browser engine. No compatibility is +claimed for those systems, PowerPC/32-bit Macs or untested future OS releases. ## Why v3.10.5 failed on macOS The release workflow uploaded raw Mach-O binaries as extensionless HTTP downloads. A browser download does not preserve Unix execute permissions, and the files had no Finder-recognized `.app` bundle, `Info.plist`, or installation flow. Finder could therefore treat them as documents. `chmod` alone was not a complete fix: the pinned Neutralino 6.5.0 x64 and arm64 binaries both contain `LC_BUILD_VERSION minos 14.0`, and link ScreenCaptureKit unconditionally. Monterey cannot load that runtime. ARM64 also cannot run on an Intel/AMD VMware guest; use x64 there. -The source of the runtime is pinned to Neutralino 6.5.0 commit `d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5`. `build-macos-runtime.sh` compiles both CPU architectures with `CMAKE_OSX_DEPLOYMENT_TARGET=12.0`. This selects upstream's existing CoreGraphics snapshot fallback; the unused ScreenCaptureKit link is removed because the framework was only introduced in 12.3. The application does not allow the native snapshot API. No new native permissions are granted. +The source of the runtime is pinned to Neutralino 6.5.0 commit `d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5`. `build-macos-runtime.sh` uses the architecture-specific deployment targets from `macos_support.py`. This selects upstream's existing CoreGraphics snapshot fallback; the unused ScreenCaptureKit link is removed because the framework was only introduced in 12.3. The application does not allow the native snapshot API. No new native permissions are granted. -`verify-macos.py` rejects newer minimum versions and mandatory ScreenCaptureKit links in **every** Mach-O slice, both before and after resource embedding. This checks binary compatibility metadata; it does not substitute for running on Monterey. +`verify-macos.py` rejects newer minimum versions and mandatory ScreenCaptureKit links in **every** Mach-O slice, both before and after resource embedding. It also checks that each download contains the advertised CPU slices. These checks do not substitute for running on older macOS releases. ## Download and install @@ -80,12 +103,12 @@ The **Build Desktop Installers** workflow runs for packaging PRs, manual dispatc CI checks: - Shared build and unit suite; Debian package metadata, permissions and extraction. -- Regression tests rejecting the original macOS 14 load commands, including a bad universal slice. +- Seven compatibility regression tests covering the previous 12/14 cutoffs, mixed universal slices, legacy Intel load commands, incorrect architecture labels and per-architecture Finder metadata. - Runtime compilation and final Mach-O, plist, signature and DMG validation. - On ARM and Intel Mac runners for macOS 15 and the latest stable hosted macOS: mount the actual DMG, copy the app, eject the image, launch through LaunchServices, import a Unicode Markdown filename, verify its bytes in the actual Documents vault, and reopen the app. The workflow logs `sw_vers` and CPU architecture to record the actual OS tested. - On Ubuntu 22.04 and 24.04: install the actual `.deb` with `apt`, validate its desktop entry, and run the same native GUI/vault check under Xvfb. -GitHub-hosted runners do not provide macOS 12. Monterey UI acceptance must be performed on a physical Mac or the user's VM. CI also does not reproduce browser quarantine or an end user's Documents-permission prompt. The native smoke script refuses to run outside disposable GitHub Actions runners or against an existing vault. +GitHub-hosted runners no longer provide macOS 10.15–14. Older macOS UI acceptance must be performed on physical Macs or suitable VMs. CI also does not reproduce browser quarantine or an end user's Documents-permission prompt. The native smoke script refuses to run outside disposable GitHub Actions runners or against an existing vault. ## macOS/Ubuntu manual acceptance diff --git a/desktop-app/packaging/RELEASE-DOWNLOADS.md b/desktop-app/packaging/RELEASE-DOWNLOADS.md index 79dbe9b3..05f7bb41 100644 --- a/desktop-app/packaging/RELEASE-DOWNLOADS.md +++ b/desktop-app/packaging/RELEASE-DOWNLOADS.md @@ -9,7 +9,7 @@ | Ubuntu/Debian on Intel or AMD | `markdown-viewer-linux_amd64.deb` | | Ubuntu/Debian on ARM64 / ARMHF | The matching `linux_arm64.deb` / `linux_armhf.deb` | -**Mac:** macOS 12.0 or newer, including Ventura, Sonoma, Sequoia and Tahoe. Monterey is the minimum, not the only supported version; use the same architecture-matching DMG on newer macOS versions. Open the DMG, drag **Markdown Viewer.app** to **Applications**, eject the disk image, then open the app. Allow Documents access for the vault. See `MACOS-SIGNING.txt` for this build's signing status. Ad-hoc builds show an unidentified-developer warning; on Monterey, Control-click the installed app → Open, or use Security & Privacy → Open Anyway for a build you trust. On newer macOS, use System Settings → Privacy & Security → Open Anyway. Developer ID signed and notarized builds use Apple's normal first-open confirmation. +**Mac:** the native builds target Intel macOS 10.15 and Apple Silicon macOS 11, with no upper-version cutoff. Use the same architecture-matching DMG on newer macOS versions. Install available Safari/WebKit updates (Catalina: Safari 15.6.1). Older systems need manual acceptance; CI tests macOS 15 and 26. Open the DMG, drag **Markdown Viewer.app** to **Applications**, eject the disk image, then open the app. Allow Documents access for the vault. See `MACOS-SIGNING.txt` for this build's signing status. Ad-hoc builds show an unidentified-developer warning; on Monterey, Control-click the installed app → Open, or use Security & Privacy → Open Anyway for a build you trust. On newer macOS, use System Settings → Privacy & Security → Open Anyway. Developer ID signed and notarized builds use Apple's normal first-open confirmation. **Ubuntu:** open the `.deb` with Software Install and install, then launch **Markdown Viewer** from Applications. If your Ubuntu installation has no graphical package installer, run `sudo apt install ./markdown-viewer-linux_amd64.deb`. The package installs WebKitGTK and other runtime dependencies. Portable `.tar.gz` downloads preserve execute permission but require those libraries to be installed separately. diff --git a/desktop-app/packaging/UNSIGNED-MACOS.txt b/desktop-app/packaging/UNSIGNED-MACOS.txt index dff5f6ec..68d98d09 100644 --- a/desktop-app/packaging/UNSIGNED-MACOS.txt +++ b/desktop-app/packaging/UNSIGNED-MACOS.txt @@ -11,6 +11,8 @@ It is intended for testing; macOS may show an unidentified-developer warning. Intel Macs / VMware on Intel or AMD Windows: mac_x64.dmg. Apple Silicon (M1/M2/M3/etc.): mac_arm64.dmg. Universal works on either architecture. -Minimum macOS: 12.0. Updating Monterey to 12.7.6 and its latest Safari is recommended. +Native build targets: Intel macOS 10.15; Apple Silicon macOS 11. +Use the latest Safari/WebKit available for your Mac (Catalina: Safari 15.6.1). +These older build targets still need manual native testing; CI tests macOS 15/26. The Vault stays in Documents/Markdown Viewer Vault when you replace the app. Never disable Gatekeeper globally to run this test. diff --git a/desktop-app/packaging/build-macos-runtime.sh b/desktop-app/packaging/build-macos-runtime.sh index 1fad27a3..f36e37f5 100644 --- a/desktop-app/packaging/build-macos-runtime.sh +++ b/desktop-app/packaging/build-macos-runtime.sh @@ -7,7 +7,7 @@ EXPECTED_COMMIT=d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5 test "$(git -C "$SOURCE_DIR" rev-parse HEAD)" = "$EXPECTED_COMMIT" test "$(uname -s)" = Darwin -# With a 12.0 deployment target upstream selects its CoreGraphics fallback. +# Older deployment targets select upstream's existing CoreGraphics fallback. # ScreenCaptureKit only exists from 12.3; do not link an unused mandatory framework. python3 - "$SOURCE_DIR/CMakeLists.txt" <<'PY' import pathlib, sys @@ -19,14 +19,28 @@ p.write_text(source.replace(line, '')) PY mkdir -p "$APP_DIR/bin" +# Prove the dependency floor with the selected Apple SDK instead of just editing +# the bundle's version label. Disabling availability errors would produce an app +# that links unavailable filesystem symbols on old systems. +PROBE="$APP_DIR/../tests/native/macos-filesystem-probe.cpp" +if xcrun clang++ -std=c++17 -arch x86_64 -mmacosx-version-min=10.14 \ + -fsyntax-only "$PROBE" > "$SOURCE_DIR/filesystem-10.14.log" 2>&1; then + echo 'Apple libc++ requirements changed; re-evaluate the Intel baseline.' >&2 + exit 1 +fi +grep -q 'unavailable.*macOS 10.15' "$SOURCE_DIR/filesystem-10.14.log" +echo 'Confirmed: the native filesystem dependency is unavailable before macOS 10.15.' for arch in x86_64 arm64; do + TARGET="$(python3 "$APP_DIR/packaging/macos_support.py" target "$arch")" + xcrun clang++ -std=c++17 -arch "$arch" -mmacosx-version-min="$TARGET" \ + "$PROBE" -o "$SOURCE_DIR/filesystem-probe-$arch" cmake -S "$SOURCE_DIR" -B "$SOURCE_DIR/build-$arch" \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES="$arch" \ - -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 + -DCMAKE_OSX_DEPLOYMENT_TARGET="$TARGET" cmake --build "$SOURCE_DIR/build-$arch" --parallel 3 done cp "$SOURCE_DIR/bin/neutralino-mac_x64" "$APP_DIR/bin/" cp "$SOURCE_DIR/bin/neutralino-mac_arm64" "$APP_DIR/bin/" lipo -create "$APP_DIR/bin/neutralino-mac_x64" "$APP_DIR/bin/neutralino-mac_arm64" \ -output "$APP_DIR/bin/neutralino-mac_universal" -python3 "$APP_DIR/packaging/verify-macos.py" "$APP_DIR/bin/neutralino-mac_universal" +python3 "$APP_DIR/packaging/verify-macos.py" --arch universal "$APP_DIR/bin/neutralino-mac_universal" diff --git a/desktop-app/packaging/macos_support.py b/desktop-app/packaging/macos_support.py new file mode 100644 index 00000000..ba888020 --- /dev/null +++ b/desktop-app/packaging/macos_support.py @@ -0,0 +1,48 @@ +"""Native dependency baselines, shared by compilation, validation and app metadata. + +Neutralino uses std::filesystem, whose Apple libc++ implementation starts in 10.15. +Apple Silicon first shipped with macOS 11. These are architecture/dependency +requirements, not an application-imposed Monterey cutoff. Older OS acceptance +also requires testing its installed WebKit; load commands alone cannot prove it. +""" +import plistlib +import sys + +DEPLOYMENT_TARGETS = {'x86_64': '10.15', 'arm64': '11.0'} +CPU_ARCHITECTURES = {0x1000007: 'x86_64', 0x100000c: 'arm64'} +PACKAGE_ARCHITECTURES = {'x64': ['x86_64'], 'arm64': ['arm64'], 'universal': ['x86_64', 'arm64']} + + +def packed_version(version): + parts = [int(part) for part in version.split('.')] + parts += [0] * (3 - len(parts)) + return (parts[0] << 16) | (parts[1] << 8) | parts[2] + + +def app_metadata(version, package_arch): + architectures = PACKAGE_ARCHITECTURES[package_arch] + minimums = {arch: DEPLOYMENT_TARGETS[arch] for arch in architectures} + return { + 'CFBundleExecutable': 'markdown-viewer', + 'CFBundleIdentifier': 'com.markdownviewer.desktop', + 'CFBundleName': 'Markdown Viewer', + 'CFBundleDisplayName': 'Markdown Viewer', + 'CFBundlePackageType': 'APPL', + 'CFBundleShortVersionString': version, + 'CFBundleVersion': version.split('-')[0], + 'CFBundleIconFile': 'MarkdownViewer', + 'LSMinimumSystemVersion': min(minimums.values(), key=packed_version), + 'LSMinimumSystemVersionByArchitecture': minimums, + 'NSHighResolutionCapable': True, + 'NSDocumentsFolderUsageDescription': 'Markdown Viewer saves your documents in the Markdown Viewer Vault in Documents.', + } + + +if __name__ == '__main__': + if sys.argv[1] == 'target': + print(DEPLOYMENT_TARGETS[sys.argv[2]]) + elif sys.argv[1] == 'plist': + with open(sys.argv[4], 'wb') as target: + plistlib.dump(app_metadata(sys.argv[2], sys.argv[3]), target) + else: + raise SystemExit('Usage: macos_support.py target | plist ') diff --git a/desktop-app/packaging/package-macos.sh b/desktop-app/packaging/package-macos.sh index 5bac4158..8349eb11 100644 --- a/desktop-app/packaging/package-macos.sh +++ b/desktop-app/packaging/package-macos.sh @@ -12,30 +12,14 @@ fi for arch in x64 arm64 universal; do INPUT="$APP_DIR/dist/markdown-viewer/markdown-viewer-mac_$arch" - python3 "$APP_DIR/packaging/verify-macos.py" "$INPUT" + python3 "$APP_DIR/packaging/verify-macos.py" --arch "$arch" "$INPUT" STAGING="$(mktemp -d)" trap 'rm -rf "$STAGING"' EXIT APP="$STAGING/Markdown Viewer.app" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$INPUT" "$APP/Contents/MacOS/markdown-viewer" chmod 755 "$APP/Contents/MacOS/markdown-viewer" - python3 - "$APP/Contents/Info.plist" "$VERSION" <<'PY' -import plistlib, sys -with open(sys.argv[1], 'wb') as target: - plistlib.dump({ - 'CFBundleExecutable': 'markdown-viewer', - 'CFBundleIdentifier': 'com.markdownviewer.desktop', - 'CFBundleName': 'Markdown Viewer', - 'CFBundleDisplayName': 'Markdown Viewer', - 'CFBundlePackageType': 'APPL', - 'CFBundleShortVersionString': sys.argv[2], - 'CFBundleVersion': sys.argv[2].split('-')[0], - 'CFBundleIconFile': 'MarkdownViewer', - 'LSMinimumSystemVersion': '12.0', - 'NSHighResolutionCapable': True, - 'NSDocumentsFolderUsageDescription': 'Markdown Viewer saves your documents in the Markdown Viewer Vault in Documents.', - }, target) -PY + python3 "$APP_DIR/packaging/macos_support.py" plist "$VERSION" "$arch" "$APP/Contents/Info.plist" ICONSET="$STAGING/MarkdownViewer.iconset" mkdir "$ICONSET" for size in 16 32 128 256 512; do @@ -63,7 +47,7 @@ PY ln -s /Applications "$STAGING/Applications" DMG="$OUTPUT/markdown-viewer-mac_$arch.dmg" rm -f "$DMG" - hdiutil create -volname 'Markdown Viewer' -srcfolder "$STAGING" -ov -format UDZO "$DMG" + hdiutil create -volname 'Markdown Viewer' -srcfolder "$STAGING" -ov -fs HFS+ -format UDZO "$DMG" if [[ "$IDENTITY" != - ]]; then codesign --sign "$IDENTITY" --timestamp "$DMG" xcrun notarytool submit "$DMG" --keychain-profile "$MACOS_NOTARY_PROFILE" --wait @@ -77,5 +61,7 @@ done if [[ "$IDENTITY" == - ]]; then cp "$APP_DIR/packaging/UNSIGNED-MACOS.txt" "$OUTPUT/MACOS-SIGNING.txt" else - printf 'Developer ID signed and Apple notarized. Minimum macOS: 12.0.\n' > "$OUTPUT/MACOS-SIGNING.txt" + printf 'Developer ID signed and Apple notarized. Native runtime baselines: Intel %s; Apple Silicon %s.\n' \ + "$(python3 "$APP_DIR/packaging/macos_support.py" target x86_64)" \ + "$(python3 "$APP_DIR/packaging/macos_support.py" target arm64)" > "$OUTPUT/MACOS-SIGNING.txt" fi diff --git a/desktop-app/packaging/verify-macos.py b/desktop-app/packaging/verify-macos.py index 18e6cad6..49e67549 100644 --- a/desktop-app/packaging/verify-macos.py +++ b/desktop-app/packaging/verify-macos.py @@ -2,7 +2,8 @@ """Check every Mach-O slice, including embedded-resource outputs, before shipping.""" import pathlib import struct -import sys +import argparse +from macos_support import CPU_ARCHITECTURES, DEPLOYMENT_TARGETS, PACKAGE_ARCHITECTURES, packed_version def inspect(data): @@ -29,14 +30,26 @@ def inspect(data): elif command == 0xc: # LC_LOAD_DYLIB (mandatory, not weak) start = struct.unpack_from(' + +int main() { + return std::filesystem::exists(std::filesystem::current_path()) ? 0 : 1; +} diff --git a/tests/native/test_macos_load_commands.py b/tests/native/test_macos_load_commands.py index 17ca4693..9f906736 100644 --- a/tests/native/test_macos_load_commands.py +++ b/tests/native/test_macos_load_commands.py @@ -1,10 +1,14 @@ import importlib.util import pathlib +import plistlib import struct +import sys import unittest -script = pathlib.Path(__file__).resolve().parents[2] / 'desktop-app/packaging/verify-macos.py' -spec = importlib.util.spec_from_file_location('verify_macos', script) +packaging = pathlib.Path(__file__).resolve().parents[2] / 'desktop-app/packaging' +sys.path.insert(0, str(packaging)) +from macos_support import app_metadata +spec = importlib.util.spec_from_file_location('verify_macos', packaging / 'verify-macos.py') verifier = importlib.util.module_from_spec(spec) spec.loader.exec_module(verifier) @@ -17,28 +21,62 @@ def macho(minimum, cpu=0x1000007, framework=False): return struct.pack('II', 0xcafebabe, 2) + header += struct.pack('>IIIII', 0x1000007, 3, 48, len(first), 0) + header += struct.pack('>IIIII', 0x100000c, 0, 48 + len(first), len(second), 0) + return header + first + second + + class Compatibility(unittest.TestCase): - def test_accepts_monterey(self): - self.assertEqual(verifier.inspect(macho(0x0c0000)), [0x1000007]) + def test_accepts_intel_catalina_and_apple_silicon_big_sur(self): + self.assertEqual(verifier.verify(macho(0x0a0f00), 'x64'), ['x86_64']) + self.assertEqual(verifier.verify(macho(0x0b0000, 0x100000c), 'arm64'), ['arm64']) + + def test_rejects_the_previous_monterey_cutoff_and_upstream_sonoma(self): + for minimum in [0x0c0000, 0x0e0000]: + for cpu in [0x1000007, 0x100000c]: + with self.subTest(minimum=minimum, cpu=cpu), self.assertRaises(AssertionError): + verifier.inspect(macho(minimum, cpu)) + + def test_rejects_mandatory_framework_missing_on_older_macs(self): + with self.assertRaises(AssertionError): + verifier.inspect(macho(0x0a0f00, framework=True)) - def test_rejects_upstream_sonoma_minimum(self): + def test_checks_both_universal_slices_against_their_own_baselines(self): + self.assertEqual(verifier.verify(universal(macho(0x0b0000, 0x100000c)), 'universal'), ['x86_64', 'arm64']) + with self.assertRaises(AssertionError): + verifier.inspect(universal(macho(0x0c0000, 0x100000c))) with self.assertRaises(AssertionError): - verifier.inspect(macho(0x0e0000)) + verifier.verify(universal(macho(0x0a0f00)), 'universal') - def test_rejects_mandatory_framework_missing_on_12_0(self): + def test_rejects_an_architecture_mislabeled_as_another_download(self): with self.assertRaises(AssertionError): - verifier.inspect(macho(0x0c0000, framework=True)) - - def test_checks_both_universal_slices(self): - def universal(second): - first = macho(0x0c0000) - header = struct.pack('>II', 0xcafebabe, 2) - header += struct.pack('>IIIII', 0x1000007, 3, 48, len(first), 0) - header += struct.pack('>IIIII', 0x100000c, 0, 48 + len(first), len(second), 0) - return header + first + second - self.assertEqual(verifier.inspect(universal(macho(0x0c0000, 0x100000c))), [0x1000007, 0x100000c]) + verifier.verify(macho(0x0b0000, 0x100000c), 'x64') with self.assertRaises(AssertionError): - verifier.inspect(universal(macho(0x0e0000, 0x100000c))) + verifier.verify(macho(0x0a0f00), 'universal') + + def test_legacy_intel_load_command_is_accepted(self): + binary = bytearray(macho(0x0a0f00)) + # Older Intel targets use LC_VERSION_MIN_MACOSX instead of LC_BUILD_VERSION. + struct.pack_into(' Date: Sat, 7 Nov 2026 10:54:04 +0000 Subject: [PATCH 3/6] fix(desktop): verify complete installer downloads before release --- .github/workflows/desktop-build.yml | 43 +++++++++++++++++--- desktop-app/packaging/README.md | 7 +++- desktop-app/packaging/RELEASE-DOWNLOADS.md | 9 +++++ desktop-app/packaging/UNSIGNED-MACOS.txt | 5 ++- desktop-app/packaging/release-manifest.js | 27 +++++++++---- tests/unit/desktop-packaging.test.mjs | 47 ++++++++++++++++++++++ 6 files changed, 121 insertions(+), 17 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 51682769..6a6f6c4d 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -192,21 +192,54 @@ jobs: - name: Launch via LaunchServices, import a file and reopen the vault run: python3 tests/native/desktop-package-smoke.py "$RUNNER_TEMP/installed/Markdown Viewer.app" + assemble-release: + needs: [test-linux, test-macos] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: actions/download-artifact@v4 + with: + name: desktop-macos + path: release-assets + - uses: actions/download-artifact@v4 + with: + name: desktop-linux-windows + path: release-assets + - name: Verify asset set and write checksums + run: | + cp desktop-app/packaging/RELEASE-DOWNLOADS.md release-assets/README.md + node desktop-app/packaging/release-manifest.js release-assets + cd release-assets + sha256sum --check SHA256SUMS.txt + - uses: actions/upload-artifact@v4 + with: + name: desktop-release + path: release-assets/* + if-no-files-found: error + retention-days: 30 + release: if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' - needs: [test-linux, test-macos] + needs: assemble-release runs-on: ubuntu-24.04 permissions: contents: write steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/download-artifact@v4 with: - pattern: desktop-* + name: desktop-release path: release-assets - merge-multiple: true - - name: Verify asset set and write checksums - run: node desktop-app/packaging/release-manifest.js release-assets + - name: Verify the assembled packages before publishing + working-directory: release-assets + run: sha256sum --check SHA256SUMS.txt - uses: softprops/action-gh-release@v2 with: name: Markdown Viewer ${{ github.ref_name }} diff --git a/desktop-app/packaging/README.md b/desktop-app/packaging/README.md index 5df21251..a75f00f7 100644 --- a/desktop-app/packaging/README.md +++ b/desktop-app/packaging/README.md @@ -96,9 +96,11 @@ For the normal trusted public-download experience, the repository owner needs an Only tag-push release jobs read these secrets. With no identity configured, releases are explicitly identified as ad-hoc signed; partially configured credentials or signing/notarization failures fail the build. PR code never receives these credentials. The workflow imports a temporary keychain, signs with hardened runtime and a timestamp, notarizes and staples the app and DMG, and removes the temporary keychain/certificate. For a local signed build, set `MACOS_SIGNING_IDENTITY` and `MACOS_NOTARY_PROFILE` to an existing `notarytool` keychain profile. No Apple credentials were available to validate that optional path while developing this change. -## CI and testing downloads +## CI and complete application downloads -The **Build Desktop Installers** workflow runs for packaging PRs, manual dispatches and version tags. The `desktop-macos` artifact contains the three DMGs and signing status. GitHub wraps it in a ZIP: extract it first, then open the appropriate DMG. Artifacts are kept for 30 days; a tag release publishes durable assets only after native smoke checks pass. No tag/release is needed to test a PR. +The **Build Desktop Installers** workflow runs for packaging PRs, manual dispatches and version tags. After all native package checks pass, `assemble-release` verifies the complete asset set and produces the **`desktop-release`** artifact: the three Mac DMGs, Linux DEBs and portable archives, Windows EXE, signing status, installation instructions and SHA-256 checksums. GitHub wraps it in a ZIP: extract it first, then open the installer for your computer. These are the complete application packages, with normal editing and durable vault storage, built through the same packaging path used for releases. They contain no smoke-test fixtures or special test mode. Mac PR/manual packages remain ad-hoc signed; Apple signing/notarization is a separate tag-release step when credentials are configured. + +Artifacts are kept for 30 days. A version tag publishes the exact assembled files only after verifying their checksums again; it does not rebuild them during publication. Merging the PR alone does not create a public release. No tag or release is needed to install the complete packages from the PR. The individual `desktop-macos` and `desktop-linux-windows` artifacts are also retained for diagnosing failed acceptance runs; use `desktop-release` for the fully checked download set. CI checks: @@ -107,6 +109,7 @@ CI checks: - Runtime compilation and final Mach-O, plist, signature and DMG validation. - On ARM and Intel Mac runners for macOS 15 and the latest stable hosted macOS: mount the actual DMG, copy the app, eject the image, launch through LaunchServices, import a Unicode Markdown filename, verify its bytes in the actual Documents vault, and reopen the app. The workflow logs `sw_vers` and CPU architecture to record the actual OS tested. - On Ubuntu 22.04 and 24.04: install the actual `.deb` with `apt`, validate its desktop entry, and run the same native GUI/vault check under Xvfb. +- On every PR/manual/tag run: require the exact complete asset set, reject empty or non-file assets, and verify checksums before making the assembled download available. GitHub-hosted runners no longer provide macOS 10.15–14. Older macOS UI acceptance must be performed on physical Macs or suitable VMs. CI also does not reproduce browser quarantine or an end user's Documents-permission prompt. The native smoke script refuses to run outside disposable GitHub Actions runners or against an existing vault. diff --git a/desktop-app/packaging/RELEASE-DOWNLOADS.md b/desktop-app/packaging/RELEASE-DOWNLOADS.md index 05f7bb41..d161ab7d 100644 --- a/desktop-app/packaging/RELEASE-DOWNLOADS.md +++ b/desktop-app/packaging/RELEASE-DOWNLOADS.md @@ -1,5 +1,9 @@ ### Desktop downloads +These installers contain the complete desktop application with normal editing, +exports and persistent vault storage. No developer tools or separate resource +files are needed. For a workflow artifact download, extract the ZIP first. + | Your computer | Download | | --- | --- | | Windows x64 | `markdown-viewer-win_x64.exe` | @@ -14,3 +18,8 @@ **Ubuntu:** open the `.deb` with Software Install and install, then launch **Markdown Viewer** from Applications. If your Ubuntu installation has no graphical package installer, run `sudo apt install ./markdown-viewer-linux_amd64.deb`. The package installs WebKitGTK and other runtime dependencies. Portable `.tar.gz` downloads preserve execute permission but require those libraries to be installed separately. Documents remain in **Documents/Markdown Viewer Vault** when replacing or uninstalling the app. `SHA256SUMS.txt` covers the files above. GitHub's automatically generated **Source code** archives are for developers, not application installers. + +To verify a download, run `shasum -a 256 ` on macOS or +`sha256sum ` on Linux and compare it with `SHA256SUMS.txt`. +When all files are extracted together, `shasum -a 256 -c SHA256SUMS.txt` (Mac) +or `sha256sum -c SHA256SUMS.txt` (Linux) verifies the complete download set. diff --git a/desktop-app/packaging/UNSIGNED-MACOS.txt b/desktop-app/packaging/UNSIGNED-MACOS.txt index 68d98d09..26366d3b 100644 --- a/desktop-app/packaging/UNSIGNED-MACOS.txt +++ b/desktop-app/packaging/UNSIGNED-MACOS.txt @@ -1,5 +1,6 @@ This build is locally (ad-hoc) signed, not Apple Developer ID signed or notarized. -It is intended for testing; macOS may show an unidentified-developer warning. +It contains the complete desktop application, with normal editing and vault storage. +macOS may show an unidentified-developer warning because Apple has not notarized it. 1. Open the .dmg and drag Markdown Viewer.app to Applications. 2. Eject the disk image and open the app from Applications. @@ -15,4 +16,4 @@ Native build targets: Intel macOS 10.15; Apple Silicon macOS 11. Use the latest Safari/WebKit available for your Mac (Catalina: Safari 15.6.1). These older build targets still need manual native testing; CI tests macOS 15/26. The Vault stays in Documents/Markdown Viewer Vault when you replace the app. -Never disable Gatekeeper globally to run this test. +Never disable Gatekeeper globally to run the application. diff --git a/desktop-app/packaging/release-manifest.js b/desktop-app/packaging/release-manifest.js index 50e7ea17..9acf97ef 100644 --- a/desktop-app/packaging/release-manifest.js +++ b/desktop-app/packaging/release-manifest.js @@ -2,16 +2,27 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); -const directory = path.resolve(process.argv[2]); const expected = [ - 'markdown-viewer-win_x64.exe', 'MACOS-SIGNING.txt', + 'markdown-viewer-win_x64.exe', 'MACOS-SIGNING.txt', 'README.md', ...['x64', 'arm64', 'universal'].map(arch => `markdown-viewer-mac_${arch}.dmg`), ...['amd64', 'arm64', 'armhf'].map(arch => `markdown-viewer-linux_${arch}.deb`), ...['x64', 'arm64', 'armhf'].map(arch => `markdown-viewer-linux_${arch}.tar.gz`), ].sort(); -const actual = fs.readdirSync(directory).filter(name => name !== 'SHA256SUMS.txt').sort(); -if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error(`Unexpected release assets: ${actual.join(', ')}`); -fs.writeFileSync(path.join(directory, 'SHA256SUMS.txt'), expected.map(name => { - const digest = crypto.createHash('sha256').update(fs.readFileSync(path.join(directory, name))).digest('hex'); - return `${digest} ${name}\n`; -}).join('')); +function writeManifest(directory) { + const actual = fs.readdirSync(directory).filter(name => name !== 'SHA256SUMS.txt').sort(); + if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error(`Unexpected release assets: ${actual.join(', ')}`); + const checksums = expected.map(name => { + const file = path.join(directory, name); + const stat = fs.lstatSync(file); + if (!stat.isFile() || !stat.size) throw new Error(`Release asset must be a non-empty regular file: ${name}`); + const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + return `${digest} ${name}\n`; + }).join(''); + fs.writeFileSync(path.join(directory, 'SHA256SUMS.txt'), checksums); +} + +if (require.main === module) { + if (process.argv.length !== 3) throw new Error('Usage: release-manifest.js '); + writeManifest(path.resolve(process.argv[2])); +} +module.exports = { writeManifest }; diff --git a/tests/unit/desktop-packaging.test.mjs b/tests/unit/desktop-packaging.test.mjs index 570d38a3..cdca10c5 100644 --- a/tests/unit/desktop-packaging.test.mjs +++ b/tests/unit/desktop-packaging.test.mjs @@ -7,6 +7,53 @@ import { execFileSync } from 'node:child_process'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { stagePackage } = require('../../desktop-app/packaging/package-linux.js'); +const { writeManifest } = require('../../desktop-app/packaging/release-manifest.js'); + +function releaseFiles(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-release-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const names = [ + 'markdown-viewer-win_x64.exe', 'MACOS-SIGNING.txt', 'README.md', + 'markdown-viewer-mac_x64.dmg', 'markdown-viewer-mac_arm64.dmg', 'markdown-viewer-mac_universal.dmg', + 'markdown-viewer-linux_amd64.deb', 'markdown-viewer-linux_arm64.deb', 'markdown-viewer-linux_armhf.deb', + 'markdown-viewer-linux_x64.tar.gz', 'markdown-viewer-linux_arm64.tar.gz', 'markdown-viewer-linux_armhf.tar.gz', + ]; + for (const name of names) fs.writeFileSync(path.join(directory, name), `Packaged bytes for ${name}\n`); + return directory; +} + +test('release assembly rejects incomplete, unexpected, empty and non-file downloads', t => { + const directory = releaseFiles(t); + const installer = path.join(directory, 'markdown-viewer-mac_x64.dmg'); + const contents = fs.readFileSync(installer); + fs.unlinkSync(installer); + assert.throws(() => writeManifest(directory), /Unexpected release assets/); + fs.writeFileSync(installer, contents); + const unexpected = path.join(directory, 'markdown-viewer-mac_x64'); + fs.writeFileSync(unexpected, 'unpackaged binary'); + assert.throws(() => writeManifest(directory), /Unexpected release assets/); + fs.unlinkSync(unexpected); + fs.writeFileSync(installer, ''); + assert.throws(() => writeManifest(directory), /non-empty regular file/); + fs.unlinkSync(installer); + fs.mkdirSync(installer); + assert.throws(() => writeManifest(directory), /non-empty regular file/); + assert.equal(fs.existsSync(path.join(directory, 'SHA256SUMS.txt')), false); +}); + +test('assembled checksums verify with the system tool and detect changed installer bytes', t => { + const directory = releaseFiles(t); + writeManifest(directory); + const first = fs.readFileSync(path.join(directory, 'SHA256SUMS.txt'), 'utf8'); + assert.equal(first.trim().split('\n').length, 12); + writeManifest(directory); + assert.equal(fs.readFileSync(path.join(directory, 'SHA256SUMS.txt'), 'utf8'), first); + if (process.platform === 'linux') { + execFileSync('sha256sum', ['--check', '--status', 'SHA256SUMS.txt'], { cwd: directory }); + fs.appendFileSync(path.join(directory, 'markdown-viewer-linux_amd64.deb'), 'changed'); + assert.throws(() => execFileSync('sha256sum', ['--check', '--status', 'SHA256SUMS.txt'], { cwd: directory })); + } +}); test('Debian packages preserve launcher permissions, desktop integration and runtime dependencies', t => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-package-')); From 5021f5ee9ba56433ce4fb46292ae9ca2f8ea3d41 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:40:53 +0000 Subject: [PATCH 4/6] fix(desktop): add platform branding and clearer installer downloads --- .github/workflows/desktop-build.yml | 21 ++++++- .../experimental/windows/brand-executable.js | 9 ++- desktop-app/packaging/README.md | 56 +++++++++++++++++-- desktop-app/packaging/RELEASE-DOWNLOADS.md | 23 ++++++++ desktop-app/packaging/macos_support.py | 11 +++- desktop-app/packaging/metadata.json | 15 +++++ desktop-app/packaging/package-linux.js | 55 +++++++++++++++++- desktop-app/packaging/package-macos.sh | 6 ++ desktop-app/packaging/release-manifest.js | 14 +++-- tests/native/desktop-package-smoke.py | 10 ++++ tests/native/test_macos_load_commands.py | 4 ++ tests/unit/desktop-packaging.test.mjs | 42 +++++++++++++- tests/unit/windows-branding.test.mjs | 4 ++ 13 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 desktop-app/packaging/metadata.json diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 6a6f6c4d..9700ad63 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -142,8 +142,13 @@ jobs: - name: Install the actual Debian package and its dependencies run: | sudo apt-get update - sudo apt-get install -y ./installers/markdown-viewer-linux_amd64.deb xvfb desktop-file-utils + sudo apt-get install -y ./installers/markdown-viewer-linux_amd64.deb xvfb desktop-file-utils appstream desktop-file-validate /usr/share/applications/com.markdownviewer.desktop.desktop + - name: Validate the installed application metadata and icon integration + run: | + appstreamcli validate --no-net /usr/share/metainfo/com.markdownviewer.desktop.metainfo.xml + dpkg-deb --extract installers/markdown-viewer-linux_amd64.deb "$RUNNER_TEMP/package-metadata" + appstreamcli validate-tree --no-net "$RUNNER_TEMP/package-metadata" - name: Launch native WebKit GUI, import a file and reopen the vault run: xvfb-run -a python3 tests/native/desktop-package-smoke.py /usr/bin/markdown-viewer @@ -185,6 +190,7 @@ jobs: uname -m mkdir -p "$RUNNER_TEMP/dmg" "$RUNNER_TEMP/installed" hdiutil attach "installers/markdown-viewer-mac_$ARCH.dmg" -nobrowse -mountpoint "$RUNNER_TEMP/dmg" + test -s "$RUNNER_TEMP/dmg/.VolumeIcon.icns" ditto "$RUNNER_TEMP/dmg/Markdown Viewer.app" "$RUNNER_TEMP/installed/Markdown Viewer.app" hdiutil detach "$RUNNER_TEMP/dmg" codesign --verify --deep --strict "$RUNNER_TEMP/installed/Markdown Viewer.app" @@ -222,6 +228,19 @@ jobs: path: release-assets/* if-no-files-found: error retention-days: 30 + - name: Prepare the recommended installer download + run: | + mkdir installer-download + cp release-assets/*.deb release-assets/*.dmg release-assets/*.exe release-assets/MACOS-SIGNING.txt release-assets/README.md installer-download/ + node desktop-app/packaging/release-manifest.js installer-download --installers-only + cd installer-download + sha256sum --check SHA256SUMS.txt + - uses: actions/upload-artifact@v4 + with: + name: desktop-installers + path: installer-download/* + if-no-files-found: error + retention-days: 30 release: if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' diff --git a/desktop-app/experimental/windows/brand-executable.js b/desktop-app/experimental/windows/brand-executable.js index 7b6bcf7a..c3c5e27b 100644 --- a/desktop-app/experimental/windows/brand-executable.js +++ b/desktop-app/experimental/windows/brand-executable.js @@ -1,6 +1,7 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const metadata = require('../../packaging/metadata.json'); // neu sets OriginalFilename to its staging binaryName. Set the final filename // after packaging, preserving all other resources, including the embedded app. @@ -21,10 +22,12 @@ async function brandExecutable(file) { version.setProductVersion(appVersion); for (const language of version.getAllLanguagesForStringValues()) { version.setStringValues(language, { - ProductName: 'Markdown Viewer', FileDescription: 'Markdown Viewer', - OriginalFilename: path.basename(file) + ProductName: metadata.name, FileDescription: metadata.name, + OriginalFilename: path.basename(file), + CompanyName: metadata.developer, + LegalCopyright: metadata.copyright, + Comments: `${metadata.summary}. ${metadata.license}. ${metadata.homepage}` }); - version.removeStringValue(language, 'CompanyName'); } version.outputToResourceEntries(resources.entries); } diff --git a/desktop-app/packaging/README.md b/desktop-app/packaging/README.md index a75f00f7..fc8a06fe 100644 --- a/desktop-app/packaging/README.md +++ b/desktop-app/packaging/README.md @@ -53,6 +53,54 @@ See [release download instructions](RELEASE-DOWNLOADS.md). Mac users download a All packages use the same application id, native API allowlist, shared application code and fixed `Documents/Markdown Viewer Vault` as Windows. Installation never creates, replaces or deletes a user's vault. The running application creates it and saves normal documents under `Workspace`. Removing/replacing the installed app leaves the vault intact. +## Linux formats and application identity + +There are **two Linux formats for three CPU architectures**, rather than six +formats. Keep DEB as the recommended Ubuntu/Debian installer: it handles native +dependencies, package removal and desktop integration. The portable tarballs +remain optional for users who manage their own libraries; they are excluded +from the recommended `desktop-installers` ZIP. All existing CPU choices remain. + +There is no single package format used by every Linux distribution. For example, +[VS Code](https://code.visualstudio.com/docs/setup/linux) distributes DEB, RPM +and Snap, and [Slack](https://slack.com/help/articles/212924728-Download-Slack-for-Linux--beta-) +documents DEB for Ubuntu and RPM for RHEL. This project currently packages DEB; +RPM, AppImage, Flatpak and Snap are not built or qualified by this workflow. +Publishing a package repository or store listing is a separate distribution task. + +`metadata.json` supplies the product name, developer, project license, website, +copyright and description for native packaging. The application remains +Apache-2.0; the runtime's MIT license is preserved separately. Generated AppStream +metadata is CC0-1.0. Linux packages include a matching desktop entry, hicolor and +pixmap icons, AppStream component, maintainer, installed size and a useful control +description. The AppStream release date comes from `RELEASE_NOTES`, not the build +date. Its legacy `developer_name` element supports Ubuntu 22.04's AppStream; +newer validators may emit informational deprecation notices. + +Mac bundles include the application icon, version, copyright, productivity +category and license files; the mounted DMG has the application volume icon. +Windows PE resources include the product/version, developer, copyright, license +and website while preserving the existing application icon and embedded content. +Native trust dialogs still depend on code signing, independently of these fields. + +### Ubuntu App Center's local-file limitation + +The App Center local-DEB screen passes only the package name to its title widget +and no icon to its title bar. It obtains description/license/size from PackageKit, +and its local-package model returns no publication date. It does not read the +DEB's AppStream component on this screen. Adding metainfo cannot make that screen +show the custom logo, verified publisher or a release date. The long description +therefore includes the actual publisher, Apache-2.0 license and project links +even when those dedicated fields remain unknown. Installed icons and metadata +are provided for desktop environments/software managers that use them. + +Source reviewed at Ubuntu App Center commit `a07818f0388a1ebe7c7d5e0156fc4aca846cb22e`: +[local title](https://github.com/ubuntu/app-center/blob/a07818f0388a1ebe7c7d5e0156fc4aca846cb22e/packages/app_center/lib/widgets/app_title.dart#L44), +[local title bar](https://github.com/ubuntu/app-center/blob/a07818f0388a1ebe7c7d5e0156fc4aca846cb22e/packages/app_center/lib/apps/app_title_bar.dart#L53), +[local metadata](https://github.com/ubuntu/app-center/blob/a07818f0388a1ebe7c7d5e0156fc4aca846cb22e/packages/app_center/lib/deb/local_deb_model.dart). +Store listing/catalog support is different from opening a local DEB. We do not +claim to remove App Center's third-party warning or change OS file-type icons. + ## Build locally Windows/Linux raw executables (Node 24, npm, Linux packaging also needs `dpkg-deb` and `tar`): @@ -98,17 +146,17 @@ Only tag-push release jobs read these secrets. With no identity configured, rele ## CI and complete application downloads -The **Build Desktop Installers** workflow runs for packaging PRs, manual dispatches and version tags. After all native package checks pass, `assemble-release` verifies the complete asset set and produces the **`desktop-release`** artifact: the three Mac DMGs, Linux DEBs and portable archives, Windows EXE, signing status, installation instructions and SHA-256 checksums. GitHub wraps it in a ZIP: extract it first, then open the installer for your computer. These are the complete application packages, with normal editing and durable vault storage, built through the same packaging path used for releases. They contain no smoke-test fixtures or special test mode. Mac PR/manual packages remain ad-hoc signed; Apple signing/notarization is a separate tag-release step when credentials are configured. +The **Build Desktop Installers** workflow runs for packaging PRs, manual dispatches and version tags. After all native package checks pass, `assemble-release` verifies the complete asset set and produces two downloads: **`desktop-installers`** (recommended: three Mac DMGs, three Linux DEBs and the Windows EXE) and **`desktop-release`** (the same installers plus three optional Linux portable archives). Both include signing status, installation instructions and their own SHA-256 checksums. GitHub wraps each in a ZIP: extract it first, then open the installer for your computer. These are the complete application packages, with normal editing and durable vault storage, built through the same packaging path used for releases. They contain no smoke-test fixtures or special test mode. Mac PR/manual packages remain ad-hoc signed; Apple signing/notarization is a separate tag-release step when credentials are configured. -Artifacts are kept for 30 days. A version tag publishes the exact assembled files only after verifying their checksums again; it does not rebuild them during publication. Merging the PR alone does not create a public release. No tag or release is needed to install the complete packages from the PR. The individual `desktop-macos` and `desktop-linux-windows` artifacts are also retained for diagnosing failed acceptance runs; use `desktop-release` for the fully checked download set. +Artifacts are kept for 30 days. A version tag publishes the exact assembled files from `desktop-release` only after verifying their checksums again; it does not rebuild them during publication. Merging the PR alone does not create a public release. No tag or release is needed to install the complete packages from the PR. The individual `desktop-macos` and `desktop-linux-windows` artifacts are also retained for diagnosing failed acceptance runs; use `desktop-installers` for the recommended fully checked download. CI checks: -- Shared build and unit suite; Debian package metadata, permissions and extraction. +- Shared build and unit suite; Debian package metadata, permissions and extraction; Windows version/icon preservation and Mac Finder metadata. - Seven compatibility regression tests covering the previous 12/14 cutoffs, mixed universal slices, legacy Intel load commands, incorrect architecture labels and per-architecture Finder metadata. - Runtime compilation and final Mach-O, plist, signature and DMG validation. - On ARM and Intel Mac runners for macOS 15 and the latest stable hosted macOS: mount the actual DMG, copy the app, eject the image, launch through LaunchServices, import a Unicode Markdown filename, verify its bytes in the actual Documents vault, and reopen the app. The workflow logs `sw_vers` and CPU architecture to record the actual OS tested. -- On Ubuntu 22.04 and 24.04: install the actual `.deb` with `apt`, validate its desktop entry, and run the same native GUI/vault check under Xvfb. +- On Ubuntu 22.04 and 24.04: install the actual `.deb` with `apt`, validate its desktop entry and AppStream metadata/file tree, and run the same native GUI/vault check under Xvfb. App Center's graphical local-DEB page is not exercised by these checks. - On every PR/manual/tag run: require the exact complete asset set, reject empty or non-file assets, and verify checksums before making the assembled download available. GitHub-hosted runners no longer provide macOS 10.15–14. Older macOS UI acceptance must be performed on physical Macs or suitable VMs. CI also does not reproduce browser quarantine or an end user's Documents-permission prompt. The native smoke script refuses to run outside disposable GitHub Actions runners or against an existing vault. diff --git a/desktop-app/packaging/RELEASE-DOWNLOADS.md b/desktop-app/packaging/RELEASE-DOWNLOADS.md index d161ab7d..02c7b626 100644 --- a/desktop-app/packaging/RELEASE-DOWNLOADS.md +++ b/desktop-app/packaging/RELEASE-DOWNLOADS.md @@ -4,6 +4,14 @@ These installers contain the complete desktop application with normal editing, exports and persistent vault storage. No developer tools or separate resource files are needed. For a workflow artifact download, extract the ZIP first. +**Recommended workflow download: `desktop-installers`.** It contains the Windows +EXE, three Mac DMGs and three Linux DEB installers. The full `desktop-release` +download additionally contains the optional Linux portable archives. + +Publisher: **ThisIs-Developer**. License: **Apache-2.0**. +[Website](https://markdownviewer.pages.dev/) · +[Support and source code](https://github.com/ThisIs-Developer/Markdown-Viewer). + | Your computer | Download | | --- | --- | | Windows x64 | `markdown-viewer-win_x64.exe` | @@ -17,6 +25,21 @@ files are needed. For a workflow artifact download, extract the ZIP first. **Ubuntu:** open the `.deb` with Software Install and install, then launch **Markdown Viewer** from Applications. If your Ubuntu installation has no graphical package installer, run `sudo apt install ./markdown-viewer-linux_amd64.deb`. The package installs WebKitGTK and other runtime dependencies. Portable `.tar.gz` downloads preserve execute permission but require those libraries to be installed separately. +**Why several Linux downloads?** Choose one CPU architecture: `amd64` / `x64` +for Intel and AMD computers (including ordinary Ubuntu VMware guests), `arm64` +for 64-bit ARM, or `armhf` for 32-bit ARM. Each architecture has a recommended +`.deb` installer and an optional `.tar.gz` archive. These are two formats for +three architectures. You only need one file; `amd64` also works on Intel CPUs. + +**Ubuntu App Center details:** opening a downloaded local DEB can still show a +generic icon, "Unknown publisher", "License unknown" or an unknown update date. +That App Center screen does not read all of the package's application metadata. +The description includes the publisher, license, website and support link; +the package installs the application icon, desktop entry and AppStream metadata +for software managers that consume it. The generic DEB file icon and third-party +package warning are controlled by the operating system. Metadata does not certify +or verify a publisher. + Documents remain in **Documents/Markdown Viewer Vault** when replacing or uninstalling the app. `SHA256SUMS.txt` covers the files above. GitHub's automatically generated **Source code** archives are for developers, not application installers. To verify a download, run `shasum -a 256 ` on macOS or diff --git a/desktop-app/packaging/macos_support.py b/desktop-app/packaging/macos_support.py index ba888020..cab1515a 100644 --- a/desktop-app/packaging/macos_support.py +++ b/desktop-app/packaging/macos_support.py @@ -7,6 +7,10 @@ """ import plistlib import sys +import json +import pathlib + +METADATA = json.loads(pathlib.Path(__file__).with_name('metadata.json').read_text()) DEPLOYMENT_TARGETS = {'x86_64': '10.15', 'arm64': '11.0'} CPU_ARCHITECTURES = {0x1000007: 'x86_64', 0x100000c: 'arm64'} @@ -25,12 +29,15 @@ def app_metadata(version, package_arch): return { 'CFBundleExecutable': 'markdown-viewer', 'CFBundleIdentifier': 'com.markdownviewer.desktop', - 'CFBundleName': 'Markdown Viewer', - 'CFBundleDisplayName': 'Markdown Viewer', + 'CFBundleName': METADATA['name'], + 'CFBundleDisplayName': METADATA['name'], 'CFBundlePackageType': 'APPL', 'CFBundleShortVersionString': version, 'CFBundleVersion': version.split('-')[0], 'CFBundleIconFile': 'MarkdownViewer', + 'CFBundleGetInfoString': f"{METADATA['name']} {version}, {METADATA['developer']}, {METADATA['license']}", + 'NSHumanReadableCopyright': METADATA['copyright'], + 'LSApplicationCategoryType': 'public.app-category.productivity', 'LSMinimumSystemVersion': min(minimums.values(), key=packed_version), 'LSMinimumSystemVersionByArchitecture': minimums, 'NSHighResolutionCapable': True, diff --git a/desktop-app/packaging/metadata.json b/desktop-app/packaging/metadata.json new file mode 100644 index 00000000..ec0bfddd --- /dev/null +++ b/desktop-app/packaging/metadata.json @@ -0,0 +1,15 @@ +{ + "name": "Markdown Viewer", + "summary": "Edit and preview Markdown with a local document vault", + "developer": "ThisIs-Developer", + "maintainer": "Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com>", + "homepage": "https://markdownviewer.pages.dev/", + "repository": "https://github.com/ThisIs-Developer/Markdown-Viewer", + "license": "Apache-2.0", + "copyright": "Copyright 2024 ThisIs-Developer", + "description": [ + "Write and preview Markdown side by side, with syntax highlighting, diagrams and mathematical notation.", + "Keep documents in a local vault, reopen your workspace, link existing Markdown files and export to HTML, PDF or PNG.", + "Core editing and bundled previews work offline. Sharing, remote imports and other network features require an internet connection." + ] +} diff --git a/desktop-app/packaging/package-linux.js b/desktop-app/packaging/package-linux.js index 99e2dd32..551e7e05 100644 --- a/desktop-app/packaging/package-linux.js +++ b/desktop-app/packaging/package-linux.js @@ -4,8 +4,54 @@ const path = require('node:path'); const { execFileSync } = require('node:child_process'); const appDir = path.resolve(__dirname, '..'); const config = require('../neutralino.config.json'); +const metadata = require('./metadata.json'); const architectures = { x64: 'amd64', arm64: 'arm64', armhf: 'armhf' }; +function xml(value) { + return String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); +} + +function appStream(version) { + const notes = fs.readFileSync(path.join(appDir, '../RELEASE_NOTES'), 'utf8'); + const released = notes.match(/_Released ([A-Za-z]+ \d{1,2}, \d{4})_/); + if (!released || !Number.isFinite(Date.parse(released[1] + ' UTC'))) throw new Error('RELEASE_NOTES must declare the release date'); + const date = new Date(released[1] + ' UTC').toISOString().slice(0, 10); + return ` + + ${xml(config.applicationId)} + CC0-1.0 + ${xml(metadata.license)} + ${xml(metadata.name)} + ${xml(metadata.summary)} + ${xml(metadata.developer)} + +${metadata.description.map(paragraph => `

${xml(paragraph)}

`).join('\n')} +
+ ${xml(config.applicationId)}.desktop + markdown-viewer + markdown-viewer + OfficeTextEditor + MarkdownEditorPreviewNotes + ${xml(metadata.homepage)} + ${xml(metadata.repository)}/issues + ${xml(metadata.repository)}/blob/main/wiki/Desktop-App.md + ${xml(metadata.repository)} + markdown-viewertext/markdowntext/x-markdown + +
+`; +} + +function installedSize(directory) { + let kibibytes = 1; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.name === 'DEBIAN') continue; + const file = path.join(directory, entry.name); + kibibytes += entry.isDirectory() ? installedSize(file) : Math.ceil(fs.statSync(file).size / 1024); + } + return kibibytes; +} + function stagePackage(root, arch, binary, icon, version = config.version) { if (!architectures[arch]) throw new Error(`Unsupported Linux architecture: ${arch}`); if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) throw new Error('Invalid package version'); @@ -15,8 +61,9 @@ function stagePackage(root, arch, binary, icon, version = config.version) { fs.chmodSync(executable, 0o755); const files = { 'usr/bin/markdown-viewer': '#!/bin/sh\nexec /usr/lib/markdown-viewer/markdown-viewer "$@"\n', - 'usr/share/applications/com.markdownviewer.desktop.desktop': `[Desktop Entry]\nType=Application\nName=Markdown Viewer\nComment=Edit and preview Markdown documents\nExec=markdown-viewer %F\nTryExec=markdown-viewer\nIcon=markdown-viewer\nTerminal=false\nCategories=Office;TextEditor;\nMimeType=text/markdown;text/x-markdown;\nStartupNotify=true\n`, - 'DEBIAN/control': `Package: markdown-viewer\nVersion: ${version.replace('-', '~')}\nSection: editors\nPriority: optional\nArchitecture: ${architectures[arch]}\nMaintainer: Markdown Viewer contributors \nHomepage: https://github.com/ThisIs-Developer/Markdown-Viewer\nDepends: libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, libgtk-3-0 (>= 3.24) | libgtk-3-0t64, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, libayatana-appindicator3-1 | libappindicator3-1, libx11-6, libxcb1, libxrandr2, libpng16-16 | libpng16-16t64\nDescription: Markdown editor and viewer with a local document vault\n Includes offline preview resources and native file dialogs.\n`, + [`usr/share/applications/${config.applicationId}.desktop`]: `[Desktop Entry]\nVersion=1.0\nType=Application\nName=${metadata.name}\nGenericName=Markdown Editor\nComment=${metadata.summary}\nExec=markdown-viewer %F\nTryExec=markdown-viewer\nIcon=markdown-viewer\nTerminal=false\nCategories=Office;TextEditor;\nKeywords=Markdown;Editor;Preview;Notes;Documents;\nMimeType=text/markdown;text/x-markdown;\nStartupNotify=true\n`, + [`usr/share/metainfo/${config.applicationId}.metainfo.xml`]: appStream(version), + 'DEBIAN/control': `Package: markdown-viewer\nVersion: ${version.replace('-', '~')}\nSection: editors\nPriority: optional\nArchitecture: ${architectures[arch]}\nMaintainer: ${metadata.maintainer}\nHomepage: ${metadata.homepage}\nDepends: libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, libgtk-3-0 (>= 3.24) | libgtk-3-0t64, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, libayatana-appindicator3-1 | libappindicator3-1, libx11-6, libxcb1, libxrandr2, libpng16-16 | libpng16-16t64\nDescription: ${metadata.name} - ${metadata.summary}\n ${metadata.description.join('\n .\n ')}\n .\n Publisher: ${metadata.developer}\n License: ${metadata.license}\n Website: ${metadata.homepage}\n Support: ${metadata.repository}/issues\n`, }; for (const [name, content] of Object.entries(files)) { const destination = path.join(root, name); @@ -27,10 +74,14 @@ function stagePackage(root, arch, binary, icon, version = config.version) { fs.mkdirSync(path.dirname(iconPath), { recursive: true }); fs.copyFileSync(icon, iconPath); fs.chmodSync(iconPath, 0o644); + const pixmap = path.join(root, 'usr/share/pixmaps/markdown-viewer.png'); + fs.mkdirSync(path.dirname(pixmap), { recursive: true }); + fs.copyFileSync(icon, pixmap); const licenses = path.join(root, 'usr/share/doc/markdown-viewer'); fs.mkdirSync(licenses, { recursive: true }); fs.copyFileSync(path.join(appDir, '../LICENSE'), path.join(licenses, 'copyright')); fs.copyFileSync(path.join(appDir, 'LICENSE'), path.join(licenses, 'neutralino-LICENSE')); + fs.appendFileSync(path.join(root, 'DEBIAN/control'), `Installed-Size: ${installedSize(root)}\n`); // Build hosts may use umask 077. Installed apps must be readable by all users. function normalizePermissions(directory) { fs.chmodSync(directory, 0o755); diff --git a/desktop-app/packaging/package-macos.sh b/desktop-app/packaging/package-macos.sh index 8349eb11..992ccb35 100644 --- a/desktop-app/packaging/package-macos.sh +++ b/desktop-app/packaging/package-macos.sh @@ -15,6 +15,7 @@ for arch in x64 arm64 universal; do python3 "$APP_DIR/packaging/verify-macos.py" --arch "$arch" "$INPUT" STAGING="$(mktemp -d)" trap 'rm -rf "$STAGING"' EXIT + chmod 755 "$STAGING" APP="$STAGING/Markdown Viewer.app" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$INPUT" "$APP/Contents/MacOS/markdown-viewer" @@ -29,6 +30,11 @@ for arch in x64 arm64 universal; do done iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/MarkdownViewer.icns" rm -rf "$ICONSET" + cp "$APP_DIR/../LICENSE" "$APP/Contents/Resources/LICENSE.txt" + cp "$APP_DIR/LICENSE" "$APP/Contents/Resources/Neutralino-LICENSE.txt" + cp "$APP_DIR/packaging/RELEASE-DOWNLOADS.md" "$STAGING/README.md" + cp "$APP/Contents/Resources/MarkdownViewer.icns" "$STAGING/.VolumeIcon.icns" + xcrun SetFile -a C "$STAGING" chmod -R u+rwX,go+rX "$APP" plutil -lint "$APP/Contents/Info.plist" # Resource embedding changes Mach-O bytes. Sign the FINAL bundle, never the input. diff --git a/desktop-app/packaging/release-manifest.js b/desktop-app/packaging/release-manifest.js index 9acf97ef..95cd8ef6 100644 --- a/desktop-app/packaging/release-manifest.js +++ b/desktop-app/packaging/release-manifest.js @@ -2,13 +2,15 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); -const expected = [ +const installerAssets = [ 'markdown-viewer-win_x64.exe', 'MACOS-SIGNING.txt', 'README.md', ...['x64', 'arm64', 'universal'].map(arch => `markdown-viewer-mac_${arch}.dmg`), ...['amd64', 'arm64', 'armhf'].map(arch => `markdown-viewer-linux_${arch}.deb`), - ...['x64', 'arm64', 'armhf'].map(arch => `markdown-viewer-linux_${arch}.tar.gz`), ].sort(); -function writeManifest(directory) { +function writeManifest(directory, { installersOnly = false } = {}) { + const expected = installersOnly ? installerAssets : [...installerAssets, + ...['x64', 'arm64', 'armhf'].map(arch => `markdown-viewer-linux_${arch}.tar.gz`), + ].sort(); const actual = fs.readdirSync(directory).filter(name => name !== 'SHA256SUMS.txt').sort(); if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error(`Unexpected release assets: ${actual.join(', ')}`); const checksums = expected.map(name => { @@ -22,7 +24,9 @@ function writeManifest(directory) { } if (require.main === module) { - if (process.argv.length !== 3) throw new Error('Usage: release-manifest.js '); - writeManifest(path.resolve(process.argv[2])); + if (process.argv.length < 3 || process.argv.length > 4 || (process.argv[3] && process.argv[3] !== '--installers-only')) { + throw new Error('Usage: release-manifest.js [--installers-only]'); + } + writeManifest(path.resolve(process.argv[2]), { installersOnly: process.argv[3] === '--installers-only' }); } module.exports = { writeManifest }; diff --git a/tests/native/desktop-package-smoke.py b/tests/native/desktop-package-smoke.py index 1d7d27b7..3ad44b8b 100644 --- a/tests/native/desktop-package-smoke.py +++ b/tests/native/desktop-package-smoke.py @@ -3,6 +3,7 @@ import json import os import pathlib +import plistlib import signal import subprocess import sys @@ -12,6 +13,15 @@ assert os.environ.get('GITHUB_ACTIONS') == 'true', 'Run only on a disposable GitHub Actions runner' application = pathlib.Path(sys.argv[1]).resolve() is_mac = sys.platform == 'darwin' +if is_mac: + contents = application / 'Contents' + with (contents / 'Info.plist').open('rb') as source: + metadata = plistlib.load(source) + assert metadata['CFBundleDisplayName'] == 'Markdown Viewer' + assert metadata['NSHumanReadableCopyright'] == 'Copyright 2024 ThisIs-Developer' + assert metadata['LSApplicationCategoryType'] == 'public.app-category.productivity' + assert (contents / 'Resources' / (metadata['CFBundleIconFile'] + '.icns')).stat().st_size > 0 + assert 'Apache License' in (contents / 'Resources/LICENSE.txt').read_text() vault = pathlib.Path.home() / 'Documents' / 'Markdown Viewer Vault' assert not vault.exists(), 'Refusing to touch a pre-existing vault' fixture_dir = pathlib.Path(tempfile.mkdtemp(prefix='markdown-viewer-smoke-')) diff --git a/tests/native/test_macos_load_commands.py b/tests/native/test_macos_load_commands.py index 9f906736..c1099e4b 100644 --- a/tests/native/test_macos_load_commands.py +++ b/tests/native/test_macos_load_commands.py @@ -77,6 +77,10 @@ def test_finder_metadata_keeps_the_intel_and_arm_requirements_separate(self): self.assertEqual(metadata['LSMinimumSystemVersionByArchitecture'], expected) self.assertEqual(metadata['CFBundleIdentifier'], 'com.markdownviewer.desktop') self.assertEqual(metadata['CFBundleExecutable'], 'markdown-viewer') + self.assertEqual(metadata['CFBundleIconFile'], 'MarkdownViewer') + self.assertEqual(metadata['NSHumanReadableCopyright'], 'Copyright 2024 ThisIs-Developer') + self.assertEqual(metadata['LSApplicationCategoryType'], 'public.app-category.productivity') + self.assertEqual(metadata['CFBundleGetInfoString'], 'Markdown Viewer 3.10.5, ThisIs-Developer, Apache-2.0') if __name__ == '__main__': diff --git a/tests/unit/desktop-packaging.test.mjs b/tests/unit/desktop-packaging.test.mjs index cdca10c5..214b6578 100644 --- a/tests/unit/desktop-packaging.test.mjs +++ b/tests/unit/desktop-packaging.test.mjs @@ -55,19 +55,37 @@ test('assembled checksums verify with the system tool and detect changed install } }); +test('recommended download keeps all CPU installers and excludes optional portable archives', t => { + const directory = releaseFiles(t); + for (const name of fs.readdirSync(directory).filter(name => name.endsWith('.tar.gz'))) { + fs.unlinkSync(path.join(directory, name)); + } + writeManifest(directory, { installersOnly: true }); + const manifest = fs.readFileSync(path.join(directory, 'SHA256SUMS.txt'), 'utf8'); + assert.equal(manifest.trim().split('\n').length, 9); + assert.doesNotMatch(manifest, /\.tar\.gz/); + for (const arch of ['amd64', 'arm64', 'armhf']) assert.match(manifest, new RegExp(`linux_${arch}\\.deb`)); + assert.throws(() => writeManifest(directory), /Unexpected release assets/); +}); + test('Debian packages preserve launcher permissions, desktop integration and runtime dependencies', t => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-package-')); t.after(() => fs.rmSync(temp, { recursive: true, force: true })); const binary = path.join(temp, 'input'); + const icon = new URL('../../assets/icon.png', import.meta.url); fs.writeFileSync(binary, 'binary fixture', { mode: 0o644 }); for (const [arch, expected] of Object.entries({ x64: 'amd64', arm64: 'arm64', armhf: 'armhf' })) { const root = path.join(temp, arch); - stagePackage(root, arch, binary, binary, '3.10.5-test.1'); + stagePackage(root, arch, binary, icon, '3.10.5-test.1'); const control = fs.readFileSync(path.join(root, 'DEBIAN/control'), 'utf8'); assert.match(control, new RegExp(`Architecture: ${expected}\\n`)); assert.match(control, /Version: 3\.10\.5~test\.1/); assert.match(control, /libwebkit2gtk-4\.1-0 \| libwebkit2gtk-4\.0-37/); assert.match(control, /libayatana-appindicator3-1 \| libappindicator3-1/); + assert.match(control, /Maintainer: Baivab Sarkar <109382325\+ThisIs-Developer@users.noreply.github.com>/); + assert.match(control, /Publisher: ThisIs-Developer\n License: Apache-2\.0/); + assert.match(control, /Homepage: https:\/\/markdownviewer\.pages\.dev\//); + assert.match(control, /Installed-Size: [1-9][0-9]*\n/); const desktop = fs.readFileSync(path.join(root, 'usr/share/applications/com.markdownviewer.desktop.desktop'), 'utf8'); assert.match(desktop, /Exec=markdown-viewer %F/); assert.match(desktop, /Terminal=false/); @@ -84,6 +102,28 @@ test('Debian packages preserve launcher permissions, desktop integration and run const extracted = path.join(temp, `${arch}-extracted`); execFileSync('dpkg-deb', ['--extract', deb, extracted]); assert.equal(fs.statSync(path.join(extracted, 'usr/lib/markdown-viewer/markdown-viewer')).mode & 0o777, 0o755); + for (const location of ['usr/share/icons/hicolor/256x256/apps', 'usr/share/pixmaps']) { + assert.deepEqual(fs.readFileSync(path.join(extracted, location, 'markdown-viewer.png')), fs.readFileSync(icon)); + } + const metainfo = path.join(extracted, 'usr/share/metainfo/com.markdownviewer.desktop.metainfo.xml'); + // Parse the extracted XML with an independent parser; verify desktop/icon links resolve. + execFileSync('python3', ['-c', ` +import pathlib, sys, xml.etree.ElementTree as ET +root = pathlib.Path(sys.argv[1]) +component = ET.parse(sys.argv[2]).getroot() +assert component.attrib['type'] == 'desktop-application' +assert component.findtext('id') == 'com.markdownviewer.desktop' +assert component.findtext('name') == 'Markdown Viewer' +assert component.findtext('developer_name') == 'ThisIs-Developer' +assert component.findtext('project_license') == 'Apache-2.0' +assert component.findtext('pkgname') == 'markdown-viewer' +desktop = root / 'usr/share/applications' / component.findtext('launchable') +assert desktop.is_file() +icon = component.findtext('icon') +assert 'Icon=' + icon in desktop.read_text() +assert (root / 'usr/share/icons/hicolor/256x256/apps' / (icon + '.png')).is_file() +assert component.find('releases/release').attrib['version'] == '3.10.5-test.1' +`, extracted, metainfo]); } } assert.throws(() => stagePackage(path.join(temp, 'invalid'), 'mips', binary, binary), /Unsupported/); diff --git a/tests/unit/windows-branding.test.mjs b/tests/unit/windows-branding.test.mjs index 00e35639..c1a50766 100644 --- a/tests/unit/windows-branding.test.mjs +++ b/tests/unit/windows-branding.test.mjs @@ -38,6 +38,10 @@ test('Windows packaging sets app/version metadata and preserves resources', asyn assert.equal(values.ProductName, 'Markdown Viewer'); assert.equal(values.FileDescription, 'Markdown Viewer'); assert.equal(values.OriginalFilename, 'Markdown-Viewer.exe'); + assert.equal(values.CompanyName, 'ThisIs-Developer'); + assert.equal(values.LegalCopyright, 'Copyright 2024 ThisIs-Developer'); + assert.match(values.Comments, /Apache-2\.0/); + assert.match(values.Comments, /https:\/\/markdownviewer\.pages\.dev\//); assert.equal(values.FileVersion, '3.10.5.0'); assert.deepEqual(Buffer.from(entries.find(entry => entry.type === 14 && entry.id === 1).bin), originalIcon); assert.deepEqual([...new Uint8Array(entries.find(entry => entry.type === 10).bin)], [1, 2, 3, 4]); From 302803d345b7642584d274c82e9b8fa6016a40c3 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:41:55 +0000 Subject: [PATCH 5/6] fix(linux): make corrected packages upgrade earlier installers --- desktop-app/packaging/README.md | 3 +++ desktop-app/packaging/package-linux.js | 4 +++- tests/unit/desktop-packaging.test.mjs | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop-app/packaging/README.md b/desktop-app/packaging/README.md index fc8a06fe..6ede3a1c 100644 --- a/desktop-app/packaging/README.md +++ b/desktop-app/packaging/README.md @@ -76,6 +76,9 @@ pixmap icons, AppStream component, maintainer, installed size and a useful contr description. The AppStream release date comes from `RELEASE_NOTES`, not the build date. Its legacy `developer_name` element supports Ubuntu 22.04's AppStream; newer validators may emit informational deprecation notices. +The DEB includes packaging revision `-1` (for example, `3.10.5-1`) so it upgrades +the earlier revision-less `3.10.5` package. The application's version stays 3.10.5; +subsequent packaging-only changes for the same app version must raise the revision. Mac bundles include the application icon, version, copyright, productivity category and license files; the mounted DMG has the application volume icon. diff --git a/desktop-app/packaging/package-linux.js b/desktop-app/packaging/package-linux.js index 551e7e05..45d5921a 100644 --- a/desktop-app/packaging/package-linux.js +++ b/desktop-app/packaging/package-linux.js @@ -6,6 +6,8 @@ const appDir = path.resolve(__dirname, '..'); const config = require('../neutralino.config.json'); const metadata = require('./metadata.json'); const architectures = { x64: 'amd64', arm64: 'arm64', armhf: 'armhf' }; +// Distinguish the corrected package from the earlier revision-less DEBs. +const packageRevision = 1; function xml(value) { return String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); @@ -63,7 +65,7 @@ function stagePackage(root, arch, binary, icon, version = config.version) { 'usr/bin/markdown-viewer': '#!/bin/sh\nexec /usr/lib/markdown-viewer/markdown-viewer "$@"\n', [`usr/share/applications/${config.applicationId}.desktop`]: `[Desktop Entry]\nVersion=1.0\nType=Application\nName=${metadata.name}\nGenericName=Markdown Editor\nComment=${metadata.summary}\nExec=markdown-viewer %F\nTryExec=markdown-viewer\nIcon=markdown-viewer\nTerminal=false\nCategories=Office;TextEditor;\nKeywords=Markdown;Editor;Preview;Notes;Documents;\nMimeType=text/markdown;text/x-markdown;\nStartupNotify=true\n`, [`usr/share/metainfo/${config.applicationId}.metainfo.xml`]: appStream(version), - 'DEBIAN/control': `Package: markdown-viewer\nVersion: ${version.replace('-', '~')}\nSection: editors\nPriority: optional\nArchitecture: ${architectures[arch]}\nMaintainer: ${metadata.maintainer}\nHomepage: ${metadata.homepage}\nDepends: libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, libgtk-3-0 (>= 3.24) | libgtk-3-0t64, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, libayatana-appindicator3-1 | libappindicator3-1, libx11-6, libxcb1, libxrandr2, libpng16-16 | libpng16-16t64\nDescription: ${metadata.name} - ${metadata.summary}\n ${metadata.description.join('\n .\n ')}\n .\n Publisher: ${metadata.developer}\n License: ${metadata.license}\n Website: ${metadata.homepage}\n Support: ${metadata.repository}/issues\n`, + 'DEBIAN/control': `Package: markdown-viewer\nVersion: ${version.replace('-', '~')}-${packageRevision}\nSection: editors\nPriority: optional\nArchitecture: ${architectures[arch]}\nMaintainer: ${metadata.maintainer}\nHomepage: ${metadata.homepage}\nDepends: libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, libgtk-3-0 (>= 3.24) | libgtk-3-0t64, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37, libayatana-appindicator3-1 | libappindicator3-1, libx11-6, libxcb1, libxrandr2, libpng16-16 | libpng16-16t64\nDescription: ${metadata.name} - ${metadata.summary}\n ${metadata.description.join('\n .\n ')}\n .\n Publisher: ${metadata.developer}\n License: ${metadata.license}\n Website: ${metadata.homepage}\n Support: ${metadata.repository}/issues\n`, }; for (const [name, content] of Object.entries(files)) { const destination = path.join(root, name); diff --git a/tests/unit/desktop-packaging.test.mjs b/tests/unit/desktop-packaging.test.mjs index 214b6578..ce4f7092 100644 --- a/tests/unit/desktop-packaging.test.mjs +++ b/tests/unit/desktop-packaging.test.mjs @@ -79,7 +79,7 @@ test('Debian packages preserve launcher permissions, desktop integration and run stagePackage(root, arch, binary, icon, '3.10.5-test.1'); const control = fs.readFileSync(path.join(root, 'DEBIAN/control'), 'utf8'); assert.match(control, new RegExp(`Architecture: ${expected}\\n`)); - assert.match(control, /Version: 3\.10\.5~test\.1/); + assert.match(control, /Version: 3\.10\.5~test\.1-1\n/); assert.match(control, /libwebkit2gtk-4\.1-0 \| libwebkit2gtk-4\.0-37/); assert.match(control, /libayatana-appindicator3-1 \| libappindicator3-1/); assert.match(control, /Maintainer: Baivab Sarkar <109382325\+ThisIs-Developer@users.noreply.github.com>/); @@ -99,6 +99,9 @@ test('Debian packages preserve launcher permissions, desktop integration and run const deb = path.join(temp, `${arch}.deb`); execFileSync('dpkg-deb', ['--root-owner-group', '--build', root, deb]); assert.equal(execFileSync('dpkg-deb', ['--field', deb, 'Architecture'], { encoding: 'utf8' }).trim(), expected); + const packageVersion = execFileSync('dpkg-deb', ['--field', deb, 'Version'], { encoding: 'utf8' }).trim(); + execFileSync('dpkg', ['--compare-versions', packageVersion, 'gt', '3.10.5~test.1']); + execFileSync('dpkg', ['--compare-versions', '3.10.5-1', 'gt', '3.10.5']); const extracted = path.join(temp, `${arch}-extracted`); execFileSync('dpkg-deb', ['--extract', deb, extracted]); assert.equal(fs.statSync(path.join(extracted, 'usr/lib/markdown-viewer/markdown-viewer')).mode & 0o777, 0o755); From 50da0aee0f0378a258824d27d678f8138562c8c0 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar <109382325+ThisIs-Developer@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:44:27 +0000 Subject: [PATCH 6/6] fix(linux): keep AppStream links compatible with Ubuntu 22.04 --- desktop-app/packaging/package-linux.js | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop-app/packaging/package-linux.js b/desktop-app/packaging/package-linux.js index 45d5921a..28a228af 100644 --- a/desktop-app/packaging/package-linux.js +++ b/desktop-app/packaging/package-linux.js @@ -37,7 +37,6 @@ ${metadata.description.map(paragraph => `

${xml(paragraph)}

`).join('\n ${xml(metadata.homepage)} ${xml(metadata.repository)}/issues ${xml(metadata.repository)}/blob/main/wiki/Desktop-App.md - ${xml(metadata.repository)} markdown-viewertext/markdowntext/x-markdown