Add a macOS Share extension so SwiftX appears in the Share menu - #23
Merged
RetroHazard merged 1 commit intoJul 27, 2026
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
SwiftX never appeared in the macOS Share… menu, or in System Settings → General → Login Items & Extensions → Sharing.
It shipped only an
NSServicesentry, which puts "Upload with SwiftX" in the right-click Services menu. The Share menu is a separate mechanism — it's populated exclusively fromcom.apple.share-servicesapp extensions (.appexbundles insideContents/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.swiftcarried 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 inSwiftX.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 _NSExtensionMainentry point withoutunsafeFlags(which would poison the package for downstream consumers), somain.swiftcalls that same Foundation entry point from an ordinarymain— equivalent, and with the Swift runtime already up when it runs.ShareViewControllerbuilds 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:CFBundlePackageTypemust beXPC!(notAPPL),CFBundleIdentifiermust be prefixed by the host app's, andNSExtensionPrincipalClassresolves through the Objective-C runtime, so the class needs@objc(ShareViewController)rather than Swift's module-mangled name. Ends with apluginkitregistration 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;ShareRequestsconsumes a request and runs it through the normal upload pipeline. Shared links reuse the existingClipboardUpload*routing (download contents / shorten / share), extracted fromclipboardUploadasuploadTextOrURL.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.~/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.Written up in
docs/macos-swift-port/SECURITY-MODEL.md.Tests
Tests/SharedKitTests/ShareInboxTests.swiftcovers 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.appexwasn't possible),SECURITY-MODEL.md,README.md,DEVELOPMENT.md, and a newdocs/solutions/best-practices/note on building an.appexfrom a SwiftPM-only project.Verification
swift buildandswift testpass on the macos-15 runner; theBundle appstep 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