A license client with no Keychain: a 0600 file, weekly revalidation and a soft wall
How NotchStar stores a license key on a Mac, when it asks the server again, and what happens when the trial ends
TL;DR
- NotchStar keeps its trial start and license in one small JSON file in Application Support, mode
0600, and does not use the Keychain. The reason is written in the source: an ad hoc signed app can get a Keychain prompt after every update. - The license is checked with the server at most once a week, silently. Only an explicit
valid: falsecan revoke it. Being offline, a timeout or a server error changes nothing. - When the trial ends, only the tool area is replaced by a prompt. Notes, clipboard and settings are not touched. The price of this design is that the file can be edited by its owner, and the post says so.
The three rules
The whole licensing layer is 382 lines of Swift in Sources/NotchStar/Licensing/ (counted with wc -l on 4 November 2026). Five files: a store, a manager, a Dodo Payments client, a trial policy and the views. Most of the design is already visible in the doc comment on LicenseManager:
/// Owns the trial and the licence. The rules, in order of importance:
/// 1. Never delete or hide the user's data. A lapsed trial only replaces the tool area with a prompt.
/// 2. Being offline never revokes a licence. Only an explicit `valid: false` from Dodo can.
/// 3. Check in at most once a week, silently.
Those three lines drive every decision below. A small app sold once has little to protect, and a lot to lose from a false lockout: a person on a plane, behind a company firewall or in a tunnel who opens the notch and finds a paywall. The app asks the question “is this still valid” rarely, and treats “I could not find out” as “yes”.
The client talks to Dodo Payments, the merchant of record, through its public license endpoints. Those need no API key, which means no secret ships inside the app. A test enforces that: testActivateSendsTheRightRequestAndNoCredentials asserts the request has no Authorization header. The details of that API are in a separate post on Dodo’s gotchas. This post is about what the app does with the answers.
What is stored
Two small value types hold everything:
struct LicenseRecord: Codable, Equatable {
var key: String
var instanceID: String
var email: String?
var activatedAt: Date
var lastValidatedAt: Date
var revoked = false
}
struct LicensePersisted: Codable, Equatable {
var trialStart: Date?
var license: LicenseRecord?
}
The trial start and the license live in the same value. That is deliberate and it has a consequence that comes up again in the limits section. instanceID is what Dodo returns when a key is activated on a Mac. It identifies that one activation, so a later check or a deactivation can name it. revoked is set only when Dodo says the key is no longer valid, for example after a refund.
The store is a protocol with two methods, load() and save(_:). There are two implementations: MemoryLicenseStore, used by tests, demo mode and the UI tests, and FileLicenseStore. Because the manager only sees the protocol, every rule in the next sections is tested against memory with a clock the test controls.
The wrong turn: the Keychain
The obvious place for a license key on macOS is the Keychain. It was ruled out, and the reason is the comment above FileLicenseStore:
/// A small owner-only JSON file in Application Support. Deliberately not the Keychain: an ad-hoc-signed app
/// gets a Keychain prompt after every update because its signature changes. Revisit once the app is
/// Developer ID signed.
At release 1.0.0 on 7 October 2026 the app was ad hoc signed (blog/FACTS.md, section A). An ad hoc signature changes with every build, and the Keychain decides whether an app may read an item by the app’s code signature. The comment records the expected outcome: a password prompt after each update, for an app whose whole pitch is that it stays out of the way. That behaviour was not reproduced for this post, so treat it as the source’s stated reason and not as a measurement.
The comment ends with a promise to revisit. Since 1.0.3 on 9 October 2026 the app has been signed with a Developer ID and notarized, so the original reason may no longer apply. The comment is still there, and the file store is still what ships. That is a decision waiting to be made again, not a settled one.
A 0600 file
Here is the save path:
func save(_ value: LicensePersisted) {
lock.lock(); defer { lock.unlock() }
let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
guard let data = try? encoder.encode(value) else { return }
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try? data.write(to: url, options: .atomic)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
Three details matter. The write is atomic, so a crash halfway never leaves a half written file. A lock serializes load and save, since both can be called from the main actor and from the weekly check. And the permissions are set after the write, to 0600: the owner can read and write, nobody else can. On a shared Mac, another user account cannot read the key. FileLicenseStoreTests.testRoundTripAndOwnerOnlyPermissions asserts the mode is exactly 0o600.
Loading is the other half:
guard let data = try? Data(contentsOf: url) else { return LicensePersisted() }
let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode(LicensePersisted.self, from: data)) ?? LicensePersisted()
A missing or corrupt file reads as an empty value. The comment next to it says why: a corrupt file must never crash the app. testCorruptFileFallsBackToEmptyInsteadOfCrashing writes the text not json and checks that no license comes back.
Is the order safe
Setting the permissions after the write raises a fair question: is there a moment when the file exists with looser permissions? A short Swift script answers it. It writes a file atomically, sets 0600, then writes again over it. Run on 4 November 2026 on macOS 27.0.1 with Swift 6.4 and a umask of 022:
after atomic write: 644
after setAttributes: 600
after second atomic write over a 0600 file: 600
So on the first save, between the write and the permission change, the file is 0644. On later saves Foundation keeps the existing mode when it replaces the file, so the window does not reopen. The application support folder itself is 755 on this Mac. The first save happens at first launch, when the trial starts and the file holds only a date, so no key is exposed in that window. The key is written later, on activation, over a file that is already 0600. This is reasoning from the code plus the measurement above, not a test in the repo. Creating the file with the right mode in the first place (for example by writing through a file descriptor opened with 0600) would remove the question, and it is a small change.
On the author’s Mac the real file is 600, checked with stat -f '%Lp' ~/Library/Application\ Support/NotchStar/license.json on 4 November 2026. That command prints the mode only, never the contents.
The trial is a pure function
The trial is counted in whole days from the first launch:
enum TrialPolicy {
static let days = 7
static func elapsedDays(start: Date, now: Date) -> Int {
max(0, Int(now.timeIntervalSince(start) / 86_400)) // a clock set backwards counts as day 0
}
static func daysLeft(start: Date, now: Date) -> Int { max(0, days - elapsedDays(start: start, now: now)) }
static func hasEnded(start: Date, now: Date) -> Bool { elapsedDays(start: start, now: now) >= days }
}
There is no state in it, only dates in and numbers out, which is why four short tests cover its edges. One is worth a look: testAClockSetBackwardsDoesNotExtendBeyondTheFullTrial sets the clock ten days before the start and expects 7 days left, not 17. The max(0, ...) is what does it. The trial can never be longer than seven days by moving the clock back, though it can still be stretched by moving the clock back before the day 7 check and keeping it there, which is the nature of any local clock.
The manager turns a persisted value into a state:
private static func compute(_ p: LicensePersisted, now: Date) -> LicenseState {
if let l = p.license { return l.revoked ? .revoked : .licensed(email: l.email) }
let start = p.trialStart ?? now
return TrialPolicy.hasEnded(start: start, now: now) ? .trialEnded : .trial(daysLeft: TrialPolicy.daysLeft(start: start, now: now))
}
A license always wins over the trial. There are four states: trial(daysLeft:), trialEnded, licensed(email:) and revoked. Only the first and third count as active.
Weekly revalidation
The check runs once per launch from the app delegate, in a task that nothing waits for:
Task { await LicenseManager.shared.revalidateIfDue() } // silent, at most weekly, never blocks
And the method itself:
func revalidateIfDue() async {
var p = store.load()
guard var l = p.license, !l.revoked,
now().timeIntervalSince(l.lastValidatedAt) >= Self.revalidateInterval else { return }
guard let valid = try? await client.validate(key: l.key, instanceID: l.instanceID) else { return }
if valid { l.lastValidatedAt = now() } else { l.revoked = true }
p.license = l; store.save(p); refresh()
}
Read it from the top. If there is no license, or it is already revoked, or fewer than seven days have passed since the last good check, it returns without a network call. If the call throws for any reason, the try? turns that into nil and the guard returns. Only a successful answer changes anything, and only false revokes.
The client supports that rule from its side. Its validate returns true only if Dodo says so, and “anything unclear throws, so a hiccup is never mistaken for a revoked licence”. A 200 with a body it cannot decode throws. A 500 throws. A timeout, set to 15 seconds in the request, becomes LicenseError.network, which also throws. The one non 200 answer that means “no” is a 404, and that returns false.
The tests read like the rules:
| Test | What it proves |
|---|---|
testBeingOfflineNeverRevokesALicence | 90 days with the network down: still licensed |
testRevalidationIsRateLimitedToOncePerWeek | 2 days later: zero requests. 6 more days: exactly one |
testRefundedOrRevokedKeyStopsWorkingAfterRevalidation | 8 days later, valid: false gives revoked |
testActivationPersistsAndSurvivesRelaunch | 400 days later, a new manager is still licensed with no network |
testDeactivateStillClearsLocallyIfOffline | removing a license from this Mac never needs the server |
The full licensing test file has 22 test functions (grep -c "func test" Tests/NotchStarTests/LicensingTests.swift, 4 November 2026): 4 for the trial, 6 for the Dodo client, 9 for the manager, 2 for the file store and 1 live test against Dodo’s test API that is skipped unless a test key is supplied.
The soft wall
When the state is not active, the root view of the notch swaps the tool area for a prompt:
ZStack {
if !model.license.state.isActive {
LicenseWall(license: model.license).transition(.opacity)
} else if let tool = model.selectedTool {
tool.makeView().id(tool.id).transition(.opacity)
}
}
The tab bar, the pin and Settings stay usable. The wall says either “Your 7 day trial has ended” or, for a revoked key, “This license is no longer active”, with a note that it may have been refunded and an address to write to. It offers two buttons: buy, and “I have a key”, which opens the license tab of Settings. The text for the trial case says the notes, clipboard and settings are untouched, and that is literally true: nothing in the licensing code deletes or hides data, and testTrialEndsButNothingIsDeleted checks the state change and nothing else.
“Soft” here means two things. The wall hides the tools instead of deleting anything, and it can be undone by activating a key at any time. A licensed user who is refunded sees the same wall and their data is still there.
Deactivation follows the same spirit. It frees the Mac’s slot on the server, but it always removes the license locally, even if the request fails:
if let l = p.license { try? await client.deactivate(key: l.key, instanceID: l.instanceID) }
p.license = nil; store.save(p); refresh()
A person must always be able to take a license off a Mac, whether or not the server answers. The app’s own error text for a fourth activation tells the user to deactivate one of their three Macs from its Settings.
What is still bad
- The file can be edited. It is plain JSON with no signature.
computetrusts alicenserecord and does not ask the server on load, so a hand written record with any key and instance ID reads as licensed until the next weekly check, and the check only revokes when the server saysvalid: falsefor that pair.0600keeps other users out. It does nothing against the person who owns the account, and nothing against a process that runs as that person. - The key is stored in plain text. The Keychain would encrypt it at rest. The file does not.
- The trial can be reset. The trial start sits in the same file. Deleting it makes the next launch start a fresh seven days. A hard to reset trial would need something outside the user’s home folder, and that conflicts with rule 1 and with a product that promises no account.
- A 404 revokes. In
validate, a 404 returnsfalse, which the manager treats as revoked. If Dodo ever answers 404 for a reason other than a missing license, a valid key would be revoked after the next due check. Today the live API answers a wrong key with 200 and{"valid":false}(checked on 4 November 2026), so the 404 branch may never fire. It is still a branch that trusts a status code. - First save is briefly
0644. Described above. It holds only a date at that point. - Not exercised end to end. The 3 device limit and the weekly check were tested against Dodo’s test API and a mock. The live purchase flow has not been run end to end by an agent (
blog/FACTS.md, section C). - The Keychain question is open. The code comment says to revisit it once the app is Developer ID signed. It has been signed since 1.0.3 and the file store remains.
Try it yourself
You can build the same pattern without this app. Four steps:
- Put everything the app remembers in one
Codablevalue and hide storage behind a two method protocol,load()andsave(_:). Ship a memory implementation next to the file one. - Inject the clock. Pass
now: () -> Dateinto the manager and never callDate()inside the rules. Every test in the table above depends on this. - Write the file atomically, then set
0600. A minimal version:
import Foundation
func saveOwnerOnly(_ data: Data, to url: URL) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: url, options: .atomic)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
- Make the server check return
Boolor throw, never a third state, and let onlyfalsechange anything. Then write the offline test first: set the network handler to throwURLError(.notConnectedToInternet), move the clock 90 days and assert the state did not change.
To check a mode from Terminal, print it and compare it to 600:
stat -f '%Lp %N' ~/Library/Application\ Support/YourApp/license.json
Cheat sheet
| Decision | What NotchStar does | Where |
|---|---|---|
| Where the key lives | JSON file, mode 0600, Application Support | LicenseStore.swift |
| Why not the Keychain | Prompt after each update on an ad hoc signed app (source comment) | LicenseStore.swift |
| Trial | 7 whole days from first launch, clock set back counts as day 0 | TrialPolicy.swift |
| Server check | At most every 7 days, once per launch, silent | LicenseManager.swift |
| What revokes | An explicit valid: false or a 404, nothing else | DodoLicenseClient.swift |
| Offline | Never changes the state | testBeingOfflineNeverRevokesALicence |
| Request timeout | 15 seconds | DodoLicenseClient.post |
| After the trial | Tool area replaced by a prompt, data untouched | LicenseViews.swift |
| Corrupt file | Reads as empty, never crashes | FileLicenseStoreTests |
- State the rules before the code, in a doc comment
- Hide storage behind a protocol and inject the clock
- Treat “could not find out” as “still valid”
- Rate limit the check and make it silent
- Never delete or hide user data when a license lapses
- Always let a user remove a license locally
- Write down what the file does not protect against
If you want to see the result in the notch, NotchStar is at notchstar.com. The licensing layer is small enough to read in one sitting.
NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.