diff --git a/CLAUDE.md b/CLAUDE.md index 2e13666..b4f40e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,25 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw (title/task/secondary/completed/checkbox) for a `Surface` (`.solid` uses per-theme tuned inks; `.glass` uses adaptive `.primary`/`.secondary`). Views read a resolved `NoteTheme`, never branch on material themselves. +- **Updates are check-only (`UpdateChecker`), never auto-install.** Tic is ad-hoc signed, so a + self-replacing updater (Sparkle) can't be trusted. Daily GET of the GitHub `releases/latest` API, + compared numerically against `CFBundleShortVersionString` (nil under `swift run` → checker off). + A newer tag shows a menu-bar item, a dot on the menu-bar icon until the menu is opened or the + item clicked (`isUnseen`, remembered in the `updateSeenVersion` default; "opened" is detected via + `NSMenu.didEndTrackingNotification` on a menu containing our item; the dot is **drawn into a + second template `NSImage`** because the status item renders only the label's image, never a + SwiftUI overlay), and **one** notification + banner per version (`updateNotifiedVersion`; authorization is requested only then, so the system + prompt appears in context). `UNUserNotificationCenter` needs a bundle id, so both the delegate + and the banner are guarded. `package.sh` defaults `VERSION` to the latest git tag so a local build + isn't "outdated". + - **Seeing it locally:** `VERSION=0.1.0 ./scripts/package.sh --open` shows the menu item and the + dot. The banner is stricter: macOS refuses notification authorization (`UNErrorDomain` code 1, + "Notifications are not allowed for this application") for an ad-hoc bundle **outside + /Applications**, and the refusal seemed to stick to the bundle id afterwards. So to test the + banner, copy the build to `/Applications/Tic Dev.app`, give it a unique `CFBundleIdentifier` + (PlistBuddy), re-sign ad-hoc, then `open` it. Re-arm with + `defaults delete updateNotifiedVersion updateSeenVersion`, and delete the copy after. - **Models are sync-friendly.** `Note`/`TaskItem` use `UUID` PKs + `updatedAt`; relationships and defaults are chosen so CloudKit/iCloud sync stays feasible later (currently local-only). diff --git a/Packaging/Info.plist b/Packaging/Info.plist index 0133956..9f97bb2 100644 --- a/Packaging/Info.plist +++ b/Packaging/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.0 + 0.0.0 CFBundleVersion 1 LSMinimumSystemVersion diff --git a/Sources/Tic/AppDelegate.swift b/Sources/Tic/AppDelegate.swift index 49413a7..efe0318 100644 --- a/Sources/Tic/AppDelegate.swift +++ b/Sources/Tic/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import UserNotifications /// App lifecycle owner. As a plain SwiftPM executable (no .app bundle yet) we must explicitly /// adopt a regular activation policy so Tic gets a Dock icon and can take foreground focus. @@ -11,6 +12,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.setActivationPolicy(.regular) Task { await AppModel.shared.bootstrap() } installNewNoteShortcut() + // Update banners (UpdateChecker). `current()` throws without a bundle id (swift run). + if Bundle.main.bundleIdentifier != nil { + UNUserNotificationCenter.current().delegate = self + } } /// ⌘N → New Note while Tic is the active app. A menu-style `MenuBarExtra` button's @@ -49,3 +54,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { AppModel.shared.newNote() } } + +/// Update-banner delegate: show it even while Tic is frontmost (macOS hides a frontmost app's +/// notifications by default), and a click opens the release page. +extension AppDelegate: UNUserNotificationCenterDelegate { + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .list] + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse + ) async { + await MainActor.run { AppModel.shared.updates.openReleasePage() } + } +} diff --git a/Sources/Tic/AppModel.swift b/Sources/Tic/AppModel.swift index 8233e76..e224ba6 100644 --- a/Sources/Tic/AppModel.swift +++ b/Sources/Tic/AppModel.swift @@ -14,6 +14,7 @@ final class AppModel { let database: AppDatabase let windows: NoteWindowManager + let updates = UpdateChecker() /// All saved notes (ascending `sortIndex`, i.e. creation order) — drives the menu bar list. /// Stays in sync as notes are created, renamed, or deleted. @@ -38,6 +39,7 @@ final class AppModel { await windows.restoreAll() startObservingNotes() applyLaunchAtLogin(launchAtLogin) // honor the saved preference (effective when packaged) + updates.start() } private func startObservingNotes() { diff --git a/Sources/Tic/TicApp.swift b/Sources/Tic/TicApp.swift index 35dc7f2..71bd8cf 100644 --- a/Sources/Tic/TicApp.swift +++ b/Sources/Tic/TicApp.swift @@ -17,23 +17,44 @@ struct TicApp: App { } /// The status-bar icon: a template-rendered menu-bar glyph (bundled via SPM resources), falling -/// back to an SF Symbol if the resource can't be found. +/// back to an SF Symbol if the resource can't be found. A small dot marks an update the user +/// hasn't looked at yet (`UpdateChecker.isUnseen`). The dot is drawn *into* the image: the status +/// item renders only the label's image, so a SwiftUI overlay never shows. private struct MenuBarLabel: View { + private var updates: UpdateChecker { AppModel.shared.updates } + var body: some View { - if let icon = Self.icon { + if let icon = updates.isUnseen ? Self.badgedIcon : Self.icon { Image(nsImage: icon).renderingMode(.template) } else { - Image(systemName: "checklist") + Image(systemName: updates.isUnseen ? "checklist.checked" : "checklist") } } - private static let icon: NSImage? = { - guard let url = menuBarIconURL(), - let image = NSImage(contentsOf: url) else { return nil } + private static let glyph: NSImage? = { + guard let url = menuBarIconURL() else { return nil } + return NSImage(contentsOf: url) + }() + private static let icon = glyph.map { compose($0, dot: false) } + private static let badgedIcon = glyph.map { compose($0, dot: true) } + + /// 20×18 template image: the 18pt glyph plus (optionally) a 6pt dot at the top-right, with a + /// knocked-out ring so it reads as a badge. Both variants share a size so nothing shifts. + private static func compose(_ glyph: NSImage, dot: Bool) -> NSImage { + let image = NSImage(size: NSSize(width: 20, height: 18), flipped: false) { _ in + glyph.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18)) + if dot { + let dot = NSRect(x: 14, y: 11, width: 6, height: 6) + NSGraphicsContext.current?.compositingOperation = .destinationOut + NSBezierPath(ovalIn: dot.insetBy(dx: -1.5, dy: -1.5)).fill() + NSGraphicsContext.current?.compositingOperation = .sourceOver + NSBezierPath(ovalIn: dot).fill() + } + return true + } image.isTemplate = true - image.size = NSSize(width: 18, height: 18) return image - }() + } private static func menuBarIconURL() -> URL? { let fileManager = FileManager.default @@ -100,6 +121,11 @@ private struct MenuBarContent: View { set: { model.setLaunchAtLogin($0) } )) + if let title = model.updates.menuTitle { + Divider() + Button(title) { model.updates.openReleasePage() } + } + Divider() Button("Quit Tic") { NSApplication.shared.terminate(nil) } diff --git a/Sources/Tic/UpdateChecker.swift b/Sources/Tic/UpdateChecker.swift new file mode 100644 index 0000000..949ea43 --- /dev/null +++ b/Sources/Tic/UpdateChecker.swift @@ -0,0 +1,197 @@ +import AppKit +import Foundation +import Observation +import UserNotifications + +/// Checks GitHub Releases for a newer Tic and surfaces it quietly: a menu-bar item while an update +/// exists, a dot on the menu-bar icon until the user has looked, and one notification banner per +/// new version. Check-only on purpose — Tic is ad-hoc signed, so a self-replacing updater +/// (Sparkle) can't be trusted; the release page is the download. +@MainActor +@Observable +final class UpdateChecker { + struct Release: Sendable, Equatable { + let version: String // "0.6.0" (tag without the leading "v") + let url: URL // the GitHub release page + } + + /// A release newer than the running build, if the last check found one. + private(set) var available: Release? + + /// True while `available` hasn't been looked at yet — drives the dot on the menu-bar icon. + /// Cleared (and remembered per version) once the menu is opened with the item in it, or the + /// item is clicked. + private(set) var isUnseen = false + + /// The menu item's title; also how the menu-tracking observer recognises our menu. + var menuTitle: String? { available.map { "Update Available: v\($0.version)…" } } + + /// The running build's `CFBundleShortVersionString`. Nil under `swift run` (no bundle), which + /// disables the checker entirely. + let currentVersion: String? + + private nonisolated static let latestReleaseURL = + URL(string: "https://api.github.com/repos/kasvith/tic/releases/latest")! + private static let interval: TimeInterval = 24 * 60 * 60 + private static let notifiedVersionKey = "updateNotifiedVersion" + private static let seenVersionKey = "updateSeenVersion" + + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let fetch: @Sendable () async throws -> Release + @ObservationIgnored private let notify: @MainActor (Release, String) async -> Void + @ObservationIgnored private var loop: Task? + + /// The parameters exist for tests; the app uses the defaults (GitHub + a real banner). + init( + currentVersion: String? = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + defaults: UserDefaults = .standard, + fetch: @escaping @Sendable () async throws -> Release = UpdateChecker.fetchLatest, + notify: @escaping @MainActor (Release, String) async -> Void = UpdateChecker.postBanner + ) { + self.currentVersion = currentVersion + self.defaults = defaults + self.fetch = fetch + self.notify = notify + + // Opening the menu (and so seeing the item) counts as "seen": the dot goes once it closes. + NotificationCenter.default.addObserver( + forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main + ) { [weak self] note in + nonisolated(unsafe) let menu = note.object as? NSMenu // queue: .main, so this is main-actor safe + MainActor.assumeIsolated { + guard let self, let menu, let title = self.menuTitle, + menu.items.contains(where: { $0.title == title }) else { return } + self.markSeen() + } + } + } + + /// Check shortly after launch (so it never competes with restoring notes), then daily. + func start() { + guard currentVersion != nil else { + NSLog("[Tic] update check disabled: no bundle version (swift run?)") + return + } + loop?.cancel() + loop = Task { [weak self] in + try? await Task.sleep(for: .seconds(10)) + while !Task.isCancelled { + await self?.check() + try? await Task.sleep(for: .seconds(Self.interval)) + } + } + } + + func check() async { + guard let currentVersion else { return } + do { + let release = try await fetch() + guard Self.isNewer(release.version, than: currentVersion) else { + available = nil + isUnseen = false + return + } + available = release + isUnseen = defaults.string(forKey: Self.seenVersionKey) != release.version + NSLog("[Tic] update available: v\(release.version)") + await notifyOnce(release) + } catch { + NSLog("[Tic] update check failed: \(error)") // silent for the user, by design + } + } + + func openReleasePage() { + guard let available else { return } + markSeen() + NSWorkspace.shared.open(available.url) + } + + func markSeen() { + guard let available else { return } + isUnseen = false + defaults.set(available.version, forKey: Self.seenVersionKey) + } + + /// One banner per version, remembered in `defaults` so daily checks and relaunches never repeat it. + private func notifyOnce(_ release: Release) async { + guard let currentVersion, defaults.string(forKey: Self.notifiedVersionKey) != release.version else { return } + defaults.set(release.version, forKey: Self.notifiedVersionKey) + await notify(release, currentVersion) + } + + // MARK: - Version compare + + /// Numeric, component-wise compare ("0.10.0" > "0.9.0"; "1.0" == "1.0.0"); a leading "v" is ignored. + nonisolated static func isNewer(_ candidate: String, than current: String) -> Bool { + let a = components(candidate), b = components(current) + let n = max(a.count, b.count) + for i in 0.. y } + } + return false + } + + private nonisolated static func components(_ version: String) -> [Int] { + var s = Substring(version) + if s.first == "v" || s.first == "V" { s = s.dropFirst() } + return s.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 } + } + + // MARK: - GitHub + + private struct LatestRelease: Decodable { // snake_case keys: tag_name, html_url + let tagName: String + let htmlUrl: URL + } + + /// `releases/latest` already excludes drafts and pre-releases. + nonisolated static func fetchLatest() async throws -> Release { + var request = URLRequest(url: latestReleaseURL) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + let (data, _) = try await URLSession.shared.data(for: request) + return try parse(data) + } + + /// Decodes the `releases/latest` payload, stripping the tag's leading "v". + nonisolated static func parse(_ data: Data) throws -> Release { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let latest = try decoder.decode(LatestRelease.self, from: data) + var version = latest.tagName + if version.hasPrefix("v") { version.removeFirst() } + return Release(version: version, url: latest.htmlUrl) + } + + // MARK: - Banner + + /// Authorization is requested here, the first time an update is actually found, so the system + /// prompt appears with the banner that explains it. Declined → the menu item still works and + /// we never ask again for this version. `UNUserNotificationCenter` needs a bundle id (swift run). + static func postBanner(_ release: Release, currentVersion: String) async { + guard Bundle.main.bundleIdentifier != nil else { return } + let center = UNUserNotificationCenter.current() + let granted: Bool + do { + granted = try await center.requestAuthorization(options: [.alert]) + } catch { + NSLog("[Tic] notification authorization failed: \(error)") + return + } + guard granted else { + NSLog("[Tic] notifications declined; the menu item still shows the update") + return + } + + let content = UNMutableNotificationContent() + content.title = "Tic v\(release.version) is available" + content.body = "You're on v\(currentVersion). Click to see what's new and download." + let request = UNNotificationRequest(identifier: "update-\(release.version)", content: content, trigger: nil) + do { + try await center.add(request) + } catch { + NSLog("[Tic] update notification failed: \(error)") + } + } +} diff --git a/Tests/TicTests/UpdateCheckerTests.swift b/Tests/TicTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..80750ed --- /dev/null +++ b/Tests/TicTests/UpdateCheckerTests.swift @@ -0,0 +1,161 @@ +import AppKit +import Foundation +import Testing +@testable import Tic + +@Suite("UpdateChecker") +struct UpdateCheckerTests { + @Test("detects newer versions numerically") + func newer() { + #expect(UpdateChecker.isNewer("0.6.0", than: "0.5.0")) + #expect(UpdateChecker.isNewer("v0.6.0", than: "0.5.0")) + #expect(UpdateChecker.isNewer("0.10.0", than: "0.9.0")) // not lexical + #expect(UpdateChecker.isNewer("1.0.1", than: "1.0")) + } + + @Test("same, older, or unparseable is not an update") + func notNewer() { + #expect(!UpdateChecker.isNewer("0.5.0", than: "0.5.0")) + #expect(!UpdateChecker.isNewer("1.0", than: "1.0.0")) + #expect(!UpdateChecker.isNewer("0.4.9", than: "0.5.0")) + #expect(!UpdateChecker.isNewer("garbage", than: "0.5.0")) + } + + @Test("parses the GitHub latest-release payload and strips the tag's v") + func parse() throws { + let json = #""" + {"tag_name":"v0.6.0","name":"Tic v0.6.0","assets":[], + "html_url":"https://github.com/kasvith/tic/releases/tag/v0.6.0"} + """# + let release = try UpdateChecker.parse(Data(json.utf8)) + #expect(release.version == "0.6.0") + #expect(release.url.absoluteString == "https://github.com/kasvith/tic/releases/tag/v0.6.0") + #expect(throws: (any Error).self) { try UpdateChecker.parse(Data("{}".utf8)) } + } +} + +/// The stateful side: menu item, dot, and banner bookkeeping, with GitHub and the notification +/// center stubbed out. +@Suite("UpdateChecker state") +@MainActor +struct UpdateCheckerStateTests { + /// Banners the stubbed notifier "posted" (by version). + final class Banners { var posted: [String] = [] } + + private nonisolated static let releasePage = URL(string: "https://github.com/kasvith/tic/releases/tag/v0.6.0")! + + private static func freshDefaults() -> UserDefaults { + let name = "UpdateCheckerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + private static func makeChecker( + current: String? = "0.5.0", latest: String? = "0.6.0", + defaults: UserDefaults = freshDefaults(), banners: Banners = Banners() + ) -> UpdateChecker { + UpdateChecker( + currentVersion: current, defaults: defaults, + fetch: { + guard let latest else { throw URLError(.notConnectedToInternet) } + return .init(version: latest, url: releasePage) + }, + notify: { release, _ in banners.posted.append(release.version) } + ) + } + + @Test("a newer release sets the item and the dot, and posts one banner even across re-checks") + func newerRelease() async { + let banners = Banners() + let checker = Self.makeChecker(banners: banners) + await checker.check() + #expect(checker.menuTitle == "Update Available: v0.6.0…") + #expect(checker.isUnseen) + #expect(banners.posted == ["0.6.0"]) + + await checker.check() // the daily re-check + #expect(checker.menuTitle == "Update Available: v0.6.0…") + #expect(banners.posted == ["0.6.0"]) + } + + @Test("up to date (or ahead) shows nothing") + func upToDate() async { + let banners = Banners() + for latest in ["0.5.0", "0.4.0"] { + let checker = Self.makeChecker(latest: latest, banners: banners) + await checker.check() + #expect(checker.menuTitle == nil) + #expect(!checker.isUnseen) + } + #expect(banners.posted.isEmpty) + } + + @Test("the banner is remembered per version across launches") + func bannerOncePerVersion() async { + let defaults = Self.freshDefaults() + let banners = Banners() + await Self.makeChecker(defaults: defaults, banners: banners).check() + await Self.makeChecker(defaults: defaults, banners: banners).check() // relaunch + #expect(banners.posted == ["0.6.0"]) + await Self.makeChecker(latest: "0.7.0", defaults: defaults, banners: banners).check() + #expect(banners.posted == ["0.6.0", "0.7.0"]) + } + + @Test("markSeen clears the dot, keeps the item, and is remembered across launches") + func seen() async { + let defaults = Self.freshDefaults() + let checker = Self.makeChecker(defaults: defaults) + await checker.check() + checker.markSeen() + #expect(!checker.isUnseen) + #expect(checker.menuTitle != nil) + + let relaunched = Self.makeChecker(defaults: defaults) + await relaunched.check() + #expect(!relaunched.isUnseen) + #expect(relaunched.menuTitle != nil) + + let newer = Self.makeChecker(latest: "0.7.0", defaults: defaults) // a newer one re-dots + await newer.check() + #expect(newer.isUnseen) + } + + @Test("closing a menu that showed the item counts as seen; other menus don't") + func menuTracking() async throws { + let checker = Self.makeChecker() + await checker.check() + #expect(checker.isUnseen) + + let other = NSMenu() + other.addItem(withTitle: "Quit Tic", action: nil, keyEquivalent: "") + NotificationCenter.default.post(name: NSMenu.didEndTrackingNotification, object: other) + try await Task.sleep(for: .milliseconds(50)) + #expect(checker.isUnseen) + + let ours = NSMenu() + ours.addItem(withTitle: checker.menuTitle!, action: nil, keyEquivalent: "") + NotificationCenter.default.post(name: NSMenu.didEndTrackingNotification, object: ours) + try await Task.sleep(for: .milliseconds(50)) + #expect(!checker.isUnseen) + } + + @Test("a failed fetch is silent and leaves the last result alone") + func fetchFailure() async { + let banners = Banners() + let checker = Self.makeChecker(latest: nil, banners: banners) + await checker.check() + #expect(checker.menuTitle == nil) + #expect(!checker.isUnseen) + #expect(banners.posted.isEmpty) + } + + @Test("no bundle version (swift run) disables the check") + func noBundleVersion() async { + let banners = Banners() + let checker = Self.makeChecker(current: nil, banners: banners) + await checker.check() + #expect(checker.menuTitle == nil) + #expect(banners.posted.isEmpty) + } +} diff --git a/scripts/package.sh b/scripts/package.sh index 73dd0d9..1ecdc80 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -43,6 +43,12 @@ if [ ! -f "$APP/Contents/Resources/Tic_Tic.bundle/MenuBarIcon.png" ]; then exit 1 fi +# The release workflow passes the tag; local builds default to the latest tag so a dev build +# doesn't look outdated to the update checker. No reachable tag (a shallow CI checkout) → unstamped. +if [ -z "${VERSION:-}" ]; then + VERSION="$(git describe --tags --abbrev=0 2>/dev/null || true)" + VERSION="${VERSION#v}" +fi if [ -n "${VERSION:-}" ]; then echo "▸ Stamping version ${VERSION}…" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${VERSION}" "$APP/Contents/Info.plist"