diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 91c67616..9700ad63 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -1,76 +1,268 @@ -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 - 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 + repository: neutralinojs/neutralinojs + ref: d1b3a4050f2073f236e14c4746ee8aa7dbb9bfe5 + path: desktop-app/.macos-runtime + persist-credentials: false + - 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 + - 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: + name: desktop-macos + path: desktop-app/dist/installers/* + if-no-files-found: error + retention-days: 30 + - name: Remove temporary signing credentials + if: always() + run: | + 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" - - name: Stage release assets - working-directory: desktop-app + 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 appstream + desktop-file-validate /usr/share/applications/com.markdownviewer.desktop.desktop + - name: Validate the installed application metadata and icon integration run: | - STAGING="release-assets" - mkdir -p "$STAGING" + 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 - # 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-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 backwards-compatible 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" + 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" + 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" - # SHA256 checksums - cd "$STAGING" - sha256sum * > SHA256SUMS.txt + 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 + - 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 - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + release: + if: startsWith(github.ref, 'refs/tags/') && github.event_name == 'push' + 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: + name: desktop-release + path: 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 }}" + 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..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. -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`. 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 @@ -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/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 new file mode 100644 index 00000000..6ede3a1c --- /dev/null +++ b/desktop-app/packaging/README.md @@ -0,0 +1,176 @@ +# 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` 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. 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 + +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. + +## 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. +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. +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`): + +```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 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 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 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; 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 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. + +## 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..02c7b626 --- /dev/null +++ b/desktop-app/packaging/RELEASE-DOWNLOADS.md @@ -0,0 +1,48 @@ +### 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. + +**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` | +| 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:** 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. + +**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 +`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 new file mode 100644 index 00000000..26366d3b --- /dev/null +++ b/desktop-app/packaging/UNSIGNED-MACOS.txt @@ -0,0 +1,19 @@ +This build is locally (ad-hoc) signed, not Apple Developer ID signed or notarized. +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. +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. +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 the application. diff --git a/desktop-app/packaging/build-macos-runtime.sh b/desktop-app/packaging/build-macos-runtime.sh new file mode 100644 index 00000000..f36e37f5 --- /dev/null +++ b/desktop-app/packaging/build-macos-runtime.sh @@ -0,0 +1,46 @@ +#!/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 + +# 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 +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" +# 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="$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" --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..cab1515a --- /dev/null +++ b/desktop-app/packaging/macos_support.py @@ -0,0 +1,55 @@ +"""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 +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'} +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': 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, + '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/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 new file mode 100644 index 00000000..28a228af --- /dev/null +++ b/desktop-app/packaging/package-linux.js @@ -0,0 +1,115 @@ +#!/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 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]); +} + +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 + 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'); + 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/${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('-', '~')}-${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); + 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 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); + 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..992ccb35 --- /dev/null +++ b/desktop-app/packaging/package-macos.sh @@ -0,0 +1,73 @@ +#!/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" --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" + chmod 755 "$APP/Contents/MacOS/markdown-viewer" + 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 + 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" + 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. + 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 -fs HFS+ -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. 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/release-manifest.js b/desktop-app/packaging/release-manifest.js new file mode 100644 index 00000000..95cd8ef6 --- /dev/null +++ b/desktop-app/packaging/release-manifest.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +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`), +].sort(); +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 => { + 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 || 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/desktop-app/packaging/verify-macos.py b/desktop-app/packaging/verify-macos.py new file mode 100644 index 00000000..49e67549 --- /dev/null +++ b/desktop-app/packaging/verify-macos.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Check every Mach-O slice, including embedded-resource outputs, before shipping.""" +import pathlib +import struct +import argparse +from macos_support import CPU_ARCHITECTURES, DEPLOYMENT_TARGETS, PACKAGE_ARCHITECTURES, packed_version + + +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(' 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-')) +fixture = fixture_dir / 'Packaging 日本 test.md' +body = '# Desktop packaging smoke test\n\nNative import and persistent vault: 日本 ✓\n' +fixture.write_text(body) +executable = application / 'Contents/MacOS/markdown-viewer' if is_mac else application +log = open(fixture_dir / 'runtime.log', 'w') + + +def app_pids(): + if not is_mac: + return [process.pid] if process.poll() is None else [] + listing = subprocess.run(['pgrep', '-f', str(executable)], capture_output=True, text=True) + return [int(pid) for pid in listing.stdout.split()] + + +def start(arguments): + command = ['open', '-W', '-n', str(application), '--args', *arguments] if is_mac else [str(executable), *arguments] + return subprocess.Popen(command, cwd=fixture_dir, stdout=log, stderr=subprocess.STDOUT) + + +def stop(): + for pid in app_pids(): + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + process.wait(timeout=20) + + +process = start([str(fixture)]) +try: + stored = None + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(f'GUI exited before creating its vault: {process.returncode}') + try: + index = json.loads((vault / '.markdown-viewer/index.json').read_text()) + for document in index['documents']: + candidate = vault / document['vaultRelativePath'] + if candidate.read_text() == body: + stored = candidate + break + except (FileNotFoundError, KeyError, json.JSONDecodeError): + pass + if stored: + break + time.sleep(0.5) + assert stored, 'Native GUI did not persist the launch-file content into the vault' + assert app_pids(), 'Native GUI is not running' + print('PASS: native GUI imported Unicode Markdown and created the real Documents vault', flush=True) + stop() + process = start([]) + time.sleep(10) + assert process.poll() is None and app_pids(), 'Native GUI failed to reopen' + assert stored.read_text() == body, 'Persisted document changed after relaunch' + index = json.loads((vault / '.markdown-viewer/index.json').read_text()) + assert any(vault / item['vaultRelativePath'] == stored for item in index['documents']) + print('PASS: native GUI relaunched with the same indexed document and bytes', flush=True) +finally: + try: + stop() + finally: + log.close() + print((fixture_dir / 'runtime.log').read_text()) diff --git a/tests/native/macos-filesystem-probe.cpp b/tests/native/macos-filesystem-probe.cpp new file mode 100644 index 00000000..0bda7cc5 --- /dev/null +++ b/tests/native/macos-filesystem-probe.cpp @@ -0,0 +1,6 @@ +// Neutralino's vault/file APIs use the same Apple libc++ filesystem dependency. +#include + +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 new file mode 100644 index 00000000..c1099e4b --- /dev/null +++ b/tests/native/test_macos_load_commands.py @@ -0,0 +1,87 @@ +import importlib.util +import pathlib +import plistlib +import struct +import sys +import unittest + +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) + + +def macho(minimum, cpu=0x1000007, framework=False): + commands = 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_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_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.verify(universal(macho(0x0a0f00)), 'universal') + + def test_rejects_an_architecture_mislabeled_as_another_download(self): + with self.assertRaises(AssertionError): + verifier.verify(macho(0x0b0000, 0x100000c), 'x64') + with self.assertRaises(AssertionError): + 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(' 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('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, 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-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>/); + 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/); + 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 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); + 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]); diff --git a/wiki/Desktop-App.md b/wiki/Desktop-App.md index d7c4a3c1..037af385 100644 --- a/wiki/Desktop-App.md +++ b/wiki/Desktop-App.md @@ -116,11 +116,14 @@ 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 at their native dependency baselines: Intel 10.15 and +Apple Silicon 11. 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 +176,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. + Native builds target Intel macOS 10.15 and Apple Silicon macOS 11, with no + upper-version cutoff. Install available Safari/WebKit updates; older OS versions + require manual acceptance (CI tests macOS 15/26). 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..b31a738a 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 rebuild the runtime for older Intel and Apple Silicon systems; 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.