← Back to list

Finder Sent the File to Your App.Why Did SwiftUI Open Another Window Anyway?

How to make Finder Open With reuse the active SwiftUI macOS window as a new tab instead of spawning another window

Doran Gao · 2026-03-08 15:11 · 1 claps · 8.9 min read paywalled
#swiftui #macos #swfit #appkit #onedit
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Finder Sent the File to Your App.Why Did SwiftUI Open Another Window Anyway?

How to make Finder Open With reuse the active SwiftUI macOS window as a new tab instead of spawning another window

https://theonequote.app/quote/11982

https://theonequote.app/quote/11982

“Simple things should be simple; complex things should be possible.” — Alan Kay.

The Finder context menu looks innocent enough: right-click a file, choose Open With, and expect your editor to open that file where you already are.

Instead, macOS launches your app, SwiftUI gives you a fresh window, and the file lands there instead of becoming a new tab in the editor window the user was already using.

That is the whole bug.

Not file loading. Not tab state. Not ContentView.

The real problem is routing.

The goal is simple:

If an active editor window exists, open the file there as a new tab. If no active editor window exists, fall back to a new window.

That sounds like a SwiftUI state problem at first. It is not.

It is an NSWindow problem.

The key mental shift

A lot of SwiftUI macOS apps start by thinking in terms of views:

  • ContentView is on screen
  • some tab state lives in the view model
  • a notification arrives
  • the view opens the file

That works right up until the system decides to create another window first.

At that point, view identity is not enough. You need to know which real AppKit window is the active editor window, which one is just a transient system-created window, and which one should actually receive the open action.

Here is the architecture change in one picture:

That shift is what makes the behavior predictable.

Thesis

To make Finder Open With behave like a real document editor in SwiftUI, you need an AppKit-aware routing layer that tracks actual NSWindow instances, selects the active resolved editor window, and delivers the open event to exactly one window.

Why the naive approach breaks

The naive implementation usually does one of two things:

  1. It broadcasts a file-open notification and lets every window decide what to do.
  2. It assumes the current SwiftUI view is the right destination.
  3. Both are brittle.

Finder does not care about your SwiftUI view hierarchy. It sends a file-open event to the app. By the time your view tree reacts, macOS may already have created a new window, and that new window may briefly be key.

So the app needs a stronger rule:

  • track real editor windows
  • remember the last resolved key editor window
  • if the current key window is unresolved and transient, ignore it
  • route the open event to the actual active editor window instead

That is the difference between “it mostly works” and “it behaves like a Mac app.”

The behavior rule to preserve

Before touching code, it helps to lock the product behavior into one sentence:

Active editor window exists: open the file there as a new tab. No active editor window: open the file in a new window.

That single rule keeps the rest of the implementation honest.

Step 1: Add an AppKit app delegate to the SwiftUI app

The first step is to give the app a place to receive real macOS file-open events.

@main
struct MyEditorApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        WindowGroup(id: "editor") {
            ContentView()
        }
    }
}

This is the bridge point.

SwiftUI stays responsible for rendering windows and views. AppDelegate becomes responsible for receiving external open requests and deciding where they should go.

That separation matters because external file-open events are app-level events, not view-level events.

Step 2: Receive file-open events in AppDelegate

Finder, LaunchServices, and commands like open -a can arrive through different delegate paths, so the delegate should normalize all of them into one forwarding function.

final class AppDelegate: NSObject, NSApplicationDelegate {
    func application(_ application: NSApplication, open urls: [URL]) {
        forwardExternalOpen(urls)
    }

    func application(_ sender: NSApplication, openFile filename: String) -> Bool {
        forwardExternalOpen([URL(fileURLWithPath: filename)])
        return true
    }

    func application(_ sender: NSApplication, openFiles filenames: [String]) {
        forwardExternalOpen(filenames.map { URL(fileURLWithPath: $0) })
        sender.reply(toOpenOrPrint: .success)
    }
}

This part is not clever. It is just necessary.

The important thing is that all external entry points converge into one routing path.

Step 3: Track real NSWindow instances, not just SwiftUI views

This is where the implementation stops being “pure SwiftUI” and starts becoming reliable.

You need a way to resolve the backing NSWindow for each editor window. A lightweight NSViewRepresentable is a clean way to do that.

struct WindowAccessor: NSViewRepresentable {
    @Binding var window: NSWindow?
    let onWindowResolved: (NSWindow) -> Void

    func makeNSView(context: Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async {
            if let window = view.window {
                self.window = window
                onWindowResolved(window)
            }
        }
        return view
    }

    func updateNSView(_ nsView: NSView, context: Context) {
        DispatchQueue.main.async {
            if let window = nsView.window, self.window !== window {
                self.window = window
                onWindowResolved(window)
            }
        }
    }
}

This is the critical bridge.

Without it, your app knows about SwiftUI screens. With it, your app knows about actual editor windows.

And once you have actual window identity, targeted routing becomes possible.

Step 4: Keep shared window state in the delegate

The delegate needs enough state to answer one question:

Which editor window should receive this external open request right now?

