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
1 change: 1 addition & 0 deletions .claude/rules/mac-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ paths:
- Formatting presets exist in THREE places that must match in name and order: `getFormattingPresets()` in `main/preferences.ts`, and twice in `renderer/js/preferences.js` (`applyPreset`, `updatePresetButtons`), plus the buttons in `preferences.html`. A test enforces this, and that no two presets are identical (a duplicate can never show as selected).
- Dropped paths come from `window.subzilla.getPathForFile(file)` (`File.path` is gone in newer Electron). Folder expansion lives in `main/files.ts`: skips hidden entries, symlinks, `.subzilla.` outputs, VobSub `.sub`+`.idx` pairs, and `.txt` inside folders.
- Renderer behaviour is tested by executing the real script against a stub DOM (`__tests__/renderer/`), not by grepping source.
- Launch the dev app only through `yarn workspace @subzilla/mac dev` / `start`: they run `scripts/sign-dev-electron.js` first. The stock dev binary gets flagged as malware and deleted by macOS (see CLAUDE.md).
- Packaging: config is `electron-builder.yml` only; `scripts/adhoc-sign.js` must stay wired as `afterPack`. `electron-store` must stay < 9 (9+ is ESM-only; main is CommonJS) and `electron-builder` stays on 24 until migrated deliberately.
- Releasing is a manual procedure: use the `/releasing-mac-app` skill.
2 changes: 1 addition & 1 deletion .claude/skills/releasing-mac-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Copy this checklist and tick items as they complete. Stop and report at the firs

## Rules

- Step 4 must print `RESULT: OK`. A bundle with an invalid signature is reported by macOS as "contains malware" and deleted on launch. Never publish or launch a build that fails it.
- Step 4 must print `RESULT: OK`. A bundle that still carries stock Electron's signature (`Identifier=Electron`) is reported by macOS as "contains malware" and deleted on launch. Never publish or launch a build that fails it.
- Steps 7 and 10 are outward-facing and hard to undo: get explicit confirmation each time. Never publish the draft yourself.
- Do not claim the app "works" from packaging alone. Only step 5 (a human launching it) establishes that.
- The app is ad-hoc signed, not notarized. Release notes must tell users to approve it once under System Settings → Privacy & Security, and that auto-update cannot install unsigned updates.
Expand Down
12 changes: 7 additions & 5 deletions .claude/skills/releasing-mac-app/scripts/verify-mac-bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ apps=(); for d in "$dist"/mac*/Subzilla.app; do [ -d "$d" ] && apps+=("$d"); don

echo "== 1. code signatures"
for app in "${apps[@]}"; do
if out="$(codesign --verify --deep --strict "$app" 2>&1)"; then
echo " ok ${app#$dist/} ($(codesign -dv "$app" 2>&1 | sed -n 's/^Identifier=//p'))"
id="$(codesign -dv "$app" 2>&1 | sed -n 's/^Identifier=//p')"
if ! out="$(codesign --verify --deep --strict "$app" 2>&1)"; then
echo " FAIL ${app#$dist/}: $out"; fail=1
elif [ "$id" = "Electron" ]; then
# Stock Electron's code hash: macOS reports it as 'contains malware' and deletes the app
echo " FAIL ${app#$dist/}: still signed as stock Electron. Is scripts/adhoc-sign.js wired as afterPack?"; fail=1
else
echo " FAIL ${app#$dist/}: $out"
echo " macOS reports such a bundle as 'contains malware' and deletes it. Is scripts/adhoc-sign.js still wired as afterPack?"
fail=1
echo " ok ${app#$dist/} ($id)"
fi
done

Expand Down
4 changes: 3 additions & 1 deletion .claude/skills/reviewing-dependency-updates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ yarn workspace @subzilla/mac build

It checks the code signature, the Electron download against Electron's official SHA-256, and every bundled package against its npm tarball, without launching anything. `RESULT: OK` is required before anyone launches the build.

