.ics import
Every meeting that arrives as an email attachment is a .ics file. Magical reads the file, asks
which account it belongs in, and hands it to that account's Google Calendar through Google's own
Import pane. A double-clicked invite and File → Import Calendar File… reach the same flow.
sequenceDiagram
participant OS as macOS (Finder / Mail)
participant Handler as ics-handler.ts
participant Main as main.ts
participant Manager as accounts-manager.ts
participant GCal as Import pane (hidden view, chosen partition)
OS->>Handler: app.on('open-file', path.ics)
Handler->>Main: handleIcsFile(path)
Main->>Main: summariseIcs (title, start, event count)
Main->>Main: chooseEventAccount (which account?)
Main->>Manager: importIcsFile(accountId, path)
Manager->>GCal: load the pane, set the file, press Import
GCal-->>Manager: "Imported 1 out of 1 event."
Manager-->>Main: { kind: 'imported', imported: 1, total: 1 }
Main->>OS: native banner naming the account
File association
apps/magical/electron-builder.json declares the type, which writes the CFBundleDocumentTypes
entry for UTI com.apple.ical.ics:
{
"fileAssociations": [
{
"ext": "ics",
"name": "iCalendar File",
"description": "Calendar event",
"role": "Editor",
"icon": "build/icon.icns",
"mimeType": "text/calendar"
}
]
}
Declaring the type puts Magical in Open With and makes it a candidate handler. Becoming the default handler is a separate claim; see Claiming the default.
Opening a file
macOS hands Magical the path through open-file. watchForIcsFiles() runs as main.ts is
evaluated, alongside setAsDefaultProtocolClient, rather than from whenReady.
Double-clicking an invite while Magical is closed launches it and fires open-file before
whenReady: before the window, the accounts and their partitions exist. A listener registered at
the usual time is registered too late, and the one file that gets dropped is the file that started
the app. What watchForIcsFiles() catches before there is anything to do with it goes into a queue;
onIcsFile installs the real handler after the window exists and replays the queue into it. A path
that is not a .ics is dropped with a log line. argv is never read: macOS delivers documents to
the running instance through open-file whether or not the app was already open, so there is no
second path to keep in step.
File → Import Calendar File… (⇧⌘I) is the same import behind a file picker, for when Magical
is already open and the file is not in front of the user. It joins the flow at handleIcsFile, so
both routes meet there. The picker takes one file at a time: each file is a separate question about
which account it belongs in, and allowing several would ask that question repeatedly.
Describing the file
readIcsFile reads the file to a string, or returns null when it has been moved or deleted;
handleIcsFile then shows an alert and stops before the user is asked which account.
summariseIcs in ics-file.ts reads just enough of the file to name the import in the picker. It
unfolds continuation lines first: RFC 5545 folds any line over 75 octets by breaking it and
prefixing the remainder with a space or tab, so a long SUMMARY arrives in pieces and reading line
by line would truncate every title that needs this most. It counts VEVENT blocks and takes the
first event's SUMMARY and DTSTART, each from inside the first VEVENT only, so a
calendar-level SUMMARY never labels a two-hundred-event file with the feed's own name.
It is not a parser, and nothing it returns decides whether the import goes ahead: an unreadable file
still reaches Google, which holds the only opinion on validity that matters. DTSTART is read in
its local or UTC form only; a TZID names an Olson zone this does not resolve, so the time is taken
at face value.
describeIcs writes the picker's second line: the count and file name for a file holding several
events, otherwise the first event's title with its start.
Account picker
chooseEventAccount in account-picker.ts presents the picker, the same one File → New Event
uses, with the title and message naming the import. handleIcsFile passes the described file as the
detail line.
The account is always asked for when more than one exists: addEventAccountPicker: 'active' can
stand in for an answer only when the request came from the main window, and a file from Finder or
Mail has no account on screen to have been the one meant.
Import pane
ics-import.ts works Google's own controls at
https://calendar.google.com/calendar/u/0/r/settings/export. Google parses the file, so every
VEVENT, RRULE and VTIMEZONE is read by the code that already understands them, and Magical
never holds an opinion about the iCalendar spec. Google Calendar exposes no session-authenticated
endpoint for creating an event, so the file goes through the pane Google already provides. u/0 is
right for every account because each partition is signed into exactly one Google account.
Three controls
| Control | Addressed as | Why that handle |
|---|---|---|
| The file field | input[type="file"][name="filename"] |
name is the form's contract with Google's server, so it outlives the markup |
| The Import button | the button whose trimmed text is Import |
It carries no stable id, label or role beyond its own text |
| The result | the [role="dialog"] or [role="alertdialog"] containing Imported |
Google reports the outcome in a modal, not in a live region |
No minified class or jsname attribute is matched, in line with the project's DOM rules.
Putting the file in the input
A file input's value cannot be assigned from script, by design, so the file goes in over the
Chrome DevTools Protocol. attachFileToPane attaches webContents.debugger, resolves the input to
a remote object, and calls DOM.setFileInputFiles. Chromium dispatches the page's own change
handler as it would for a real selection.
Success is not assumed from the call returning. Google's Import button starts disabled and is
enabled by that change handler, so attachFileToPane waits until the button is enabled: an
enabled button is proof the file reached the form.
The three waits (the pane rendering its file field, the button becoming enabled, the verdict
appearing) run inside the page through waitUntil from @magimail/core's DOM_DRIVE_PREAMBLE, so
the pane is watched as it changes rather than re-read from the main process every 250 ms.
waitInPage carries the predicate across as a string, and the predicate has to yield a boolean or a
string: a DOM node does not survive the trip back.
Reading the verdict
Google answers in a modal whose text is Imported 1 out of 1 event., or Imported 0 out of 0 events. followed by its own reason. parseImportResult reads the two counts and returns one of
three outcomes:
| Outcome | Means | What the user sees |
|---|---|---|
imported |
At least one event landed | A banner naming the account |
rejected |
Google read the file and refused it | An alert carrying Google's own wording |
unconfirmed |
The pane gave no answer that could be read | The pane on screen, file already in it |
A partial import counts as imported: events landed. A rejection is a verdict on the file, and an
unconfirmed import means the page changed shape, which is not the user's problem to diagnose, so
Magical falls back rather than reporting a failure.
The dialog is dismissed on the way out. The view is the account's own in the fallback case, and leaving a modal over it would block everything the user does there next.
Hidden view
AccountsManager.importIcsFile builds a second WebContentsView on the chosen account's partition
(the same signed-in session Google sees) and never adds it to the window. Driving the import in the
account's own view would take the user's week away for the duration; the hidden view costs one
renderer for about two seconds, and the user's calendar stays on the day they were looking at. The
view is destroyed either way.
It is given the same bounds the account views get. Google lays the pane out against the viewport, and a zero-sized view leaves the controls unrendered for the selectors to miss.
When the outcome is unconfirmed, revealImportPane switches to the account and navigates its
visible view to the same pane with the file already in its input; preparePane loads the pane and
sets the file without pressing Import. A changed page then costs the user one click.
Import outcome
reportImport in main.ts turns the outcome into one message.
A finished import is a banner, posted through the helper's magical.notice category, which carries
no action buttons: the work is done, and Snooze on a finished import would offer to postpone
nothing. The banner is titled Imported to Calendar, subtitled with the account, and bodied with
the event count.
rejected and unconfirmed are both alerts, because each leaves the user with something to decide.
A rejection carries Google's own reason. An unconfirmed import says Google's pane is open in the
chosen account with the file already selected, and to press Import to finish.
Claiming the default
DefaultHandler.swift in the Swift helper claims the type with
NSWorkspace.setDefaultApplication(at:toOpen:), naming Magical.app as the handler for
com.apple.ical.ics. It lives in the helper because Electron cannot reach it:
app.setAsDefaultProtocolClient claims a URL scheme, and a .ics file is a content type
for which LaunchServices has no scheme to claim. The helper is the process carrying the app's bundle
identity, so it is the one that can name the bundle.
Per-type claims
macOS exposes no "default calendar app" role. Mail and the browser are the only two roles System
Settings offers; everything else is tracked per content type and per URL scheme, which is why
.ics, webcal: and ical: can be owned by three different apps at once on the same Mac.
DefaultHandler.Target mirrors that, and carries one case because .ics is the only thing Magical
can service.
The claim button
LaunchServices offers no way for an app to stop being a handler, so the settings row is a button. Once Magical holds the type the button disappears; the only way back is to name a different app in Finder's Get Info panel. A switch would carry an off position that could not work.
The row reads LaunchServices on every appearance rather than remembering what Magical last set, because the user can change the handler outside the app at any moment. Both states are the same sentence with a different app in it, so the row reads as one fact that changes.
Surfaces and when they ask
Three surfaces reach the same claim:
| Surface | Behaviour |
|---|---|
| Magical → Set Magical as Default for Calendar Files… | Always answers, including "already the default" |
| Settings → General, the Calendar files (.ics) row | Shows who holds the type; the button goes once Magical does |
| The launch prompt, once | Claim it, not now, or stop asking |
promptSetAsDefault is the menu item. It always puts a dialog up, ignoring
askAboutDefaultCalendarApp and answering even when Magical already holds the type: the user chose
the item deliberately, so a silent no-op would read as a broken menu.
offerToBecomeDefault is the launch prompt. It runs after the open-file handler is installed, so
a launch that came from a double-clicked file imports it rather than meeting a dialog first. It
offers three answers, and only "don't ask again" is stored, as askAboutDefaultCalendarApp in
settings.json.
Outside an app bundle there is nothing to register, so the launch prompt stays quiet, the menu item says the installed app is needed, and the settings row says the same rather than offering a button that would fail.