The refactor that made a native app take a day
The Tauri shell for Cuewise took a day. The day before it, I deleted every direct chrome.* call from the code that mattered. Those aren’t two facts. They’re one.
That earlier day went into a pull request that adds no feature, fixes no bug, and leaves every test asserting exactly what it asserted before: 49 files, 1,500 lines added, 774 deleted, no behaviour change (#162). It looks like a wasted day. It’s the only reason the next one worked.
I’m writing for anyone staring at a codebase welded to one platform’s API, wondering whether to move it or rewrite it. The answer is usually neither, not yet.
What a native app is actually for
Cuewise is a new-tab Chrome extension. That sentence contains its own ceiling: it runs when you open a tab, and not otherwise. A reminder set for 3pm depends on Chrome being awake and the extension’s service worker being alive to hear the alarm.
Before building anything I read the competition, weighting criticism over praise. The break-reminder and posture-nudge market is crowded, and posture sensing turns out to be table stakes rather than a moat: four of the eight apps I surveyed already ship it. So the native app’s job was never “add posture.” It was to be resident, so a nudge arrives when the window is closed.
That is the one thing an extension structurally cannot do. Everything else is a feature you can add later, to an app that exists.
What was in the way
Three platform capabilities, hard-coded. chrome.alarms for scheduling, chrome.notifications for delivery, chrome.storage for persistence. Called directly, from stores and helpers, everywhere.
None of them exist inside a WKWebView. Neither does the global chrome object they hang off.
Three driven ports
So before writing a line of Rust I put the three capabilities behind interfaces, with one small container in @cuewise/shared/platform:
configurePlatform({ scheduler, notifier, storage });
// Portable code never names a platform:
const scheduler = getScheduler();
await scheduler.scheduleAt(reminder.id, reminder.dueAt);
The extension supplies ChromeScheduler, ChromeNotifier and ChromeKeyValueStore, calls configurePlatform once at startup, and nothing downstream knows what it got. This is ports and adapters, a twenty-year-old idea and not remotely clever. In Cockburn’s vocabulary these are driven ports: capabilities the core reaches out for, as opposed to the driving ports a user interface calls into.
Two older names fit the same code. Keeping the interfaces in @cuewise/shared while the Chrome* implementations live at the app edge is Fowler’s Separated Interface. configurePlatform is his Plugin, which he defines as linking classes during configuration rather than compilation.
The discipline was in refusing to do anything else at the same time. Every Chrome adapter preserved exactly what the code did before. Tests that used to reach for a global chrome object now inject a fake, which made them shorter, and the suite stayed green throughout: 249 in shared, 12 in storage, 708 in the extension.
A good port names the differences
Here’s the part I’d have got wrong a few years ago. The instinct with an abstraction like this is to make the platforms look identical, so the code above can forget where it’s running. That instinct is what produces leaky abstractions.
The Scheduler port does the opposite. It exposes two booleans whose entire job is to admit that platforms differ:
export interface Scheduler {
/** Delivered by a resident background context, or must the page poll? */
readonly deliversInBackground: boolean;
/** Do armed wakes survive a process restart? (chrome.alarms does.) */
readonly persistsAcrossRestarts: boolean;
scheduleAt(id: string, when: Date): Promise<void>;
cancel(id: string): Promise<void>;
}
Front-end engineers have known this rule for twenty years under a different name. You detect the feature, not the browser. MDN calls browser sniffing “a terrible practice that should be discouraged at all costs,” and it’s right. A driven port is that same rule aimed at the operating system instead of the browser. Ask what a platform can do; never ask which platform it is.
deliversInBackground is that rule made literal. It replaced UI code that sniffed for chrome.alarms to decide whether to trust the scheduler or poll the page instead. Under WKWebView that sniff threw a ReferenceError. Now the component asks the port about capability rather than identity.
persistsAcrossRestarts is subtler, and I didn’t see it coming. chrome.alarms survive a browser restart. An in-memory native timer does not. Something has to re-arm pending reminders from storage on startup, on the platform that needs it and only there, or the extension double-fires every reminder it already had armed.
A third distinction sits in the same file. Only a resident context can receive a fire, so onFire lives on SchedulerHost, not on Scheduler. The stores depend on Scheduler. A store trying to subscribe is a compile error rather than a silent no-op, which you get for free once you’ve bothered to name the two roles apart.
The habit stuck. Two days later the storage port grew a supportsSync flag (#173), because Chrome’s cross-device sync rides on chrome.storage.sync and a native app has no such area, which left the settings toggle inert and misleading there. The extension still shows the row. macOS hides it. Neither one asks which platform it is.
Shipping a shortcut on purpose
The shell itself was mostly plumbing (#163): a Tauri 2 app, Rust core, WKWebView, menu-bar tray, real Notification Center notifications, reusing the entire extension UI. Same React, same Zustand stores, same components.
It shipped with a hack. To mount that UI from a different app directory I pointed a Vite alias called @ext at the extension’s src, plus a loose type shim to keep TypeScript quiet. That is not an architecture. That’s a symlink wearing a hat.
I shipped it anyway, and designed the real thing after watching the shell run instead of before. It landed the same day (#165): the shared UI moved into a @cuewise/app package both apps now consume identically. 247 files, +284/−1138, a net deletion of about 850 lines. Behaviour was provably unchanged, because the shell had been running that code through the bridge the whole time I built it.
One thing earned its keep during bring-up: a Playwright suite pinned to the WebKit engine, which is roughly what WKWebView gives you, failing on any console error. It caught three real Chrome-versus-WebKit gaps before I would have found them by hand. One was a Notification.requestPermission() call on mount, which WebKit blocks and Chrome tolerates.
Why it was the right choice
The macOS app had been running a NoopScheduler, a polite way of saying reminders didn’t fire while the window was hidden. That is the one thing it exists to do, so this was the exam.
A hidden webview’s setInterval gets throttled, so the wake had to leave JavaScript. Not every throttled timer does. A countdown that ran slow can ask the wall clock how much time actually passed and correct itself on the next tick, which is how the Pomodoro timer stays honest with no Rust at all. But a wake that never fires has nothing to reconcile against. Rust owns the timer, JavaScript owns delivery. schedule_wake and cancel_wake manage a map of tokio timers in the Rust core; on fire, the core emits a scheduler://fire event carrying the wake id, and that event wakes the JS runloop to process it. TauriScheduler implements SchedulerHost by forwarding scheduleAt to invoke and onFire to listen.
What matters is what I didn’t touch. No store changed. No reminder logic changed. Swapping a native Rust timer in for chrome.alarms was a 19-file change (#166), because the shape of “arm a wake, hear it fire” had been fixed days earlier and neither platform got to define it.
Then persistsAcrossRestarts: false did its job unprompted: the reminder store re-arms pending wakes on startup under Tauri and skips it entirely under Chrome. Nobody wrote a conditional. The port had already said which platform needed it.
The harder proof arrived two days after the ports did, when posture tracking landed (#169). A Swift sidecar running Apple’s Vision framework, a camera session, a Rust process spawn, JSON over stdio: 884 new lines. Every one of them sits under apps/macos/, except two type definitions in @cuewise/shared that exist only at compile time. The extension bundle came out byte-identical, its tests untouched. A whole native capability arrived and the browser build never noticed.
What it cost
A day, and the shortcut was paid down before the day was out. Against that: the Rust scheduler landed without touching a single store or reminder rule.
The honest caveats. If macOS App Naps the process after a long idle, the first nudge can arrive a few seconds late; the ones after it are accurate. Making that bulletproof means moving delivery into Rust too, passing the notification text at schedule time, which I’ve left alone. And the Rust core still isn’t built in CI, so cargo check stays a local step, though both native languages now get static analysis there whenever the macOS app changes.
One last piece of accounting. All of it, ports included, came to less than a day of hands-on work, most of it written with an AI pair. That doesn’t weaken the argument for cutting ports first; it’s what makes it affordable. A refactor that adds no features is the first thing you skip when you’re impatient, and impatience is usually rational: nobody has a week to spend moving 247 files by hand. When it costs an hour, doing it in the right order stops being a luxury.
The ports made the move cheap. AI made the ports cheap.
So: if you’re about to move an app onto a new platform, the move is not the work. Find the four or five places your code says the platform’s name out loud, and give each one a driven port first. Do it as a change that adds no features, so the diff is reviewable and the tests prove you changed nothing. Then let the port tell the truth about where the platforms differ, instead of papering over it. Two booleans on a scheduler saved me from if (isChrome) in a Zustand store, and I’d make that trade every time.