If macOS ever reports the app as malware: do not launch anything else. Run the script first. An invalid signature (not a compromised package) caused this once already; the script distinguishes the two.
If macOS ever reports the app (or `Electron.app`) as malware: do not launch anything else, and run the script first. It has happened here, and it was NOT a compromised package: macOS flags anything still carrying stock Electron 31.7.7's code hash, and re-signing fixes it. The script tells the two apart (authentic download + identical-to-npm packages + `Identifier=Electron` means "not re-signed", not "tampered").

An Electron version bump changes that stock hash. After one, confirm both the packaged app and `yarn workspace @subzilla/mac dev` still launch.

## 5. Report

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,11 @@ jobs:
- name: Verify the code signatures
working-directory: packages/mac/dist-electron
run: |
# An invalid signature makes macOS report the app as malware and delete it
# The bundle must be RE-SIGNED: one still carrying stock Electron's code hash
# is reported by macOS as malware and deleted (see scripts/adhoc-sign.js)
for app in mac/Subzilla.app mac-arm64/Subzilla.app; do
codesign --verify --deep --strict --verbose=2 "$app"
codesign -dv "$app" 2>&1 | grep -q '^Identifier=net.onyxdev.subzilla$' || { echo "::error::$app still has stock Electron's signature"; exit 1; }
done

