Skip to content

Handle unsigned macOS game executable signing#150

Open
wycats wants to merge 2 commits into
netniV:devfrom
wycats:wycats/macos-entitlement-signing-fallback
Open

Handle unsigned macOS game executable signing#150
wycats wants to merge 2 commits into
netniV:devfrom
wycats:wycats/macos-entitlement-signing-fallback

Conversation

@wycats

@wycats wycats commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

This handles a macOS launcher case where the game executable is unsigned.

Why:

  • PR Correct entitlements for App Signing #123 avoids re-signing when the game already has the entitlements needed for DYLD-based mod loading.
  • While testing locally, I hit a different state: the game executable reported code object is not signed at all.
  • The existing fallback then tried to sign the executable path directly, but codesign walked the app bundle and failed on an invalid nested NativeScript.bundle.

Changes:

  • Distinguish valid, missing, and unsigned entitlement states.
  • For unsigned executables, sign a temporary copy and write the signed bytes back to the game executable.
  • Preserve the executable's original permissions.
  • Keep the existing user-confirmed file-picker flow for the signed-but-missing-entitlements case.

Validation:

  • Restored an unsigned copy of the game executable.
  • Clicked Engage from the launcher.
  • Launcher log showed Game executable is unsigned, then Successfully applied loader entitlements to unsigned game executable.
  • Game launched and the mod loaded.
  • ./scripts/mac-build-test-debug.sh -m release build

I split this from the local build-script PR so the signing behavior can be reviewed independently.

Copilot AI review requested due to automatic review settings April 29, 2026 22:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Updates macOS launcher entitlement handling to correctly support the case where the game’s main executable is completely unsigned, avoiding codesign failures caused by invalid nested bundles during signing.

Changes:

  • Adds an EntitlementCheckResult state machine to distinguish valid, missing, and unsigned entitlement/signing states.
  • Introduces a new unsigned flow that signs a temporary copy of the main executable and writes the signed result back to the original file.
  • Updates entitlement verification call sites to match against .valid instead of a boolean.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread macos-launcher/src/PatchEntitlements.swift
Comment thread macos-launcher/src/PatchEntitlements.swift Outdated
Comment thread macos-launcher/src/PatchEntitlements.swift Outdated
@a5ehren

a5ehren commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

How did you hit a state where the game app is unsigned?

@wycats wycats force-pushed the wycats/macos-entitlement-signing-fallback branch from e128672 to cf7bf59 Compare April 30, 2026 05:52
@wycats

wycats commented Apr 30, 2026

Copy link
Copy Markdown
Contributor Author

How did you hit a state where the game app is unsigned?

I honestly don't know but it's what happened. I can try to provide more logs or other information. I agree it's weeeeird.

@a5ehren

a5ehren commented May 1, 2026

Copy link
Copy Markdown
Contributor

Nah it's fine. It's a decent edge case to have coverage for

UserDefaults.standard.set(false, forKey: "forceEntitlementReapplication")
return
case .missing:
break

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

If an entitlement key is missing, nothing happens? Yet the code returning this breaks out of the loop on the first missing entitlement.... surely we should be saying something somewhere?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewer note: the concern is valid, though partly a readability issue. Two things:

  • break here just exits the switch (Swift break, not a loop break), so control does fall through to the re-signing block below, which logs logger.warning("Game is missing required loader entitlements, re-signing is needed"). So it isn't a no-op — but a bare case .missing: break with no comment reads like one, which is what makes this confusing.
  • The genuinely weak part: which entitlement is missing is only logged at .debug (in checkEntitlements, the "not found"/"mismatch" branches). On a default logger that's invisible, so when the re-sign path runs you can't tell why from normal logs.

Suggested fix — make the fall-through explicit and surface the missing key at a visible level:

case .missing:
    // Fall through to the re-signing flow below.
    break

and bump the checkEntitlements diagnostics from .debug to .notice so the missing/mismatched key shows up in logs.


// Now sign just the main executable with the entitlements
// This preserves nested code signatures and only modifies what we need
logger.info("Signing executable with entitlements...")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Do we care that a bunch of debug info was removed?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewer note: mostly fine, one line worth restoring.

  • The --remove-signature stdout/stderr logging is gone because the step itself is gone — the new copy-then---force-sign approach never removes a signature first, so that logging is correctly obsolete. No loss.
  • The explicit logger.info("Command: codesign --force --sign ... \(mainExecutablePath)") line, however, was genuinely useful for diagnosing signing failures and is gone. The sign process's stdout/stderr is still logged, so you're not blind, but you lose the exact invocation.

Given this PR exists because of a hard-to-reproduce signing edge case, keeping that diagnostic has real value. Suggest restoring an equivalent line before signProcess.run():

logger.info("Command: codesign --force --options runtime --sign \(signingIdentity) --entitlements \(entitlementsPath) \(tempExecutable.path)")

@a5ehren a5ehren left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few additional findings from a fresh review pass, alongside the replies on netniV's threads. None are blocking on their own, but I'd want answers on the .error fall-through change and the unsigned-path consent divergence before merge.

UserDefaults.standard.set(false, forKey: "forceEntitlementReapplication")
}
return
case .unsigned:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Behavioral divergence worth confirming is intentional: the .unsigned path calls applyEntitlementsDirectly directly, skipping the requestGameBundleAccess file picker that the .missing path uses for explicit user consent. Since the launcher is not sandboxed (app-sandbox = false in macOSLauncher.entitlements), file access still works via the App Management TCC grant, so this isn't a correctness bug — but one path asks "may I modify your game binary?" and the other silently rewrites it. Note the doc comments on applyEntitlementsDirectly/executeCodesignDirectly still say "Must be called while security-scoped resource access is active", which this caller now violates. Either route unsigned through the same consent flow or relax those comments.

case .missing:
break
case .error:
throw EntitlementError.entitlementVerificationFailed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Behavior change to confirm: previously any falsy checkEntitlements result (codesign failure, empty output, parse failure) fell through to the re-signing path. Now .error throws entitlementVerificationFailed immediately and never attempts the file-picker fallback. This is arguably more correct (don't modify the binary when you couldn't even read entitlements), but it's a real change for transient codesign failures the old code would have recovered from. Intentional?


// Verify the entitlements were applied successfully to the main executable
guard checkEntitlements(appPath: gamePath, expectedEntitlements: loaderEntitlements) else {
guard case .valid = checkEntitlements(appPath: gamePath, expectedEntitlements: loaderEntitlements) else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Indentation glitch: this guard is over-indented relative to the function body. No SwiftLint/swift-format config exists in macos-launcher/, so CI won't catch it — worth fixing by hand for consistency.

let entitlementsDict = plist as? [String: Any] else {
logger.error("Failed to parse entitlements plist")
return false
return .error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Indentation glitch: return .error is over-indented compared to the surrounding block. Align with the other statements in this guard body.


do {
let executableURL = URL(fileURLWithPath: mainExecutablePath)
try fileManager.replaceItemAt(executableURL, withItemAt: tempExecutable)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

replaceItemAt (without .usingNewMetadataOnly) is what actually backs the PR's "preserve original permissions" claim — it preserves the destination's metadata. That's load-bearing and easy to silently break later by adding the wrong option. Worth a one-line comment here noting the intent so a future change doesn't regress it.

@netniV

netniV commented Jun 4, 2026

Copy link
Copy Markdown
Owner

If you can address these @wycats we can look at getting this merged in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants