Magic Apps

Documentation

macmagic.app

Share Sheet, handlers and synthesised drag

Processes a share crosses

Content shared into or out of Magimail crosses three processes:

flowchart TD
    subgraph macOS["macOS System"]
        Finder["Finder / Safari / Third-Party Apps"]
        ShareExt["MagimailShare.appex (OS-Hosted Share Sheet)"]
    end

    subgraph ElectronMain["Electron Main Process"]
        URLRouter["URL Handler (magimail://share, mailto:)"]
        Picker["Account Picker (pickAccount)"]
        ComposeMgr["Compose Manager (triggerComposePopout)"]
    end

    subgraph NativeAddon["Native C++ Addon (native/)"]
        DragBridge["ToolbarBridge / nativeDropFiles()"]
    end

    subgraph Chromium["Gmail Compose WebContentsView"]
        DOM["Gmail Compose Dropzone DOM"]
    end

    Finder -->|Share Action| ShareExt
    ShareExt -->|Open URL magimail://share| URLRouter
    URLRouter --> Picker
    Picker --> ComposeMgr
    ComposeMgr --> DOM
    ComposeMgr -->|Dispatch File Paths| DragBridge
    DragBridge -->|Synthesise AppKit Drag Events| DOM

Inbound sharing

Share Sheet extension

MagimailShare.appex is a standalone macOS app extension bundled inside Magimail.app/Contents/PlugIns/:

Account picker coordination

A share can arrive when no account window is frontmost, so Magimail asks which account to use: handleShareUrl() receives the payload in the main process, and if multiple Google accounts exist it presents an AppKit account selection sheet ("Share content with which account?"). Once the user picks the destination, Magimail initialises or focuses that account's partition.

Synthesised AppKit file dragging

JavaScript cannot attach a local file

Gmail runs in a standard Chromium WebContentsView, and a browser only accepts a disk path in <input type="file"> if the user chose it in a file picker. Simulating the drop in JavaScript (new DragEvent('drop', { dataTransfer: ... })) fails too: Chromium's Blink engine checks that the backing OS drag pasteboard exists.

Magimail therefore synthesises Cocoa drag events through its N-API addon, so attaching the files costs the user no second prompt:

sequenceDiagram
    participant Main as Electron Main (share-handler.ts)
    participant Cpp as Native Addon (magimail_addon.mm)
    participant Cocoa as macOS Cocoa Window Server
    participant View as Chromium WebContentsView

    Main->>Main: waitForComposeElements() (Wait for To/Subject/Body DOM)
    Main->>Cpp: nativeDropFiles(windowHandle, [filePaths])
    Cpp->>Cocoa: Create NSPasteboard (NSFilenamesPboardType)
    Cpp->>Cocoa: Synthesise NSEvent (.leftMouseDragged, .leftMouseUp)
    Cocoa->>View: Dispatch OS Drag & Drop Info
    View->>View: Gmail native dropzone activates
    Note over View: Files uploaded directly as Gmail attachments
  1. waitForComposeElements() polls the compose view until the Gmail compose DOM is rendered and interactive.
  2. nativeDropFiles calculates the screen coordinates of the compose dropzone, then invokes Cocoa's NSDraggingInfo event pipeline directly on the window's underlying NSView.
  3. Gmail treats the drop as a real drag from Finder, attaching the files with its usual progress bars and preview chips.

Outbound sharing and export

Magimail exposes native macOS sharing and export through File → Share:

URL schemes and default mail client

Standard mailto: client

Magimail declares mailto URL scheme handling in Info.plist. apps/magimail/src/mailto.ts parses an incoming mailto: query, pulling out to, cc, bcc, subject and body. The native account picker appears before navigation, so the message composes from the account the user chose.

Deep link routing

The app handles custom protocol routes via app.setAsDefaultProtocolClient('magimail'):

Route Payload Parameters Action Performed
magimail://share files, text, url, to, su Opens compose window and synthesises file drop attachment.
magimail://compose account, to, su, body Launches pop-out compose window for specified account.
magimail://inbox account Focuses main window and switches to account inbox.
magimail://thread/<id> account, action Opens the thread, or triages it where action says so.