- name: Verify the bundles
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,5 @@ yarn workspace @subzilla/mac build # package the a
- Adding a strip option touches ~10 places: `IStripOptions`, Zod schema, `ConfigManager` (`KNOWN_PROPERTIES` + defaults), CLI `options.ts` + `strip-options.ts` + `IStripCommandOptions`, mac `preferences.ts` (schema, defaults, presets), `preferences.html`, `preferences.js` (element, listener list, load, save, presets ×2), READMEs. `git grep -n bidiControl` lists them all.
- In the mac app, electron-store defaults override `.subzillarc` values (open product question — do not "fix" silently).
- `packages/mac/electron-builder.yml` is the only builder config. Never re-add a `"build"` field to `packages/mac/package.json`: it silently shadows the yml.
- The app is ad-hoc signed by `scripts/adhoc-sign.js`. An invalid signature makes macOS report "contains malware" and delete the bundle. Before launching any build: `codesign --verify --deep --strict <app>`.
- macOS reports anything carrying STOCK Electron 31.7.7's code hash as "contains malware" and deletes it — even a pristine, checksum-verified `Electron.app`. Re-signing ad hoc gives it a new hash and it runs. So the packaged app is re-signed by `scripts/adhoc-sign.js` (afterPack) and the dev binary by `scripts/sign-dev-electron.js` (runs before `dev`/`start`). Never launch `node_modules/electron/dist/Electron.app` directly. Before launching any build: `codesign -dv <app>` must NOT say `Identifier=Electron`. (The "code has no resources…" message from `codesign --verify` on stock Electron is normal and was a red herring.)
- macOS has no `timeout` command. When checking that a launched process is alive, use its PID (`kill -0 $PID`); `pgrep -f <path>` matches your own shell command.
4 changes: 2 additions & 2 deletions packages/mac/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"main": "dist/main/index.js",
"scripts": {
"build": "tsc && yarn run copy-renderer && electron-builder",
"dev": "tsc && yarn run copy-renderer && electron .",
"start": "electron dist/main/index.js",
"dev": "tsc && yarn run copy-renderer && node scripts/sign-dev-electron.js && electron .",
"start": "node scripts/sign-dev-electron.js && electron dist/main/index.js",
"copy-renderer": "mkdir -p dist/renderer && cp -r src/renderer/* dist/renderer/",
"icon": "node scripts/gen-icon.mjs",
"dist": "electron-builder",
Expand Down
28 changes: 16 additions & 12 deletions packages/mac/scripts/adhoc-sign.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
// electron-builder "afterPack" hook.
// electron-builder "afterPack" hook: re-sign the bundle ad hoc.
//
// Without a Developer ID certificate electron-builder skips code signing and
// leaves Electron's ORIGINAL ad-hoc signature on a bundle whose contents it has
// just changed (Info.plist, icon, app.asar). That signature no longer matches:
// Without a Developer ID certificate electron-builder skips code signing, so the
// app ships with STOCK Electron's executable and signature (Identifier=Electron).
//
// codesign --verify: "code has no resources but signature indicates they must be present"
// Observed on macOS 26: a bundle carrying stock Electron 31.7.7's code hash is
// reported as "was not opened because it contains malware" and deleted - even a
// pristine Electron.app whose download matches Electron's published SHA-256.
// Stock 31.0.0 is not flagged; any re-signed 31.7.7 runs fine (7 of 7 launches).
// The discriminator is the executable's CDHash, which re-signing replaces. Most
// likely Apple blocklists that stock hash because real malware ships inside
// unmodified Electron binaries - that last step is inference, the rest is measured.
//
// Apple Silicon refuses to run code with an invalid signature, and macOS reports
// it as: "Subzilla.app was not opened because it contains malware" — then deletes
// the app. Re-signing ad hoc ("-") makes the signature valid again. It is not a
// substitute for a Developer ID (Gatekeeper still asks users to approve the app
// once), but the app is no longer treated as tampered.
// NOT the cause, despite looking like it: `codesign --verify` on stock Electron
// says "code has no resources but signature indicates they must be present". Every
// stock Electron ships like that, including versions that run without complaint.
//
// When a real certificate is configured (CSC_LINK / CSC_NAME), electron-builder
// signs after this hook and simply replaces the ad-hoc signature.
// Re-signing is not a substitute for a Developer ID (Gatekeeper still asks users
// to approve the app once). With a real certificate configured (CSC_LINK /
// CSC_NAME), electron-builder signs after this hook and replaces the signature.
const { execFileSync } = require('child_process');
const path = require('path');

Expand Down
40 changes: 40 additions & 0 deletions packages/mac/scripts/sign-dev-electron.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Re-sign the development Electron binary ad hoc before `electron .` runs.
//
// Observed on macOS 26 with Electron 31.7.7: launching the STOCK Electron binary
// makes macOS report "Electron.app was not opened because it contains malware"
// and delete it. The download is authentic (it matches Electron's published
// SHA-256). Stock 31.0.0 is not flagged, and any re-signed 31.7.7 runs fine.
// The consistent discriminator is the executable's code hash (CDHash): re-signing
// gives the bundle a new one. Most likely Apple blocklists that stock hash because
// real malware ships inside unmodified Electron binaries; that part is inference.
//
// Idempotent and quick; also restores the binary if macOS already removed it.
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');

if (process.platform !== 'darwin') process.exit(0);

const electronDir = path.dirname(require.resolve('electron/package.json'));
const appPath = path.join(electronDir, 'dist', 'Electron.app');
const binary = path.join(appPath, 'Contents', 'MacOS', 'Electron');

if (!fs.existsSync(binary)) {
console.log(' • dev Electron binary is missing (macOS may have removed it) - reinstalling');
fs.rmSync(path.join(electronDir, 'dist'), { recursive: true, force: true });
fs.rmSync(path.join(electronDir, 'path.txt'), { force: true });
execFileSync(process.execPath, [path.join(electronDir, 'install.js')], { stdio: 'inherit' });
}

// `codesign -dv` reports on stderr
const info = spawnSync('codesign', ['-dv', appPath], { encoding: 'utf8' }).stderr || '';

if (/^Identifier=net\.onyxdev\.subzilla\.dev$/m.test(info)) {
process.exit(0); // already re-signed
}

execFileSync('codesign', ['--force', '--deep', '--sign', '-', '--identifier', 'net.onyxdev.subzilla.dev', appPath], {
stdio: 'inherit',
});
execFileSync('codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' });
console.log(' • dev Electron re-signed ad hoc (net.onyxdev.subzilla.dev)');
Loading