A practical answer is to track:

  • every resolved editor window ID
  • the last resolved key editor window ID
final class AppDelegate: NSObject, NSApplicationDelegate {
    static private(set) var shared: AppDelegate?

    private var resolvedEditorWindowIDs: Set<ObjectIdentifier> = []
    private var lastResolvedKeyEditorWindowID: ObjectIdentifier?

    override init() {
        super.init()
        Self.shared = self
    }

    func editorWindowDidResolve(_ window: NSWindow) {
        let id = ObjectIdentifier(window)
        resolvedEditorWindowIDs.insert(id)
        if window.isKeyWindow {
            lastResolvedKeyEditorWindowID = id
        }
    }

    @objc private func handleWindowDidBecomeKey(_ notification: Notification) {
        guard let window = notification.object as? NSWindow else { return }
        let id = ObjectIdentifier(window)
        if resolvedEditorWindowIDs.contains(id) {
            lastResolvedKeyEditorWindowID = id
        }
    }
}

This state does not try to model everything.

It only models what matters for routing: which windows are real editor windows, and which editor window was last meaningfully active.

That restraint is useful. It avoids turning the delegate into a second UI framework.

A diagram of the routing decision

Once you track resolved windows, the selection logic becomes much easier to reason about:

That is the whole policy.

Everything else is plumbing.

Step 5: Select the active resolved editor window, not the transient new one

Here is the selector that turns the policy into code:

struct ActiveExternalOpenTargetSelection {
    let targetWindowID: ObjectIdentifier?
    let shouldCloseUnresolvedKeyWindow: Bool
}

struct ActiveExternalOpenWindowSelector {
    static func selection(
        keyWindowID: ObjectIdentifier?,
        resolvedEditorWindowIDs: Set<ObjectIdentifier>,
        lastResolvedKeyEditorWindowID: ObjectIdentifier?
    ) -> ActiveExternalOpenTargetSelection {
        guard let keyWindowID else {
            return .init(targetWindowID: nil, shouldCloseUnresolvedKeyWindow: false)
        }

        if resolvedEditorWindowIDs.contains(keyWindowID) {
            return .init(targetWindowID: keyWindowID, shouldCloseUnresolvedKeyWindow: false)
        }

        guard
            let lastResolvedKeyEditorWindowID,
            resolvedEditorWindowIDs.contains(lastResolvedKeyEditorWindowID),
            lastResolvedKeyEditorWindowID != keyWindowID
        else {
            return .init(targetWindowID: nil, shouldCloseUnresolvedKeyWindow: false)
        }

        return .init(
            targetWindowID: lastResolvedKeyEditorWindowID,
            shouldCloseUnresolvedKeyWindow: true
        )
    }
}

This is the part that fixes the awkward macOS behavior.

If Finder triggered a new unresolved key window before your editor window could respond, the selector treats that new key window as transient, targets the last real editor window instead, and gives you the option to close the transient one.

That is what makes the app feel intentional rather than reactive.

Step 6: Deliver the open action to exactly one window

Once you know the target window, the next rule is simple:

Do not broadcast blindly.

Post a notification that includes the target NSWindow in userInfo, and let only that window handle it.

enum ExternalOpenNotificationUserInfoKey {
    static let targetWindow = "externalOpenTargetWindow"
}

private func forwardExternalOpen(_ urls: [URL]) {
    let selection = ActiveExternalOpenWindowSelector.selection(
        keyWindowID: NSApp.keyWindow.map(ObjectIdentifier.init),
        resolvedEditorWindowIDs: resolvedEditorWindowIDs,
        lastResolvedKeyEditorWindowID: lastResolvedKeyEditorWindowID
    )

    let targetWindow = selection.targetWindowID.flatMap { id in
        NSApp.windows.first { ObjectIdentifier($0) == id }
    }

    DispatchQueue.main.async {
        for url in urls {
            NotificationCenter.default.post(
                name: .openRecentFile,
                object: url,
                userInfo: targetWindow.map {
                    [ExternalOpenNotificationUserInfoKey.targetWindow: $0]
                }
            )
        }

        if
            selection.shouldCloseUnresolvedKeyWindow,
            let keyWindow = NSApp.keyWindow,
            keyWindow !== targetWindow
        {
            keyWindow.close()
        }
    }
}

The important design decision here is not the notification itself.

It is the fact that the open action is targeted.

That makes external open behavior single-window by construction.

Step 7: Let ContentView respond only if it owns the target window

Each window gets a local NSWindow? reference through WindowAccessor. Then it filters incoming notifications before opening anything.

struct ContentView: View {
    @State private var window: NSWindow?

    var body: some View {
        EditorUI()
            .background(
                WindowAccessor(window: $window) { resolvedWindow in
                    AppDelegate.shared?.editorWindowDidResolve(resolvedWindow)
                }
            )
            .onReceive(NotificationCenter.default.publisher(for: .openRecentFile)) { notification in
                if shouldHandleOpenRecentFile(notification),
                   let url = notification.object as? URL {
                    openFileFromURL(url)
                }
            }
    }

