Skip to content

Add a macOS Share extension so SwiftX appears in the Share menu - #23

Merged
RetroHazard merged 1 commit into
cc/loving-hopper-jv4wy8from
claude/swiftx-share-menu-missing-xxzwf6
Jul 27, 2026
Merged

Add a macOS Share extension so SwiftX appears in the Share menu#23
RetroHazard merged 1 commit into
cc/loving-hopper-jv4wy8from
claude/swiftx-share-menu-missing-xxzwf6

Conversation

@RetroHazard

Copy link
Copy Markdown
Owner

The problem

SwiftX never appeared in the macOS Share… menu, or in System Settings → General → Login Items & Extensions → Sharing.

It shipped only an NSServices entry, which puts "Upload with SwiftX" in the right-click Services menu. The Share menu is a separate mechanism — it's populated exclusively from com.apple.share-services app extensions (.appex bundles inside Contents/PlugIns/), and SwiftX shipped none, so it could never show up there. Nothing surfaces an error for this; the entry is simply absent.

The code already knew: ServicesProvider.swift carried a comment that a share-sheet .appex "needs an embedded extension bundle, which the SPM bundling pipeline doesn't produce yet."

What this adds

SwiftXShare.appex, embedded in SwiftX.app/Contents/PlugIns/, handling files, images, movies, text and links.

Sources/ShareExtension/ — a new SwiftPM executable target (swiftx-share). SwiftPM has no app-extension product type and can't set Xcode's -e _NSExtensionMain entry point without unsafeFlags (which would poison the package for downstream consumers), so main.swift calls that same Foundation entry point from an ordinary main — equivalent, and with the Swift runtime already up when it runs. ShareViewController builds a compact AppKit sheet listing what is about to be uploaded, with Cancel/Upload.

Scripts/make-app.sh — assembles the surrounding bundle and signs it before the app seal. The three things that silently break registration are called out in comments: CFBundlePackageType must be XPC! (not APPL), CFBundleIdentifier must be prefixed by the host app's, and NSExtensionPrincipalClass resolves through the Objective-C runtime, so the class needs @objc(ShareViewController) rather than Swift's module-mangled name. Ends with a pluginkit registration nudge and a check.

Handoff, not duplication — the extension uploads nothing itself; destinations, Keychain credentials, history and the after-upload chain all live in the app. It stages copies of the shared items inside its own container and opens swiftx://ShareExtensionInput/<uuid>. ShareInbox (SharedKit) is the shared contract; ShareRequests consumes a request and runs it through the normal upload pipeline. Shared links reuse the existing ClipboardUpload* routing (download contents / shorten / share), extracted from clipboardUpload as uploadTextOrURL.

Sandboxing — the extension is App-Sandboxed (Resources/ShareExtension.entitlements), which macOS requires of app extensions even though the app deliberately is not. It costs the extension nothing: it reads the shared items, copies them into its container, and opens a URL. That the app is unsandboxed is what makes the handoff work at all — it reads straight into the extension's container, so no app group and therefore no provisioning profile is needed.

Security

The URL scheme stays untrusted, but unlike a bare swiftx://FileUpload/<path> the payload here is authenticated by construction, so the upload isn't gated behind a third confirmation (the user already picked SwiftX in the Share menu and confirmed the item list in the extension's sheet). The normal multi-file and large-file warnings still apply.

  • Location authenticates it — the caller supplies only a UUID; every path is derived from ~/Library/Containers/com.retrohazard.swiftx.share/…. A web page cannot create a directory inside another process's sandbox container, so it cannot forge a request, and there is no attacker-chosen path to aim at ~/.ssh.
  • Freshness kills replay — requests older than five minutes are deleted unread.
  • Reads consume — the request directory is removed on first read, valid or not.
  • Manifest names are validated as plain file names, so a tampered manifest can't walk out of the request directory.

Written up in docs/macos-swift-port/SECURITY-MODEL.md.

Tests

Tests/SharedKitTests/ShareInboxTests.swift covers the manifest round-trip, UUID/staged-name validation (traversal attempts), the freshness window, and — the one that matters most — that the writer side (spoolRoot(containerHome:), called from inside the sandbox) and the reader side (spoolRoots(userHome:), called from outside it) agree on the same directory, and that only the extension's own container is searched.

Docs

PARITY.md (the row claiming an .appex wasn't possible), SECURITY-MODEL.md, README.md, DEVELOPMENT.md, and a new docs/solutions/best-practices/ note on building an .appex from a SwiftPM-only project.

Verification

swift build and swift test pass on the macos-15 runner; the Bundle app step exercises the new appex assembly and codesign path.

Not verified here: end-to-end Share-menu behaviour needs a real Mac. Worth checking on a dev machine — Finder → select a file → Share… → SwiftX, and pluginkit -m -p com.apple.share-services | grep com.retrohazard.swiftx.share.

🤖 Generated with Claude Code

https://claude.ai/code/session_017k1NuLEpakUu9qvsW63xDP


Generated by Claude Code

SwiftX only shipped an NSServices entry, which puts "Upload with SwiftX"
in the right-click Services menu. The system "Share…" menu and the Sharing
list in System Settings are a different mechanism entirely — they are
populated only from com.apple.share-services app extensions — so SwiftX was
absent from both with nothing to explain why.

Adds SwiftXShare.appex, embedded in SwiftX.app/Contents/PlugIns:

- New ShareExtension executable target. SwiftPM has no app-extension product
  type and cannot set -e _NSExtensionMain without unsafe flags, so main.swift
  calls the same Foundation entry point from an ordinary main; make-app.sh
  assembles the surrounding bundle (XPC! package type, prefixed bundle id,
  @objc-stable principal class) and signs it before the app seal.
- The extension is App-Sandboxed, which macOS requires of app extensions even
  though the app deliberately is not. It uploads nothing itself: it confirms
  the item list, stages copies inside its own container, and opens
  swiftx://ShareExtensionInput/<uuid>. Because the app is unsandboxed it can
  read straight into that container — no app group, no provisioning profile.
- ShareInbox (SharedKit) is the shared handoff contract; ShareRequests
  consumes a request and runs it through the normal upload pipeline. The URL
  scheme stays untrusted, but the payload is authenticated by construction:
  the caller supplies only a UUID, every path is derived from the extension's
  container (which a web page cannot write into), requests expire after five
  minutes, and a read consumes the directory.
- Shared links reuse the ClipboardUpload* routing (download contents /
  shorten / share), extracted from clipboardUpload as uploadTextOrURL.

Handles files, images, movies, text and links. Docs updated (PARITY,
SECURITY-MODEL, README, DEVELOPMENT) plus a solutions note on building an
.appex from a SwiftPM-only project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017k1NuLEpakUu9qvsW63xDP
@RetroHazard
RetroHazard merged commit d497e81 into cc/loving-hopper-jv4wy8 Jul 27, 2026
2 checks passed
RetroHazard added a commit that referenced this pull request Jul 27, 2026
…ind (#22)

* Fix OAuth Connect button not updating for Google Drive, YouTube, OneDrive

SettingsView read UploadersConfig and OAuthTokenStore straight from disk
on every body evaluation but never touched a tracked @State/@published
property when they changed, so SwiftUI had no reason to re-render:
entering a client ID in "Advanced: use your own OAuth app" never revealed
the Connect button, and a successful connect never flipped the button to
"Connected" — both read as "nothing happens" (issue #19).

Add an @State refresh counter that's bumped whenever the OAuth config or
token store changes (editing the client ID, connecting, disconnecting)
and read it as a dependency in oauthFields so the section re-evaluates.
Make OAuthConnectCoordinator.connect awaitable so the button can refresh
state once the flow actually completes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqRq4G42pkHdWvVcVn8hzx

* Make OAuth connect failures visible and fix the loopback listener binding

The released build ships baked-in app credentials, so users see the
Connect button — but clicking it could still silently do nothing:

- LoopbackRedirect restricted the NWListener with requiredInterfaceType
  = .loopback, which can leave the listener stuck in .waiting and never
  .ready; init then timed out and threw. Bind explicitly to 127.0.0.1
  with requiredLocalEndpoint instead, and include the underlying NWError
  in the failure message.
- Every connect failure was reported only via Notification Center, which
  drops banners silently when authorization was denied — a failed flow
  looked identical to a dead button. Failures now present an NSAlert
  (the flow starts from the Settings window) and log to AppLog.upload.
- NSWorkspace.open's result was ignored; a failed browser launch is now
  an error instead of an silent no-op.
- Install the connection handler before the listener starts and buffer a
  fast redirect so it can't slip through before waitForCallback runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqRq4G42pkHdWvVcVn8hzx

* Fix definite-initialization error in LoopbackRedirect.init

The connection handler closure captures self before the port property
was initialized. Give port a default and assign it after the listener
reports ready, so phase-one init completes before the capture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqRq4G42pkHdWvVcVn8hzx

* Offer each destination only in the slots it can serve

The per-type split let any host be picked for any slot, so YouTube was
selectable for text uploads and image hosts for archives — choices that
can only fail at the host's API.

Add DestinationCatalog in UploadKit: one table of every selectable
destination with the upload kinds it accepts, following ShareX's
uploader-type split. Image-only hosts (Chevereto, vgy.me) and video
hosts (YouTube, Streamable) are now confined to their slots; general
file storage still serves all three, since SwiftX sends text as a .txt
file. The catalog also replaces the destination list and display-name
switch the picker duplicated, so hosts are declared once.

UploadKind moves to UploadKit alongside it — the pickers and the router
now classify against the same type.

A stored destination that doesn't fit its slot (an imported Windows
config, or one set before this filtering existed) still gets a picker
row, labelled as not valid for that kind. Dropping it would silently
repoint the slot; routing behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqRq4G42pkHdWvVcVn8hzx

* Document the OAuth app registration gotchas

Registering the provider apps has two traps that present as SwiftX bugs
rather than setup mistakes:

- Azure's portal refuses http-scheme redirect URIs with 127.0.0.1, so
  the loopback URI has to go in the app manifest. Registered under the
  Authentication blade's Web platform instead, OneDrive fails mid-flow
  with "invalid_request: ... redirect_uri ... is not valid".
- Google expires refresh tokens after 7 days while the consent screen is
  in Testing, so Drive/YouTube drop their connection weekly.

Also record that make-app.sh bundles the plist before signing (so
credentials changes need a re-run, and a bare swift run never sees them)
and that write-oauth-plist.sh can generate it from the environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqRq4G42pkHdWvVcVn8hzx

* Drop the "use your own OAuth app" fields from settings

The client ID/secret disclosure asked end users to register their own
app with Google or Microsoft — a developer-only workflow sitting in the
middle of the destination settings, where it mostly added noise. Setup
is one click: SwiftX ships the credentials, the user signs in.

The UI is gone; the OAuthApps override key in UploadersConfig.json is
still honoured. Anyone who did enter their own client ID holds a refresh
token issued to that app, so ignoring the key would silently break their
connection at the next refresh rather than migrate it.

Reword the unavailable-host and connect-failure messages, which both
pointed at the fields that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqRq4G42pkHdWvVcVn8hzx

* Add a macOS Share extension so SwiftX appears in the Share menu (#23)

SwiftX only shipped an NSServices entry, which puts "Upload with SwiftX"
in the right-click Services menu. The system "Share…" menu and the Sharing
list in System Settings are a different mechanism entirely — they are
populated only from com.apple.share-services app extensions — so SwiftX was
absent from both with nothing to explain why.

Adds SwiftXShare.appex, embedded in SwiftX.app/Contents/PlugIns:

- New ShareExtension executable target. SwiftPM has no app-extension product
  type and cannot set -e _NSExtensionMain without unsafe flags, so main.swift
  calls the same Foundation entry point from an ordinary main; make-app.sh
  assembles the surrounding bundle (XPC! package type, prefixed bundle id,
  @objc-stable principal class) and signs it before the app seal.
- The extension is App-Sandboxed, which macOS requires of app extensions even
  though the app deliberately is not. It uploads nothing itself: it confirms
  the item list, stages copies inside its own container, and opens
  swiftx://ShareExtensionInput/<uuid>. Because the app is unsandboxed it can
  read straight into that container — no app group, no provisioning profile.
- ShareInbox (SharedKit) is the shared handoff contract; ShareRequests
  consumes a request and runs it through the normal upload pipeline. The URL
  scheme stays untrusted, but the payload is authenticated by construction:
  the caller supplies only a UUID, every path is derived from the extension's
  container (which a web page cannot write into), requests expire after five
  minutes, and a read consumes the directory.
- Shared links reuse the ClipboardUpload* routing (download contents /
  shorten / share), extracted from clipboardUpload as uploadTextOrURL.

Handles files, images, movies, text and links. Docs updated (PARITY,
SECURITY-MODEL, README, DEVELOPMENT) plus a solutions note on building an
.appex from a SwiftPM-only project.


Claude-Session: https://claude.ai/code/session_017k1NuLEpakUu9qvsW63xDP

Co-authored-by: Claude <noreply@anthropic.com>

* Document the pending OAuth verification status

Both provider registrations are unverified: Google is submitted and
awaiting review, Microsoft is not submitted because publisher
verification needs a Partner Center business account. Both work, so the
only symptom is the "unverified app" warning on the sign-in screen —
which reads like a security problem when it is really a statement about
SwiftX's paperwork with the provider.

Say so everywhere someone meets it:

- .github/release-notice.md, prepended to every release's changelog. The
  release step now assembles notes itself, since --generate-notes has no
  defined behaviour alongside a notes body. Deleting the file is all it
  takes to retire the notice.
- README, for anyone reading before downloading.
- The site FAQ, next to the existing question about where uploads go.
- DEVELOPMENT.md, with the parts that matter to whoever maintains the
  registrations: the ~100-user cap on an unverified app requesting
  sensitive scopes, the 7-day refresh-token expiry if the consent screen
  ever returns to Testing, and what Microsoft verification would require.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqRq4G42pkHdWvVcVn8hzx

* Surface Windows .sxb backup import and picker filtering at the user-facing layer

PARITY.md (added by #21) already tracked the .sxb import, the Services
entry, and the per-type destination split in full detail, but the
top-level docs a prospective switcher or reviewer actually reads first
didn't carry them:

- README's feature list never mentioned .sxb import — the flagship
  "bring your whole ShareX setup" capability from #21.
- The site FAQ literally asks "does my setup come with me?" and answered
  with only the individual .sxcu/.sxie/history compatibility, missing the
  one-step backup import that answers the question best.
- PARITY.md's destination-slot row predated #22's kind filtering
  (DestinationCatalog), so it still described the per-type pickers without
  noting they now reject invalid host/kind combinations.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@RetroHazard
RetroHazard deleted the claude/swiftx-share-menu-missing-xxzwf6 branch July 27, 2026 13:59
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