Skip to content

feat: add manual install fallback for unsigned macOS auto-updates#29

Merged
zortos293 merged 1 commit intomainfrom
capy/mac-unsigned-update-fallback
Mar 14, 2026
Merged

feat: add manual install fallback for unsigned macOS auto-updates#29
zortos293 merged 1 commit intomainfrom
capy/mac-unsigned-update-fallback

Conversation

@zortos293
Copy link
Owner

@zortos293 zortos293 commented Mar 14, 2026

What Changed

  • Added isMacDeveloperIdSignedBuild() in apps/desktop/src/main.ts to detect Developer ID signing at runtime using codesign and cache the result.
  • Created apps/desktop/src/macCodeSigning.ts with hasDeveloperIdApplicationAuthority() to parse code-sign output and verify Developer ID authority.
  • Created apps/desktop/src/macUpdateInstaller.ts with:
    • buildMacManualUpdateInstallScript() to generate a detached installer shell script that waits for app exit, replaces the bundle via ditto, prompts for admin if needed, and relaunches.
    • findFirstAppBundlePath() to recursively find an .app bundle in the extracted update directory.
    • resolveDownloadedMacUpdateZipPath() to locate the downloaded update .zip from electron-updater's file list.
  • Modified installDownloadedUpdate() in apps/desktop/src/main.ts to branch at install time: signed builds continue using autoUpdater.quitAndInstall(), while unsigned builds use the new manual fallback path.
  • Added setDownloadedUpdateFiles() and clearDownloadedUpdateFiles() to retain downloaded file paths for manual install.
  • Removed the macOS signing gate from getAutoUpdateDisabledReason() in apps/desktop/src/updateState.ts so updates remain enabled for unsigned builds.
  • Added tests in apps/desktop/src/macCodeSigning.test.ts and apps/desktop/src/macUpdateInstaller.test.ts.

Why

Electron's autoUpdater.quitAndInstall() on macOS uses Squirrel.Mac, which requires a signed Developer ID application to install without blocking. Unsigned DMG installs (e.g., local test builds opened via Gatekeeper's "Open Anyway") would download updates but fail silently when clicked to install, providing no restart or installation feedback. This PR provides a working update flow for unsigned builds by extracting the downloaded .zip and replacing the app bundle via ditto from a detached installer script, preserving the signed-build behavior using the native Squirrel path.

UI Changes

None (backend updater behavior only).

Open in Capy TC-9 · 5.4

Summary by CodeRabbit

Release Notes

  • New Features

    • Added macOS code-signing verification for desktop application.
    • Implemented manual update installation fallback for unsigned macOS builds.
  • Tests

    • Added comprehensive test coverage for macOS code-signing detection and update installation utilities.

@zortos293 zortos293 added the capy Generated by capy.ai label Mar 14, 2026 — with Capy AI
@coderabbitai
Copy link

coderabbitai bot commented Mar 14, 2026

📝 Walkthrough

Walkthrough

This PR introduces macOS code signing validation and manual update installation capabilities for unsigned builds. New modules add utility functions for validating Developer ID Application signatures, resolving downloaded update files, discovering app bundles, and generating shell scripts for manual updates. Integration into main.ts tracks downloaded update artifacts, detects unsigned builds, and routes unsigned macOS updates through a new manual installation path instead of the standard auto-update flow, with fallback to standard installation on failure.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a manual install fallback for unsigned macOS auto-updates, which is the core objective of this PR.
Description check ✅ Passed The description comprehensively covers what changed, why it was needed, lists all major files/functions added, and confirms no UI changes. It follows the template structure with clear sections.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can approve the review once all CodeRabbit's comments are resolved.

Enable the reviews.request_changes_workflow setting to automatically approve the review once all CodeRabbit's comments are resolved.

Copy link

@capy-ai capy-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 1 comment

}

install_update() {
/bin/rm -rf "$TARGET_APP"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium]

The generated installer removes the currently installed bundle before verifying the new bundle has been copied successfully, so a copy failure (for example disk full or I/O error during ditto) can leave users with no runnable app at the target path. This creates a real failure-mode regression in the unsigned-update path. Use a two-phase replace: copy to a temporary sibling path, verify success, then atomically swap (or move) into place and only then remove the old bundle. ```sh

apps/desktop/src/macUpdateInstaller.ts

install_update() {
/bin/rm -rf "$TARGET_APP"
/usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/macUpdateInstaller.ts`:
- Around line 71-84: The install_update routine currently removes TARGET_APP
before copying, risking loss if ditto fails; change install_update (and the
osascript fallback) to copy SOURCE_APP into a sibling temporary bundle (e.g.,
TARGET_APP+".tmp" or use mktemp) first, verify the copy completed successfully,
then atomically rename/move the temp bundle over TARGET_APP (or use /bin/mv) to
replace it; keep TARGET_APP until the move succeeds so the old app is available
for rollback, and ensure wait_for_app_exit is still invoked before the final
swap; update references to SOURCE_APP and TARGET_APP in both the shell and
osascript paths accordingly.

