Skip to content

Add a double-clickable macOS app bundle - #5

Open
JYPochez wants to merge 6 commits into
jackhumphries:mainfrom
JYPochez:feature/macos-app-bundle
Open

Add a double-clickable macOS app bundle#5
JYPochez wants to merge 6 commits into
jackhumphries:mainfrom
JYPochez:feature/macos-app-bundle

Conversation

@JYPochez

@JYPochez JYPochez commented Aug 25, 2026

Copy link
Copy Markdown

SwiftPM has no .app product type, so the app could previously only be launched
from a terminal through run.sh. This adds two equivalent build paths that each
produce a real, self-contained AirPort Utility.app:

./make-app.sh [--sign] [--notarize] [--zip]   # shell script, no Xcode needed
open AirPortUtility.xcodeproj                 # scheme "AirPort Utility App"

Both build a universal binary (arm64 + x86_64) and embed the Python backend,
so the bundle runs without a source checkout beside it. They share one
Packaging/Info.plist: Xcode substitutes the $(VAR) placeholders natively and
the script substitutes them with sed, so the two cannot drift apart.

Why the layout is what it is

Four constraints shaped this, each found by building and running rather than by
inspection:

The backend lives in Contents/Resources, not Contents/MacOS. codesign
treats everything in Contents/MacOS as code and fails verification on
non-Mach-O files there (code object is not signed at all). This is the only
change that touches shared source: defaultRepoPath() now also resolves the
backend through Bundle.main.resourceURL.

Info.plist declares NSLocalNetworkUsageDescription and
NSBonjourServices.
On macOS 15+ a bundled app is its own TCC principal.
Without these, Bonjour discovery silently returns nothing and ACP connections to
port 5009 fail. Running from a terminal hides the problem, because there the
permission belongs to the terminal.

LSEnvironment sets PATH. A Finder-launched app inherits a minimal one, so
#!/usr/bin/env python3 resolves to /usr/bin/python3 — the Command Line Tools
stub, Python 3.9.6. The backend's full test suite does pass on 3.9.6, but this
prefers a real interpreter when one is installed.

LSEnvironment also sets PYTHONDONTWRITEBYTECODE. Python would otherwise
write __pycache__ beside the bundled backend on import, mutating a sealed
resource and invalidating the code signature.

Notes for review

  • The entitlements file is intentionally empty. The app is not sandboxed, and
    Hardened Runtime comes from codesign --options runtime rather than an
    entitlement. AMFI rejects XML comments inside an entitlements plist, so the
    rationale lives in Packaging/README.md.
  • Package.swift gains a library product for AirPortUtilityCore so the Xcode
    target can link it as a local package product instead of duplicating the
    source list.
  • The bundle identifier is defined in Packaging/Base.xcconfig with an optional
    #include? "Local.xcconfig", so a fork can set its own identifier without
    modifying a tracked file. A clone without that file builds with the default.
  • .github/workflows/release.yml builds, signs, notarizes, staples, verifies and
    attaches a zip on v* tags. It is inert without the six repository secrets
    listed at the top of the file.

Testing

  • swift test and the Python backend suite pass unchanged.
  • The signed bundle was verified to launch from a neutral working directory,
    resolve both Bundle.module resources and the embedded backend, and keep its
    signature intact after running.
  • The Xcode archive path produces the same bundle, signed with Developer ID and
    Hardened Runtime, passing codesign --verify --strict.

SwiftPM has no .app product type, so until now the app could only be launched
from a terminal via run.sh. Add two equivalent build paths that each produce a
real, self-contained "AirPort Utility.app":

  ./make-app.sh [--sign] [--notarize] [--zip]   shell script, no Xcode needed
  open AirPortUtility.xcodeproj                 scheme "AirPort Utility App"

Both build a universal binary (arm64 + x86_64) and embed the Python backend so
the bundle runs without a source checkout beside it.

Several bundling constraints drove the layout:

* The backend lives in Contents/Resources, not Contents/MacOS. codesign treats
  everything in Contents/MacOS as code and fails verification on non-Mach-O
  files there ("code object is not signed at all"). This is the only change
  that touches shared source: defaultRepoPath() now also resolves the backend
  through Bundle.main.resourceURL.