    private func shouldHandleOpenRecentFile(_ notification: Notification) -> Bool {
        if let targetWindow = notification.userInfo?[ExternalOpenNotificationUserInfoKey.targetWindow] as? NSWindow {
            return window === targetWindow
        }

        return WindowActionRouter.shouldHandleAction(
            windowID: window.map(ObjectIdentifier.init),
            keyWindowID: NSApp.keyWindow.map(ObjectIdentifier.init),
            mainWindowID: NSApp.mainWindow.map(ObjectIdentifier.init),
            orderedWindowIDs: NSApp.orderedWindows.map(ObjectIdentifier.init)
        )
    }
}

This is the quiet but important finish.

Even your “normal” broadcast-style actions should still route through a single-window selector such as:

  • key window
  • then main window
  • then frontmost ordered window

That keeps all window-scoped behavior consistent, not just Finder opens.

Step 8: Open the file as a tab in that window

Once the correct window receives the event, the file-open logic becomes pleasantly ordinary.

If the file is already open in that window, select its existing tab. If not, create a new one.

private func openFileFromURL(_ url: URL) {
    if let existingTab = tabs.first(where: { $0.fileURL == url }) {
        selectedTabID = existingTab.id
        return
    }

    let contents = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
    let tab = EditorTab(title: url.lastPathComponent, content: contents, fileURL: url)
    tabs.append(tab)
    selectedTabID = tab.id
}

This is exactly where file opening should happen: inside the owning window’s tab state.

Not globally. Not opportunistically. Not in every window that happened to hear a notification.

Why this works

The sequence is straightforward once the architecture is in place:

The key is that the app delegate decides where, and the window decides how.

That division of labor is what makes the implementation stable.

The practical edge case: duplicate events

Depending on how the app is launched and what state it is in, you may see duplicate open events during iteration.

That is why optional deduping is worth keeping in mind.

Even a lightweight “recently forwarded URL” check or a short-lived event token can prevent duplicate tabs from being created when Finder or LaunchServices gets noisy.

This is not the core fix, but it is often the polish that makes the solution production-ready.

How to make the app appear in Finder’s Open With

Routing the open event correctly only matters if Finder can hand your app the file in the first place.

That means declaring document support in Info.plist.

<key>CFBundleDocumentTypes</key>
<array>
    <dict>
        <key>CFBundleTypeName</key>
        <string>Text</string>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>LSHandlerRank</key>
        <string>Alternate</string>
        <key>LSItemContentTypes</key>
        <array>
            <string>public.plain-text</string>
            <string>public.source-code</string>
            <string>public.json</string>
            <string>net.daringfireball.markdown</string>
        </array>
    </dict>
</array>

The important pieces are:

  • a stable, unique CFBundleIdentifier
  • CFBundleDocumentTypes
  • the UTIs you want to support
  • LSHandlerRank = Alternate if you want Finder visibility without aggressively taking over as the default app

Then register the built app with LaunchServices:

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f /path/to/MyEditor.app

If stale copies are interfering, unregister them first:

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -u /path/to/OldEditor.app

That solves a different class of confusion: the app is correct, but Finder is still pointing at an older bundle.

The fastest test loop

Using Finder for every iteration is slow.

Use this instead:

open -a /path/to/MyEditor.app /path/to/file.md

It exercises the same external-open path, but with much less friction.

That matters when you are debugging window routing, because this bug is mostly about timing and lifecycle. The faster you can repeat the sequence, the faster the architecture reveals whether it is truly correct.

Key takeaways

What changed

The app stopped treating external open as a view event.

The delegate started treating it as a window-routing event.

The target became a real NSWindow, not “whichever SwiftUI view hears it first.”

The file finally opened where the user expected: the active editor window, as a new tab.

Demo

In the demo below, Finder’s Open With is used to send a file to the app.

https://www.onedit.app

https://www.onedit.app

Instead of spawning a new window, the routing layer identifies the active editor window and delivers the open event directly to it. The file then appears as a new tab in the existing window, matching the behavior users expect from a native macOS editor.

This small change makes the workflow feel natural: external opens reuse the active editor when possible, and only create a new window when no editor window exists.

AI only gets real when you stop talking about it and start building with it.Used well, it unlocks what wasn’t possible before — and as it evolves, it keeps opening new paths and redefining how we do the old ones. That’s what I share here — what works, what breaks, and what’s worth understanding more deeply. **Follow along and subscribe** if you want to stay close to the edge.

[embed]About — Doran Gao — Medium Read writing from Doran Gao on Medium. Doran Gao builds AI-powered products and systems. Creator of TheOneQuote.app…medium.com


메타데이터
post_id
c4ef243ef000
slug
finder-sent-the-file-to-your-app-why-did-swiftui-open-another-window-anyway-c4ef243ef000
url
https://medium.com/@dorangao/finder-sent-the-file-to-your-app-why-did-swiftui-open-another-window-anyway-c4ef243ef000
canonical_url
https://medium.com/@dorangao/finder-sent-the-file-to-your-app-why-did-swiftui-open-another-window-anyway-c4ef243ef000
author_url
https://medium.com/@dorangao
status
ok
fetched_at
2026-07-12 03:01:25