In `@apps/desktop/src/main.ts`:
- Around line 901-906: The current branch calls
installDownloadedUnsignedMacUpdate() after backend shutdown and update-polling
was cleared, which can throw and leave the app half-shutdown; preflight the
unsigned installer instead (i.e., run the installer lookup/extract/bundle
discovery/script write/spawn steps that installDownloadedUnsignedMacUpdate()
performs) before shutting down backend and clearing polling, or wrap the call in
a try/catch that on error restarts the backend and re-enables polling before
rethrowing/logging; update the code paths around
installDownloadedUnsignedMacUpdate(), autoUpdater.quitAndInstall(), and
app.quit() so the installer preflight occurs safely or the system recovers on
failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a595743f-0d01-49a1-b5d7-48f7fa990172

📥 Commits

Reviewing files that changed from the base of the PR and between b974cea and ba32b36.

📒 Files selected for processing (5)
  • apps/desktop/src/macCodeSigning.test.ts
  • apps/desktop/src/macCodeSigning.ts
  • apps/desktop/src/macUpdateInstaller.test.ts
  • apps/desktop/src/macUpdateInstaller.ts
  • apps/desktop/src/main.ts

Comment on lines +71 to +84
install_update() {
/bin/rm -rf "$TARGET_APP"
/usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
}

wait_for_app_exit

if ! install_update >/dev/null 2>&1; then
export SOURCE_APP TARGET_APP
/usr/bin/osascript <<'APPLESCRIPT'
set sourceApp to system attribute "SOURCE_APP"
set targetApp to system attribute "TARGET_APP"
do shell script "/bin/rm -rf " & quoted form of targetApp & " && /usr/bin/ditto " & quoted form of sourceApp & " " & quoted form of targetApp with administrator privileges
APPLESCRIPT
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't remove the installed app before the replacement copy has succeeded.

Both install paths delete "$TARGET_APP" before running ditto. If the copy then fails (corrupt archive, disk full, interrupted copy), the current bundle is already gone and there is nothing left to relaunch. Copy into a sibling temp bundle first, then swap it into place only after the new bundle is complete, keeping the old bundle as rollback until the move succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/macUpdateInstaller.ts` around lines 71 - 84, The
install_update routine currently removes TARGET_APP before copying, risking loss
if ditto fails; change install_update (and the osascript fallback) to copy
SOURCE_APP into a sibling temporary bundle (e.g., TARGET_APP+".tmp" or use
mktemp) first, verify the copy completed successfully, then atomically
rename/move the temp bundle over TARGET_APP (or use /bin/mv) to replace it; keep
TARGET_APP until the move succeeds so the old app is available for rollback, and
ensure wait_for_app_exit is still invoked before the final swap; update
references to SOURCE_APP and TARGET_APP in both the shell and osascript paths
accordingly.

Comment on lines +901 to +906
if (process.platform === "darwin" && !isMacDeveloperIdSignedBuild()) {
installDownloadedUnsignedMacUpdate();
app.quit();
} else {
autoUpdater.quitAndInstall();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preflight the unsigned-mac installer before tearing the app down.

installDownloadedUnsignedMacUpdate() still does zip lookup, extraction, bundle discovery, script write, and process spawn, and any of those can throw. By this point Line 900 has already stopped the backend and Line 898 has already cleared update polling, so the catch path leaves the current session half-shutdown. Either do the manual-install preflight before Line 900, or explicitly restart the backend and polling when this branch fails.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main.ts` around lines 901 - 906, The current branch calls
installDownloadedUnsignedMacUpdate() after backend shutdown and update-polling
was cleared, which can throw and leave the app half-shutdown; preflight the
unsigned installer instead (i.e., run the installer lookup/extract/bundle
discovery/script write/spawn steps that installDownloadedUnsignedMacUpdate()
performs) before shutting down backend and clearing polling, or wrap the call in
a try/catch that on error restarts the backend and re-enables polling before
rethrowing/logging; update the code paths around
installDownloadedUnsignedMacUpdate(), autoUpdater.quitAndInstall(), and
app.quit() so the installer preflight occurs safely or the system recovers on
failure.

@zortos293 zortos293 merged commit de57e54 into main Mar 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capy Generated by capy.ai size:L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant