Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bundle id> 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).

Expand Down
2 changes: 1 addition & 1 deletion Packaging/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<string>0.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
Expand Down
21 changes: 21 additions & 0 deletions Sources/Tic/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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() }
}
}
2 changes: 2 additions & 0 deletions Sources/Tic/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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() {
Expand Down
42 changes: 34 additions & 8 deletions Sources/Tic/TicApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down
197 changes: 197 additions & 0 deletions Sources/Tic/UpdateChecker.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

/// 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..<n {
let x = i < a.count ? a[i] : 0
let y = i < b.count ? b[i] : 0
if x != y { return x > 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)")
}
}
}
Loading