* Info.plist declares NSLocalNetworkUsageDescription and NSBonjourServices. On
  macOS 15+ a bundled app is its own TCC principal, and without these Bonjour
  discovery silently returns nothing and ACP connections to port 5009 fail.
  Running from a terminal masks this, because there the permission belongs to
  the terminal.

* LSEnvironment sets PATH, because a Finder-launched app inherits a minimal one
  and "#!/usr/bin/env python3" would resolve to /usr/bin/python3, the Command
  Line Tools stub (3.9.6). The backend's suite does pass there, but a real
  interpreter is preferred when installed.

* LSEnvironment also sets PYTHONDONTWRITEBYTECODE, since Python would otherwise
  write __pycache__ beside the bundled backend and invalidate the signature.

* The entitlements file is intentionally empty: the app is not sandboxed, and
  Hardened Runtime comes from `codesign --options runtime`. Note that AMFI
  rejects XML comments inside an entitlements plist, so the rationale lives in
  Packaging/README.md.

Package.swift gains a library product for AirPortUtilityCore so the Xcode
target can link it as a local package product instead of duplicating the
source list.

Tagging v* runs .github/workflows/release.yml, which builds, signs, notarizes,
staples, verifies and attaches the zip to a GitHub release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
JYPochez and others added 4 commits August 25, 2026 21:38
Package.swift declares swift-tools-version 6.0, but the project did not build
on Swift 6.0 -- only on the newer toolchain most contributors happen to have.
CI surfaced it on an Xcode 16 runner:

    error: call can throw, but it is not marked with 'try'
      accounts += selectedTopologyDeviceIdentifiers.compactMap(Self.passwordStoreAccount)

passwordStoreAccount is a plain non-throwing "-> String?". The problem is that
passing it as a *function value* to the nonisolated rethrows compactMap crosses
an actor boundary, since it inherits @mainactor from AirportAppModel. Swift 6.0
reports that as the argument being able to throw; 6.2 accepts it.

Calling it from inside a closure keeps the call in the isolated context, so no
function value crosses the boundary. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
The release workflow pinned Xcode 16, whose Swift 6.0 rejects concurrency
patterns this project uses -- passing actor-isolated methods as function values
to nonisolated generics, in AirPortCommandRunner, AirPortServices, TopologyView
and AirportAppModelPasswords. None of that is specific to this branch; the
project simply needs a newer toolchain than the pin allowed.

Selects the highest-numbered Xcode present instead, and logs what was available
so a future mismatch is diagnosable from the run alone.

This supersedes the previous commit, which patched one of those call sites. That
was treating a symptom: the other three remained, so the project still did not
build on Swift 6.0, and the change was unrelated churn in shared code. Reverted.
The file holds a fork's own bundle identifier and signing team and is meant to
stay untracked. The ignore rule lived only on a later branch, so on this one it
was not ignored at all and got committed.

Adds the rule here, where Packaging/ is introduced, so it applies to every
branch built on this one.
@jackhumphries

jackhumphries commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Still making my way through this PR. I appreciate the effort and thought you put into making the icon and it gave me a chuckle, but would it be alright if we instead reuse the AirPort Utility icon instead? If Apple complains about this, then we can move to another icon design at that point.

@JYPochez

Copy link
Copy Markdown
Author

Hi, I made effort NOT to use any apple logo or part of logo intentionnaly, because I'm sure the minute you publish it you get an army of lawyers sending you cease&desists in your mailbox ...
Also your port of Airport Utility is really great, it must be clear to users that Apple abandonned it along with the time capsules...

Tagging a release in a repository without the signing secrets failed at the
certificate import, leaving a red run on every tag. That is the state any fork
starts in, including this one, so the workflow reported a problem where there
was none.

The credential-dependent steps are now skipped when MACOS_CERTIFICATE is unset:
the run builds, tests and stops, and emits a notice saying signing and
publishing were skipped and why. With the secrets configured it behaves exactly
as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
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.

2 participants