Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,12 @@ jobs:

- name: Build
run: npm run build

- name: Package for Linux x64 (unpacked)
run: npx electron-builder --linux --x64 --dir --publish never

- name: Install Electron runtime libraries
run: sudo apt-get update && sudo apt-get install -y libgtk-3-0t64 libnss3 libgbm1 libasound2t64

- name: Verify packaged native modules
run: npm run verify:package -- --platform linux --arch x64
49 changes: 44 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,25 @@ jobs:
if: github.event.action == 'prereleased'
strategy:
matrix:
# Each arch builds on its own native runner: npm and Prisma only install the prebuilt
# binaries of the host arch, and electron-builder doesn't fetch those of another arch.
# There is no Windows arm64 entry because Prisma has no Windows arm64 engine.
include:
- os: macos-15-intel
platform: mac
arch: x64
- os: macos-latest
platform: mac
arch: arm64
- os: windows-latest
platform: win
arch: x64
- os: ubuntu-latest
platform: linux
arch: x64
- os: ubuntu-24.04-arm
platform: linux
arch: arm64

runs-on: ${{ matrix.os }}

Expand All @@ -33,8 +45,10 @@ jobs:
node-version: "20"
cache: "npm"

# phantomjs-prebuilt (via electron-icon-builder, dev-only) fails to install on linux-arm64.
# Nothing in the build needs an install script: Prisma and electron-builder fetch on demand.
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts

- name: Set version from tag
shell: bash
Expand All @@ -43,14 +57,22 @@ jobs:
npm pkg set version=$VERSION
echo "Set version to $VERSION"

# Uploading is left to the steps below, so nothing is published before it's verified.
- name: Build application
run: npm run build:${{ matrix.platform }}
run: npm run build:${{ matrix.platform }} -- --${{ matrix.arch }} --publish never
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

- name: Install Electron runtime libraries
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libgtk-3-0t64 libnss3 libgbm1 libasound2t64

- name: Verify packaged native modules
run: npm run verify:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}

- name: Submit notarization (async)
if: matrix.platform == 'mac'
continue-on-error: true
Expand Down Expand Up @@ -84,7 +106,7 @@ jobs:
- name: Save update manifests as artifacts
uses: actions/upload-artifact@v4
with:
name: update-manifests-${{ matrix.platform }}
name: update-manifests-${{ matrix.platform }}-${{ matrix.arch }}
path: release/*.yml
retention-days: 30

Expand Down Expand Up @@ -132,12 +154,29 @@ jobs:
echo "Remaining manifests:"
find manifests -type f -name "*.yml" | sort

# electron-updater reads one latest-mac.yml and picks the file for its arch from it.
- name: Merge macOS manifests
run: |
MERGED=manifests/update-manifests-mac/latest-mac.yml
mkdir -p "$(dirname "$MERGED")"
yq '.files += load("manifests/update-manifests-mac-arm64/latest-mac.yml").files' \
manifests/update-manifests-mac-x64/latest-mac.yml > "$MERGED"
rm -r manifests/update-manifests-mac-x64 manifests/update-manifests-mac-arm64

for arch in x64 arm64; do
if ! grep -q -- "-$arch.zip" "$MERGED"; then
echo "::error::$MERGED has no $arch zip"
exit 1
fi
done

- name: Validate required manifests
run: |
REQUIRED=(
manifests/update-manifests-win/latest.yml
manifests/update-manifests-win-x64/latest.yml
manifests/update-manifests-mac/latest-mac.yml
manifests/update-manifests-linux/latest-linux.yml
manifests/update-manifests-linux-x64/latest-linux.yml
manifests/update-manifests-linux-arm64/latest-linux-arm64.yml
)
for file in "${REQUIRED[@]}"; do
if [ ! -f "$file" ]; then
Expand Down
18 changes: 13 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ npm run dev # Run full dev environment (generates Prisma, sta
npm run build # Full production build (generates Prisma + builds Electron app)
npm run start # Preview production build (generates Prisma + previews Electron)

# Platform-specific builds (must run npm run build first)
npm run build:win # Build Windows NSIS installer (x64 + arm64)
npm run build:mac # Build macOS DMG (x64 + arm64)
npm run build:linux # Build Linux AppImage (x64 + arm64)
# Platform-specific builds, for the host architecture only (see "Native Modules and Packaging")
npm run build:win # Build Windows NSIS installer
npm run build:mac # Build macOS DMG
npm run build:linux # Build Linux AppImage
npm run verify:package # Check the packaged native modules (arch, presence, load test)

# Database
npm run generate # Generate Prisma client (required after schema changes)
Expand Down Expand Up @@ -277,7 +278,7 @@ The domain layer contains **only interfaces and pure business logic** with no ex
- `ci.yml` - Runs on push/PR to master: commit validation, typecheck, lint, test, build
- `changelog.yml` - Auto-updates CHANGELOG.md on release
- `draft-release.yml` - Creates draft release on tag push
- `release.yml` - Builds and uploads assets on release, publishes to npm
- `release.yml` - Builds each arch on a native runner, verifies and uploads assets on release, publishes to npm

### Adding New Features

Expand All @@ -297,6 +298,13 @@ For detailed instructions on adding new features, including step-by-step develop
- Linux: AppImage with PNG icons
- **Artifact Naming**: `${productName}-${version}-${arch}.${ext}` (e.g., `MarkPDFdown-1.0.0-x64.exe`)

### Native Modules and Packaging
- **Build on the target architecture**: sharp, `@napi-rs/canvas` and the Prisma engine ship as prebuilt binaries that `npm ci` and `prisma generate` install for the host only, and electron-builder does not fetch those of another arch. Packaging `--x64` on an arm64 machine produces an app that crashes at startup, so `release.yml` runs one job per arch on a native runner.
- **`npm run verify:package`** (`scripts/verify-package-arch.mjs`) checks an unpacked package: binary architectures, required modules, and, when the host can run the package, a real load test in the packaged Electron. Pass `-- --platform <mac|win|linux> --arch <x64|arm64>` for another target. CI fails on any problem.
- **New native dependency**: add it to `requiredModules` in the verify script and to `scripts/package-load-check.cjs`.
- **Windows is x64 only**: Prisma has no Windows arm64 engine, and an arm64 process can't load the x64 one. Windows on Arm runs the x64 build under emulation.
- **`latest-mac.yml`**: each mac job writes its own, and the `upload-manifests` job in `release.yml` merges them, x64 first.

### IPC API Reference

For complete IPC API documentation, see: **[docs/IPC_API.md](./docs/IPC_API.md)**
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@
"dev": "npm run generate && electron-vite dev",
"build": "npm run generate && electron-vite build",
"start": "npm run generate && electron-vite preview",
"build:mac": "npm run build && electron-builder --mac --x64 --arm64",
"build:win": "npm run build && electron-builder --win --x64 --arm64",
"build:linux": "npm run build && electron-builder --linux --x64 --arm64",
"build:mac": "npm run build && electron-builder --mac",
"build:win": "npm run build && electron-builder --win",
"build:linux": "npm run build && electron-builder --linux",
"verify:package": "node scripts/verify-package-arch.mjs",
"migrate:dev": "prisma migrate dev --schema=./src/core/infrastructure/db/schema.prisma",
"migrate:reset": "prisma migrate reset --schema=./src/core/infrastructure/db/schema.prisma",
"generate": "prisma generate --schema=./src/core/infrastructure/db/schema.prisma",
Expand Down Expand Up @@ -75,7 +76,8 @@
}
],
"asarUnpack": [
"**/*.{node,dll,metal,exp,lib}"
"**/*.{node,dll,metal,exp,lib}",
"**/node_modules/@img/**/*"
],
"directories": {
"buildResources": "assets",
Expand Down
199 changes: 199 additions & 0 deletions scripts/__tests__/verify-package-arch.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseBinaryHeader, resolvePackageLayout, verifyBinaries } from '../verify-package-arch.mjs';

const CPU_TYPE = { x64: 0x01000007, arm64: 0x0100000c };

function elf(machine) {
const buffer = Buffer.alloc(64);
buffer.write('\x7fELF', 0, 'latin1');
buffer[4] = 2; // 64-bit
buffer[5] = 1; // little endian
buffer.writeUInt16LE(machine, 18);
return buffer;
}

function macho(arch) {
const buffer = Buffer.alloc(32);
buffer.writeUInt32LE(0xfeedfacf, 0);
buffer.writeInt32LE(CPU_TYPE[arch], 4);
return buffer;
}

function fatMacho(archs) {
const buffer = Buffer.alloc(8 + archs.length * 20);
buffer.writeUInt32BE(0xcafebabe, 0);
buffer.writeUInt32BE(archs.length, 4);
archs.forEach((arch, i) => buffer.writeInt32BE(CPU_TYPE[arch], 8 + i * 20));
return buffer;
}

function pe(machine) {
const buffer = Buffer.alloc(0x100);
buffer.write('MZ', 0, 'latin1');
buffer.writeUInt32LE(0x80, 0x3c);
buffer.write('PE\0\0', 0x80, 'latin1');
buffer.writeUInt16LE(machine, 0x84);
return buffer;
}

describe('parseBinaryHeader', () => {
it.each([
['ELF x64', elf(62), { format: 'elf', archs: ['x64'] }],
['ELF arm64', elf(183), { format: 'elf', archs: ['arm64'] }],
['Mach-O x64', macho('x64'), { format: 'macho', archs: ['x64'] }],
['Mach-O arm64', macho('arm64'), { format: 'macho', archs: ['arm64'] }],
['fat Mach-O', fatMacho(['x64', 'arm64']), { format: 'macho', archs: ['x64', 'arm64'] }],
['PE x64', pe(0x8664), { format: 'pe', archs: ['x64'] }],
['PE arm64', pe(0xaa64), { format: 'pe', archs: ['arm64'] }],
['PE ia32', pe(0x14c), { format: 'pe', archs: ['ia32'] }],
])('recognises %s', (_name, header, expected) => {
expect(parseBinaryHeader(header)).toEqual(expected);
});

it('labels an unrecognised machine instead of treating the file as non-native', () => {
expect(parseBinaryHeader(elf(0x1234))).toEqual({ format: 'elf', archs: ['unknown(0x1234)'] });
});

it.each([
['text starting with MZ', Buffer.from('MZ is not a PE file, and this text is long enough to be read as one'.padEnd(0x100))],
['Java class file (same magic as fat Mach-O)', Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 61])],
['JavaScript', Buffer.from('module.exports = {};\n')],
['a file shorter than a header', Buffer.from('hi')],
])('ignores %s', (_name, content) => {
expect(parseBinaryHeader(content)).toBeNull();
});
});

describe('resolvePackageLayout', () => {
const names = { releaseDir: 'release', productName: 'MarkPDFdown', executableName: 'markpdfdown' };

it.each([
['mac', 'x64', 'release/mac/MarkPDFdown.app/Contents/Resources', 'release/mac/MarkPDFdown.app/Contents/MacOS/MarkPDFdown'],
['mac', 'arm64', 'release/mac-arm64/MarkPDFdown.app/Contents/Resources', 'release/mac-arm64/MarkPDFdown.app/Contents/MacOS/MarkPDFdown'],
['linux', 'x64', 'release/linux-unpacked/resources', 'release/linux-unpacked/markpdfdown'],
['linux', 'arm64', 'release/linux-arm64-unpacked/resources', 'release/linux-arm64-unpacked/markpdfdown'],
['win', 'x64', 'release/win-unpacked/resources', 'release/win-unpacked/MarkPDFdown.exe'],
])('finds the %s-%s package', (platform, arch, resourcesDir, executable) => {
const layout = resolvePackageLayout({ ...names, platform, arch });
expect(layout.resourcesDir.split(path.sep).join('/')).toBe(resourcesDir);
expect(layout.executable.split(path.sep).join('/')).toBe(executable);
});
});