The magimail://share query parameters are:

Parameter Type Multi-value Description Example
files string (path) Yes (repeatable) Absolute local filesystem path to an attachment. Multiple files parameters can be supplied to attach multiple files. files=/Users/casey/doc.pdf&files=/Users/casey/chart.png
text string No Text snippet to include in the email compose body. text=Check%20out%20this%20proposal
url string No Web URL to append to the email compose body. url=https%3A%2F%2Fexample.com
subject string No Compose subject line. Also accepted as su. subject=Q3%20numbers
to string No Pre-filled recipient. to=alex%40example.com

action takes open (the default), archive, trash, markRead and markUnread: the same set a notification's buttons offer, and the same call they make, so every action the app can take on a thread is reachable without waiting for a notification to fire. Anything else is refused and logged rather than treated as open, because doing something other than what a link asked for is worse than doing nothing. Only open raises the window; triage leaves it where it is.

Becoming the app macOS opens mailto: with

Three surfaces reach the same claim:

Surface Behaviour
Magimail → Set Magimail as Default Email Reader… Always answers, including "already the default"
Settings → General, the mailto: row Shows who holds the scheme; the button goes once Magimail does
The launch prompt, once Claim it, not now, or stop asking

The menu item and the launch prompt are promptSetAsDefault and checkOnStartup in default-client.ts, and both claim the scheme through app.setAsDefaultProtocolClient('mailto'). Only "don't ask again" is stored, in default-client-preference.json under the user data directory.

The settings row is DefaultHandlerRow in the Swift helper, fed by SettingsModel.refreshHandlers(). It lives there because Electron answers only whether Magimail holds the scheme, never which app holds it instead; NSWorkspace.urlForApplication names that app, and DefaultHandler.swift asks it. Magical reaches LaunchServices from its helper too, for a different reason: .ics is a content type, and Electron can claim only URL schemes. See ics-import.md's Claiming the default.

The row reads LaunchServices on every appearance rather than remembering what Magimail last set, because the user can name a different reader in System Settings at any moment. The button goes once Magimail holds the scheme, because LaunchServices offers no way to stop being a handler; the only way back is to name another app in System Settings. Outside an app bundle, as under pnpm dev, there is nothing to register, so the row says the installed app is needed rather than offering a button that would fail.

Staging and cleanup

Both halves of sharing put readable mail on disk: an outbound share renders the thread to a PDF the sharing service reads, and an inbound share arrives as files the extension has already copied. Neither is content the app needs once the operation is over, and share-staging.ts is the one module that makes these files and the one module that removes them.

Removal

Staging Written by Removed when
$TMPDIR/magimail-share-<ts>/<subject>.pdf shareActiveThreadAsPdf The chosen sharing service reports it has read the file
<group>/SharedFiles/<uuid>/ ShareViewController.swift handleShareUrl returns, whichever way the share ended
$TMPDIR/magimail-share-<ts>/<attachment> share-handler.ts 30 seconds after the synthesised drop, once Gmail has uploaded

The PDF waits on a real answer rather than a timer. showSharingPicker stands its delegate in as the chosen service's delegate and calls back on didShareItems:, on didFailToShareItems:, or straight away when the sheet closes with nothing chosen. A timer would have to outlast an AirDrop transfer to be safe, and would still take the file out from under a slower one. The staged original goes in a finally, so a cancelled account picker and a compose window that never opened clear it as surely as a successful attachment does.

Guarding removal

The inbound file list arrives from a magimail://share URL, and any process on the machine can open one of those with any path in it. A path is therefore removed only once it has been shown to be a staging directory owned by the app, resolved through realpath so no .. or symlink reaches past the check:

A build with no entitlement for the group resolves no container, so it can prove nothing about the path it was handed and removes nothing. Anything that fails a check is logged and left alone.

Startup sweep

sweepAbandonedShareStaging runs once from whenReady and takes both kinds, so a build that stops the leak also clears what earlier ones left behind, and it collects staging from a crash between writing a file and sharing it. It takes only directories last written more than an hour ago: a share arriving while Magimail is closed launches it, so staging written seconds ago can be the very file the share is waiting to attach, and a sweep with no age bound would race the share that started the app.