describe('verifyBinaries', () => {
let resourcesDir;
let executable;

const put = (relativePath, content) => {
const filePath = path.join(resourcesDir, relativePath);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
};

/** The native files of an app package, with one header per file. */
const putModules = (target, header) => {
put(`app.asar.unpacked/node_modules/@img/sharp-${target}/lib/sharp-${target}.node`, header);
put(`app.asar.unpacked/node_modules/@img/sharp-libvips-${target}/lib/libvips-cpp.8.17.3.dylib`, header);
put(`app.asar.unpacked/node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-${target}/skia.${target}.node`, header);
put('node_modules/.prisma/client/libquery_engine-darwin.dylib.node', header);
};

const verifyMac = (arch) => verifyBinaries({ resourcesDir, executable, platform: 'mac', arch });

beforeEach(() => {
resourcesDir = mkdtempSync(path.join(tmpdir(), 'verify-package-arch-'));
executable = path.join(resourcesDir, 'MarkPDFdown');
writeFileSync(executable, macho('x64'));
});

afterEach(() => {
rmSync(resourcesDir, { recursive: true, force: true });
});

it('accepts a package whose binaries all match', () => {
putModules('darwin-x64', macho('x64'));
put('app.asar.unpacked/node_modules/@img/sharp-darwin-x64/package.json', '{}');

const { problems, binaries } = verifyMac('x64');

expect(problems).toEqual([]);
expect(binaries).toHaveLength(5);
});

it('rejects the package from issue #77: an x64 app holding only arm64 modules', () => {
putModules('darwin-arm64', macho('arm64'));

const { problems } = verifyMac('x64');

expect(problems.filter((p) => p.startsWith('Wrong architecture'))).toHaveLength(4);
expect(problems).toEqual(
expect.arrayContaining([
'Missing native module for mac-x64: sharp',
'Missing native module for mac-x64: libvips',
'Missing native module for mac-x64: @napi-rs/canvas',
]),
);
});

it('rejects a stray binary of another arch, even when all required modules are fine', () => {
putModules('darwin-x64', macho('x64'));
put('app.asar.unpacked/node_modules/@prisma/engines/schema-engine-darwin-arm64', macho('arm64'));

const { problems } = verifyMac('x64');

expect(problems).toEqual([
'Wrong architecture: app.asar.unpacked/node_modules/@prisma/engines/schema-engine-darwin-arm64 is macho/arm64, expected macho/x64',
]);
});

it('rejects the right arch in the wrong binary format', () => {
putModules('darwin-x64', macho('x64'));
put('app.asar.unpacked/node_modules/some-module/native.node', elf(62));

expect(verifyMac('x64').problems).toHaveLength(1);
});

it('accepts a universal binary that contains the target arch', () => {
putModules('darwin-x64', fatMacho(['x64', 'arm64']));

expect(verifyMac('x64').problems).toEqual([]);
});

it('rejects a package whose Electron executable has the wrong arch', () => {
putModules('darwin-arm64', macho('arm64'));

const { problems } = verifyMac('arm64');

expect(problems).toEqual([expect.stringMatching(/^Wrong architecture: MarkPDFdown is macho\/x64/)]);
});

it('reports a missing Electron executable', () => {
putModules('darwin-x64', macho('x64'));
rmSync(executable);

expect(verifyMac('x64').problems).toEqual([`Missing file: ${executable}`]);
});

it('knows libvips lives in the sharp package on Windows', () => {
writeFileSync(executable, pe(0x8664));
put('app.asar.unpacked/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node', pe(0x8664));
put('app.asar.unpacked/node_modules/@img/sharp-win32-x64/lib/libvips-42.dll', pe(0x8664));
put('app.asar.unpacked/node_modules/@napi-rs/canvas-win32-x64-msvc/skia.win32-x64-msvc.node', pe(0x8664));
put('node_modules/.prisma/client/query_engine-windows.dll.node', pe(0x8664));

expect(verifyBinaries({ resourcesDir, executable, platform: 'win', arch: 'x64' }).problems).toEqual([]);
});

it('finds the versioned libvips on Linux', () => {
writeFileSync(executable, elf(183));
put('app.asar.unpacked/node_modules/@img/sharp-linux-arm64/lib/sharp-linux-arm64.node', elf(183));
put('app.asar.unpacked/node_modules/@img/sharp-libvips-linux-arm64/lib/libvips-cpp.so.8.17.3', elf(183));
put('app.asar.unpacked/node_modules/@napi-rs/canvas-linux-arm64-gnu/skia.linux-arm64-gnu.node', elf(183));
put('node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so.node', elf(183));

expect(verifyBinaries({ resourcesDir, executable, platform: 'linux', arch: 'arm64' }).problems).toEqual([]);
});
});
Loading
Loading