From b6f96c126192bb43cf277abf30cec99060c7babd Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 11:23:17 +0900
Subject: [PATCH 01/11] Read Mac calendars through EventKit and add a
sample-day onboarding
Until can now use the events the Calendar app already syncs (iCloud,
Exchange, Google, CalDAV) without signing in. Mac calendars are one more
source next to Google accounts: they are listed in Settings grouped by
account, default off when the same account is connected to Google, and
events that duplicate a Google event are merged away. Google-only actions
(adding a Meet link, creating notes) stay on Google events.
The first-run popover now offers Mac calendars first, Google sign-in
second, and a sample day that shows the timeline before connecting
anything. Events also carry up to three non-meeting links from their
description or attachments for the upcoming card.
---
Sources/Until/AppModel.swift | 191 +++++++-
Sources/Until/CalendarClient.swift | 14 +-
Sources/Until/MacCalendarSource.swift | 418 ++++++++++++++++++
Sources/Until/Models.swift | 205 +++++++--
.../Resources/en.lproj/Localizable.strings | 24 +
.../Resources/ja.lproj/Localizable.strings | 24 +
Sources/Until/Views.swift | 298 +++++++++----
Tests/UntilTests/MacCalendarSourceTests.swift | 204 +++++++++
scripts/entitlements/app.entitlements | 2 +
scripts/entitlements/mas.entitlements | 2 +
scripts/package-app.sh | 4 +
11 files changed, 1233 insertions(+), 153 deletions(-)
create mode 100644 Sources/Until/MacCalendarSource.swift
create mode 100644 Tests/UntilTests/MacCalendarSourceTests.swift
diff --git a/Sources/Until/AppModel.swift b/Sources/Until/AppModel.swift
index c39a27b..076e59f 100644
--- a/Sources/Until/AppModel.swift
+++ b/Sources/Until/AppModel.swift
@@ -1,5 +1,6 @@
import AppKit
import Combine
+import EventKit
import Foundation
import OSLog
import ServiceManagement
@@ -27,6 +28,12 @@ final class AppModel: ObservableObject {
@Published var externalSharePrompt: ExternalSharePrompt?
@Published private(set) var launchAtLoginEnabled = false
@Published private(set) var launchAtLoginError: String?
+ @Published private(set) var macCalendarAccess: MacCalendarAccess = MacCalendarSource.access()
+ @Published private(set) var macCalendarError: String?
+ /// A sample day shown before any calendar is connected, so the timeline can
+ /// be tried without signing in. Nothing is persisted, notified, or shared
+ /// with the widget while it is on.
+ @Published private(set) var isPreviewingSample = false
/// Launch-at-login via `SMAppService` only works from a real .app bundle. In
/// bare `swift run` dev mode the row is shown disabled — same bundle check
@@ -48,6 +55,8 @@ final class AppModel: ObservableObject {
private let runtimeOptions: AppRuntimeOptions
private let store = ConfigStore()
private let notifier = EventNotifier()
+ private let macCalendars = MacCalendarSource()
+ private var macCalendarObserver: NSObjectProtocol?
private var accounts: [GoogleAuth] = []
private var rawEvents: [CalendarEvent] = []
private var refreshTimer: Timer?
@@ -86,6 +95,7 @@ final class AppModel: ObservableObject {
return
}
accounts = KeychainStore.loadTokens().map { GoogleAuth(config: config, token: $0) }
+ observeMacCalendarChanges()
updateAuthState()
startTimers()
Task {
@@ -98,6 +108,96 @@ final class AppModel: ObservableObject {
if let wakeObserver {
NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
}
+ if let macCalendarObserver {
+ NotificationCenter.default.removeObserver(macCalendarObserver)
+ }
+ }
+
+ /// Demo runs and the sample preview both show synthetic data and must not
+ /// reach Google, the widget, or Notification Center.
+ private var isDemo: Bool {
+ runtimeOptions.demoMode || isPreviewingSample
+ }
+
+ func startSamplePreview() {
+ guard !runtimeOptions.demoMode, !state.auth.authenticated else { return }
+ isPreviewingSample = true
+ loadDemoData(now: Date())
+ }
+
+ func endSamplePreview() {
+ guard isPreviewingSample else { return }
+ isPreviewingSample = false
+ rawEvents = []
+ calendars = []
+ state.lastSync = nil
+ invalidateCalendarCoverage()
+ updateAuthState()
+ reapplyFilter()
+ Task {
+ await refreshCalendars()
+ await refresh()
+ }
+ }
+
+ /// Mac calendars are in use once the user turned them on and macOS still
+ /// grants full read access.
+ var usesMacCalendars: Bool {
+ config.macCalendarsEnabled && macCalendarAccess == .authorized
+ }
+
+ /// Turns on Mac calendars, asking macOS for access first when needed.
+ func enableMacCalendars() {
+ guard !runtimeOptions.demoMode else { return }
+ endSamplePreview()
+ macCalendarError = nil
+ Task {
+ let granted = await macCalendars.requestAccess()
+ macCalendarAccess = MacCalendarSource.access()
+ guard granted else {
+ macCalendarError = loc(
+ "Until can't read your calendars. Allow access in System Settings → Privacy & Security → Calendars."
+ )
+ return
+ }
+ var next = config
+ next.macCalendarsEnabled = true
+ saveConfig(next)
+ updateAuthState()
+ }
+ }
+
+ func disableMacCalendars() {
+ var next = config
+ next.macCalendarsEnabled = false
+ saveConfig(next)
+ updateAuthState()
+ }
+
+ func openCalendarPrivacySettings() {
+ let urls = [
+ "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Calendars",
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars"
+ ]
+ for value in urls {
+ guard let url = URL(string: value), NSWorkspace.shared.open(url) else { continue }
+ return
+ }
+ }
+
+ /// Calendar app edits (and syncs from iCloud/Exchange) land here right away
+ /// instead of waiting for the next poll.
+ private func observeMacCalendarChanges() {
+ macCalendarObserver = NotificationCenter.default.addObserver(
+ forName: .EKEventStoreChanged,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in
+ guard let self, self.usesMacCalendars else { return }
+ await self.refresh()
+ }
+ }
}
/// Invalidates the calendar snapshot before a shaping input changes or a
@@ -112,7 +212,10 @@ final class AppModel: ObservableObject {
guard RuleValidator.validate(next.filterRules) == nil else { return }
invalidateCalendarCoverage()
config = normalized(next)
- if runtimeOptions.demoMode {
+ if isDemo {
+ if isPreviewingSample {
+ persistConfig()
+ }
loadDemoData(now: Date())
startTimers()
return
@@ -137,7 +240,7 @@ final class AppModel: ObservableObject {
var next = config
next.skippedMenubarEvents[event.actionKey] = event.endDate
config = AppModel.purgingExpiredSkips(next)
- if !runtimeOptions.demoMode {
+ if !isDemo {
persistConfig()
}
reapplyFilter()
@@ -148,7 +251,7 @@ final class AppModel: ObservableObject {
var next = config
next.skippedMenubarEvents.removeValue(forKey: event.actionKey)
config = AppModel.purgingExpiredSkips(next)
- if !runtimeOptions.demoMode {
+ if !isDemo {
persistConfig()
}
reapplyFilter()
@@ -170,6 +273,7 @@ final class AppModel: ObservableObject {
/// in-flight sign-in first so only one OAuth loopback server runs at a
/// time.
func startLogin() {
+ endSamplePreview()
signInTask?.cancel()
signInTask = Task { [weak self] in
await self?.login()
@@ -252,7 +356,7 @@ final class AppModel: ObservableObject {
}
func logout(email: String? = nil) async {
- guard !runtimeOptions.demoMode else {
+ guard !isDemo else {
loadDemoData(now: Date())
return
}
@@ -279,19 +383,25 @@ final class AppModel: ObservableObject {
state.lastError = nil
reapplyFilter()
updateAuthState()
+ if usesMacCalendars {
+ await refreshCalendars()
+ await refresh()
+ }
} catch {
state.lastError = error.localizedDescription
}
}
func refresh() async {
- if runtimeOptions.demoMode {
+ if isDemo {
isRefreshing = true
loadDemoData(now: Date())
isRefreshing = false
return
}
- guard !accounts.isEmpty else {
+ macCalendarAccess = MacCalendarSource.access()
+ updateAuthState()
+ guard !accounts.isEmpty || usesMacCalendars else {
reapplyFilter()
return
}
@@ -302,11 +412,14 @@ final class AppModel: ObservableObject {
let lookaheadHours = config.lookaheadHours
calendarRefreshGeneration += 1
let refreshGeneration = calendarRefreshGeneration
- let results = await fetchAllAccounts(
+ var results = await fetchAllAccounts(
selectedIds: config.selectedCalendarIds,
lookaheadHours: lookaheadHours,
now: fetchStart
)
+ if usesMacCalendars {
+ results.append(fetchMacCalendars(lookaheadHours: lookaheadHours, now: fetchStart))
+ }
applyFetchResults(
results,
fetchStart: fetchStart,
@@ -360,6 +473,21 @@ final class AppModel: ObservableObject {
}
}
+ /// Mac calendars ride along as one more "account" so a failure there is
+ /// reported the same way and never discards Google events.
+ private func fetchMacCalendars(lookaheadHours: Int, now: Date) -> AccountFetchResult {
+ let calendars = macCalendars.calendars(
+ selections: config.macCalendarSelections,
+ googleAccountEmails: accounts.map(\.email)
+ )
+ let events = macCalendars.events(
+ calendars: calendars.filter(\.selected),
+ lookaheadHours: lookaheadHours,
+ now: now
+ )
+ return AccountFetchResult(email: loc("Calendars on this Mac"), calendars: calendars, events: events, error: nil)
+ }
+
/// Aggregates per-account results, publishing the merged events/calendars
/// only when at least one account succeeded — so a transient outage on
/// every account leaves previously cached events (`rawEvents`) untouched
@@ -389,7 +517,7 @@ final class AppModel: ObservableObject {
}
if anySucceeded {
- rawEvents = fetchedEvents.sorted { $0.startDate < $1.startDate }
+ rawEvents = mergingCalendarSources(fetchedEvents.sorted { $0.startDate < $1.startDate })
calendars = fetchedCalendars.sorted { $0.name < $1.name }
state.lastSync = fetchStart
}
@@ -404,13 +532,21 @@ final class AppModel: ObservableObject {
}
func refreshCalendars() async {
- if runtimeOptions.demoMode {
+ if isDemo {
calendars = DemoCalendarData.calendars(selectedIds: config.selectedCalendarIds)
return
}
+ let macCalendarList = usesMacCalendars
+ ? macCalendars.calendars(
+ selections: config.macCalendarSelections,
+ googleAccountEmails: accounts.map(\.email)
+ )
+ : []
guard !accounts.isEmpty else {
- calendars = []
- invalidateCalendarCoverage()
+ calendars = macCalendarList.sorted { $0.name < $1.name }
+ if macCalendarList.isEmpty {
+ invalidateCalendarCoverage()
+ }
return
}
let selectedIds = config.selectedCalendarIds
@@ -431,7 +567,7 @@ final class AppModel: ObservableObject {
}
return collected
}
- calendars = next.sorted { $0.name < $1.name }
+ calendars = (next + macCalendarList).sorted { $0.name < $1.name }
} catch {
invalidateCalendarCoverage()
state.lastError = error.localizedDescription
@@ -439,7 +575,15 @@ final class AppModel: ObservableObject {
}
func setCalendar(_ id: String, selected: Bool) {
+ if id.hasPrefix(macCalendarIdPrefix) {
+ var next = config
+ next.macCalendarSelections[id] = selected
+ saveConfig(next)
+ return
+ }
var ids = Set(config.selectedCalendarIds)
+ // Keep "all selected" (an empty list) about Google calendars only.
+ let calendars = self.calendars.filter { $0.source == .google }
if ids.isEmpty {
ids = Set(calendars.map(\.id))
}
@@ -607,8 +751,9 @@ final class AppModel: ObservableObject {
}
func addConference(for event: CalendarEvent) {
+ guard event.source == .google else { return }
let key = event.actionKey
- if runtimeOptions.demoMode {
+ if isDemo {
rawEvents = rawEvents.map { current in
guard current.actionKey == key else { return current }
var next = current
@@ -663,7 +808,7 @@ final class AppModel: ObservableObject {
/// Creates an app-managed template Google Doc for the account, stores its id,
/// and opens it in the browser for editing.
func createTemplateDoc(for accountEmail: String) {
- guard !runtimeOptions.demoMode else {
+ guard !isDemo else {
var next = config
next.meetingNotesTemplateDocsByAccount[accountEmail] = "demo-template-\(accountEmail)"
saveConfig(next)
@@ -713,6 +858,9 @@ final class AppModel: ObservableObject {
openNote(url: notesUrl, accountEmail: event.account.email)
return
}
+ // Notes docs are attached to the Google event; Mac calendar events can
+ // only open a notes link they already carry.
+ guard event.source == .google else { return }
let external = externalAttendees(for: event)
if !external.isEmpty {
@@ -804,7 +952,7 @@ final class AppModel: ObservableObject {
let notificationEvents = config.notifyVideoOnly
? activeEvents.filter { !$0.conferenceUrl.isEmpty }
: activeEvents
- guard runtimeOptions.allowsNotifications else { return }
+ guard runtimeOptions.allowsNotifications, !isPreviewingSample else { return }
Task {
await notifier.sync(
events: notificationEvents,
@@ -816,9 +964,9 @@ final class AppModel: ObservableObject {
private func publishWidgetSnapshot() {
// Demo runs must not replace the real calendar shown by an installed widget.
- guard !runtimeOptions.demoMode, let url = WidgetAgendaStore.sharedURL() else { return }
+ guard !isDemo, let url = WidgetAgendaStore.sharedURL() else { return }
let snapshot = WidgetAgendaSnapshot(
- authenticated: !accounts.isEmpty,
+ authenticated: state.auth.authenticated,
lastSync: state.lastSync,
coverageEnd: state.calendarCoverageEnd,
events: (state.allDayEvents + state.events).map { event in
@@ -967,10 +1115,11 @@ final class AppModel: ObservableObject {
accounts: demoFixture.accountEmails().map { AccountState(email: $0) }
)
} else {
- calendars = DemoCalendarData.calendars(selectedIds: config.selectedCalendarIds)
+ let selectedIds = isPreviewingSample ? [] : config.selectedCalendarIds
+ calendars = DemoCalendarData.calendars(selectedIds: selectedIds)
rawEvents = DemoCalendarData.events(
now: now,
- selectedIds: config.selectedCalendarIds,
+ selectedIds: selectedIds,
scenario: runtimeOptions.demoScenario
)
state.auth = DemoCalendarData.accountState()
@@ -990,7 +1139,7 @@ final class AppModel: ObservableObject {
.map { AccountState(email: $0.email) }
.sorted { $0.email < $1.email }
state.auth = AuthState(
- authenticated: !accountStates.isEmpty,
+ authenticated: !accountStates.isEmpty || usesMacCalendars,
accounts: accountStates
)
}
@@ -1003,7 +1152,7 @@ final class AppModel: ObservableObject {
defer { creatingNoteKey = nil }
do {
- if runtimeOptions.demoMode {
+ if isDemo {
noteResults[key] = DemoCalendarData.noteResult(for: event)
return
}
diff --git a/Sources/Until/CalendarClient.swift b/Sources/Until/CalendarClient.swift
index 90667df..31c633b 100644
--- a/Sources/Until/CalendarClient.swift
+++ b/Sources/Until/CalendarClient.swift
@@ -116,6 +116,11 @@ final class CalendarClient {
let attendees = normalizeAttendees(raw.attendees ?? [])
let selfAttendee = attendees.first { $0.selfUser }
let noteURL = raw.existingNoteURL
+ let conference = conferenceURL(for: raw)
+ let attachmentLinks = (raw.attachments ?? []).compactMap { attachment -> EventLink? in
+ guard let url = attachment.fileUrl, !url.isEmpty else { return nil }
+ return EventLink(title: attachment.title?.nilIfEmpty ?? linkTitle(for: url), url: url)
+ }
return CalendarEvent(
id: raw.id,
@@ -134,11 +139,16 @@ final class CalendarClient {
organizer: raw.organizer?.email ?? "",
selfResponse: selfAttendee?.responseStatus ?? "none",
isRecurring: raw.recurringEventId != nil,
- conferenceUrl: conferenceURL(for: raw),
+ conferenceUrl: conference,
notesUrl: noteURL ?? "",
colorId: raw.colorId ?? "",
transparency: raw.transparency == "transparent" ? "free" : "busy",
- htmlLink: raw.htmlLink ?? ""
+ htmlLink: raw.htmlLink ?? "",
+ links: eventLinks(
+ description: raw.description,
+ attachments: attachmentLinks,
+ excluding: [conference, noteURL ?? ""]
+ )
)
}
diff --git a/Sources/Until/MacCalendarSource.swift b/Sources/Until/MacCalendarSource.swift
new file mode 100644
index 0000000..e004a53
--- /dev/null
+++ b/Sources/Until/MacCalendarSource.swift
@@ -0,0 +1,418 @@
+import AppKit
+import EventKit
+import Foundation
+
+/// Prefix for Mac calendar ids, keeping them apart from Google's
+/// `email::calendarId` keys in selections and filter rules.
+let macCalendarIdPrefix = "eventkit::"
+
+func macCalendarKey(_ calendarIdentifier: String) -> String {
+ macCalendarIdPrefix + calendarIdentifier
+}
+
+enum MacCalendarAccess: Equatable {
+ case notDetermined
+ case denied
+ case restricted
+ /// macOS 14+ can grant add-only access, which cannot read events.
+ case writeOnly
+ case authorized
+
+ var label: String {
+ switch self {
+ case .notDetermined: return loc("Not Determined")
+ case .denied: return loc("Denied")
+ case .restricted: return loc("Restricted")
+ case .writeOnly: return loc("Add-only")
+ case .authorized: return loc("Authorized")
+ }
+ }
+}
+
+/// Reads the Calendar app's events through EventKit. Everything stays on the
+/// Mac: no sign-in, no network, and read-only.
+@MainActor
+final class MacCalendarSource {
+ private let store = EKEventStore()
+
+ static func access() -> MacCalendarAccess {
+ let status = EKEventStore.authorizationStatus(for: .event)
+ switch status {
+ case .notDetermined: return .notDetermined
+ case .restricted: return .restricted
+ case .denied: return .denied
+ default: return grantedAccess(status)
+ }
+ }
+
+ /// macOS 14 split the old "authorized" into full and write-only access;
+ /// only full access can read events.
+ private static func grantedAccess(_ status: EKAuthorizationStatus) -> MacCalendarAccess {
+ if #available(macOS 14.0, *) {
+ return status == .fullAccess ? .authorized : .writeOnly
+ }
+ return .authorized
+ }
+
+ /// Shows the system prompt when access hasn't been decided yet. Returns
+ /// whether events can be read afterwards.
+ func requestAccess() async -> Bool {
+ do {
+ if #available(macOS 14.0, *) {
+ _ = try await store.requestFullAccessToEvents()
+ } else {
+ _ = try await store.requestAccess(to: .event)
+ }
+ } catch {
+ return false
+ }
+ store.reset()
+ return Self.access() == .authorized
+ }
+
+ func calendars(
+ selections: [String: Bool],
+ googleAccountEmails: [String]
+ ) -> [CalendarSummary] {
+ guard Self.access() == .authorized else { return [] }
+ let googleEmails = Set(googleAccountEmails.map { $0.lowercased() })
+ return store.calendars(for: .event).map { calendar in
+ let key = macCalendarKey(calendar.calendarIdentifier)
+ let sourceTitle = calendar.source?.title ?? ""
+ let duplicate = googleEmails.contains(sourceTitle.lowercased())
+ return CalendarSummary(
+ id: key,
+ googleId: calendar.calendarIdentifier,
+ name: calendar.title,
+ primary: false,
+ backgroundColor: calendar.color.map(hexString(for:)) ?? "#888888",
+ selected: isMacCalendarSelected(key, duplicatesGoogleAccount: duplicate, selections: selections),
+ accountEmail: sourceTitle.contains("@") ? sourceTitle : "",
+ source: .eventKit,
+ sourceTitle: sourceTitle,
+ duplicatesGoogleAccount: duplicate
+ )
+ }
+ }
+
+ func events(
+ calendars summaries: [CalendarSummary],
+ lookaheadHours: Int,
+ now: Date
+ ) -> [CalendarEvent] {
+ guard Self.access() == .authorized else { return [] }
+ let wanted = Set(summaries.map(\.googleId))
+ let ekCalendars = store.calendars(for: .event).filter { wanted.contains($0.calendarIdentifier) }
+ guard !ekCalendars.isEmpty else { return [] }
+ let byId = Dictionary(summaries.map { ($0.googleId, $0) }, uniquingKeysWith: { first, _ in first })
+ // Same window as the Google fetch: from local midnight so today's finished
+ // events can sit above the now-line.
+ let start = Calendar.current.startOfDay(for: now)
+ let end = now.addingTimeInterval(TimeInterval(lookaheadHours) * 3600)
+ let predicate = store.predicateForEvents(withStart: start, end: end, calendars: ekCalendars)
+ return store.events(matching: predicate).compactMap { event in
+ guard let calendar = event.calendar, let summary = byId[calendar.calendarIdentifier] else { return nil }
+ return MacCalendarEventInput(event).makeEvent(calendar: summary, now: now)
+ }
+ .sorted { $0.startDate < $1.startDate }
+ }
+}
+
+/// Calendars without an explicit choice are on, except those whose account is
+/// already connected directly to Google (they would show every event twice).
+func isMacCalendarSelected(
+ _ key: String,
+ duplicatesGoogleAccount: Bool,
+ selections: [String: Bool]
+) -> Bool {
+ selections[key] ?? !duplicatesGoogleAccount
+}
+
+/// The EventKit fields Until uses, copied into a plain value so the mapping to
+/// `CalendarEvent` is testable without a calendar database.
+struct MacCalendarEventInput {
+ struct Participant {
+ var email: String
+ var name: String
+ var status: String
+ var isCurrentUser: Bool
+ var isResource: Bool
+ }
+
+ var itemIdentifier: String
+ var eventIdentifier: String?
+ var title: String?
+ var notes: String?
+ var location: String?
+ var url: String?
+ var startDate: Date
+ var endDate: Date
+ var isAllDay: Bool
+ var status: String
+ var isFree: Bool
+ var isRecurring: Bool
+ var organizerEmail: String
+ var participants: [Participant]
+
+ init(
+ itemIdentifier: String,
+ eventIdentifier: String? = nil,
+ title: String?,
+ notes: String? = nil,
+ location: String? = nil,
+ url: String? = nil,
+ startDate: Date,
+ endDate: Date,
+ isAllDay: Bool = false,
+ status: String = "confirmed",
+ isFree: Bool = false,
+ isRecurring: Bool = false,
+ organizerEmail: String = "",
+ participants: [Participant] = []
+ ) {
+ self.itemIdentifier = itemIdentifier
+ self.eventIdentifier = eventIdentifier
+ self.title = title
+ self.notes = notes
+ self.location = location
+ self.url = url
+ self.startDate = startDate
+ self.endDate = endDate
+ self.isAllDay = isAllDay
+ self.status = status
+ self.isFree = isFree
+ self.isRecurring = isRecurring
+ self.organizerEmail = organizerEmail
+ self.participants = participants
+ }
+
+ init(_ event: EKEvent) {
+ let status: String
+ switch event.status {
+ case .canceled: status = "cancelled"
+ case .tentative: status = "tentative"
+ default: status = "confirmed"
+ }
+ self.init(
+ itemIdentifier: event.calendarItemIdentifier,
+ eventIdentifier: event.eventIdentifier,
+ title: event.title,
+ notes: event.notes,
+ location: event.location,
+ url: event.url?.absoluteString,
+ startDate: event.startDate,
+ endDate: event.endDate,
+ isAllDay: event.isAllDay,
+ status: status,
+ isFree: event.availability == .free,
+ isRecurring: event.hasRecurrenceRules,
+ organizerEmail: event.organizer.map { emailAddress(from: $0.url) } ?? "",
+ participants: (event.attendees ?? []).map { participant in
+ let email = emailAddress(from: participant.url)
+ return Participant(
+ email: email,
+ name: participant.name ?? email,
+ status: participantStatus(participant.participantStatus),
+ isCurrentUser: participant.isCurrentUser,
+ isResource: participant.participantType == .room || participant.participantType == .resource
+ )
+ }
+ )
+ }
+
+ /// EventKit ends an all-day event either at the next midnight or at
+ /// 23:59:59 of its last day; Google (and the rest of the app) use an
+ /// exclusive next-midnight end.
+ private var normalizedBounds: (start: Date, end: Date) {
+ guard isAllDay else { return (startDate, endDate) }
+ let calendar = Calendar.current
+ let lastDay = calendar.startOfDay(for: max(startDate, endDate.addingTimeInterval(-1)))
+ return (calendar.startOfDay(for: startDate), calendar.date(byAdding: .day, value: 1, to: lastDay) ?? lastDay)
+ }
+
+ private var attendees: [Attendee] {
+ participants.map {
+ Attendee(
+ email: $0.email,
+ name: $0.name,
+ responseStatus: $0.status,
+ selfUser: $0.isCurrentUser,
+ resource: $0.isResource
+ )
+ }
+ }
+
+ func makeEvent(calendar: CalendarSummary, now: Date) -> CalendarEvent? {
+ let (start, end) = normalizedBounds
+ guard end >= start else { return nil }
+ let attendees = self.attendees
+ let conference = [url, location, notes]
+ .lazy
+ .compactMap { extractMeetingURL($0) }
+ .first ?? ""
+ let notesURL = GoogleDocLinks.documentURL(from: notes) ?? ""
+ let links = eventLinks(description: notes, extra: [url], excluding: [conference, notesURL])
+ return CalendarEvent(
+ id: "\(itemIdentifier)::\(Int(start.timeIntervalSince1970))",
+ title: (title?.isEmpty == false ? title : nil) ?? "(no title)",
+ description: notes ?? "",
+ location: location ?? "",
+ startISO: ISO8601DateFormatter.fallback.string(from: start),
+ endISO: ISO8601DateFormatter.fallback.string(from: end),
+ allDay: isAllDay,
+ status: status,
+ startMinutesFromNow: Int((start.timeIntervalSince(now) / 60).rounded()),
+ durationMinutes: max(0, Int((end.timeIntervalSince(start) / 60).rounded())),
+ calendar: CalendarRef(
+ id: calendar.id,
+ googleId: calendar.googleId,
+ primary: false,
+ backgroundColor: calendar.backgroundColor,
+ source: .eventKit
+ ),
+ account: AccountRef(email: calendar.accountEmail),
+ attendees: attendees,
+ organizer: organizerEmail,
+ selfResponse: attendees.first(where: \.selfUser)?.responseStatus ?? "none",
+ isRecurring: isRecurring,
+ conferenceUrl: conference,
+ notesUrl: notesURL,
+ colorId: "",
+ transparency: isFree ? "free" : "busy",
+ htmlLink: calendarAppURL(eventIdentifier: eventIdentifier) ?? "",
+ links: links
+ )
+ }
+}
+
+/// Opens the event in the Calendar app (the scheme Calendar registers for
+/// its own events).
+private func calendarAppURL(eventIdentifier: String?) -> String? {
+ guard let eventIdentifier, !eventIdentifier.isEmpty else { return nil }
+ // Identifiers look like "UUID:UUID"; Calendar expects the colon as is.
+ let allowed = CharacterSet.urlPathAllowed.union(CharacterSet(charactersIn: ":"))
+ let encoded = eventIdentifier.addingPercentEncoding(withAllowedCharacters: allowed) ?? eventIdentifier
+ return "ical://ekevent/\(encoded)?method=show&options=more"
+}
+
+private func emailAddress(from url: URL) -> String {
+ let raw = url.absoluteString
+ guard raw.lowercased().hasPrefix("mailto:") else { return "" }
+ let address = String(raw.dropFirst("mailto:".count))
+ return (address.removingPercentEncoding ?? address).lowercased()
+}
+
+private func participantStatus(_ status: EKParticipantStatus) -> String {
+ switch status {
+ case .accepted, .delegated, .completed, .inProcess: return "accepted"
+ case .declined: return "declined"
+ case .tentative: return "tentative"
+ default: return "needsAction"
+ }
+}
+
+private func hexString(for color: NSColor) -> String {
+ guard let rgb = color.usingColorSpace(.sRGB) else { return "#888888" }
+ let red = Int((rgb.redComponent * 255).rounded())
+ let green = Int((rgb.greenComponent * 255).rounded())
+ let blue = Int((rgb.blueComponent * 255).rounded())
+ return String(format: "#%02X%02X%02X", red, green, blue)
+}
+
+/// Mac calendars mirror accounts the user may also connect to Google directly,
+/// and the same invitation can land in two Mac calendars. Google copies win
+/// (they carry notes and Meet actions); otherwise the first copy wins.
+func mergingCalendarSources(_ events: [CalendarEvent]) -> [CalendarEvent] {
+ func key(_ event: CalendarEvent) -> String {
+ let title = event.title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return "\(title)|\(event.startDate.timeIntervalSince1970)|\(event.endDate.timeIntervalSince1970)|\(event.allDay)"
+ }
+ let googleKeys = Set(events.filter { $0.source == .google }.map(key))
+ var seenMacKeys: Set = []
+ return events.filter { event in
+ guard event.source == .eventKit else { return true }
+ let eventKey = key(event)
+ guard !googleKeys.contains(eventKey) else { return false }
+ return seenMacKeys.insert(eventKey).inserted
+ }
+}
+
+/// Up to three links from an event's description and attachments that are not
+/// the meeting link itself — the doc, design, or PR the meeting is about.
+func eventLinks(
+ description: String?,
+ attachments: [EventLink] = [],
+ extra: [String?] = [],
+ excluding excluded: [String]
+) -> [EventLink] {
+ var seen = Set(excluded)
+ var links: [EventLink] = []
+ func append(_ link: EventLink) {
+ guard links.count < 3, !link.url.isEmpty, seen.insert(link.url).inserted else { return }
+ guard EventLinks.meetingProvider(for: link.url) == nil else { return }
+ links.append(link)
+ }
+ attachments.forEach(append)
+ let candidates = extra.compactMap { $0 } + allURLs(in: description)
+ for url in candidates {
+ append(EventLink(title: linkTitle(for: url), url: url))
+ }
+ return links
+}
+
+private func allURLs(in text: String?) -> [String] {
+ guard let text, !text.isEmpty else { return [] }
+ let decoded = decodeHtmlEntities(text)
+ let pattern = #"\bhttps?://[^\s<>"')\]}]+"#
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] }
+ let range = NSRange(decoded.startIndex.. String {
+ guard let url = URL(string: rawURL), let host = url.host?.lowercased() else { return rawURL }
+ if host == "docs.google.com", let kind = googleDocKinds.first(where: { url.path.hasPrefix($0.path) }) {
+ return kind.name
+ }
+ if host == "github.com" {
+ return gitHubLinkTitle(path: url.path)
+ }
+ if let product = linkProductNames.first(where: { host == $0.domain || host.hasSuffix("." + $0.domain) }) {
+ return product.name
+ }
+ return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host
+}
+
+private func gitHubLinkTitle(path: String) -> String {
+ let parts = path.split(separator: "/")
+ guard parts.count >= 4 else { return "GitHub" }
+ switch parts[2] {
+ case "pull": return "PR #\(parts[3])"
+ case "issues": return "Issue #\(parts[3])"
+ default: return "GitHub"
+ }
+}
diff --git a/Sources/Until/Models.swift b/Sources/Until/Models.swift
index a5c28c5..ff1614e 100644
--- a/Sources/Until/Models.swift
+++ b/Sources/Until/Models.swift
@@ -8,11 +8,27 @@ struct Attendee: Hashable {
var resource: Bool
}
+/// Where an event or calendar comes from. Google events carry the richer
+/// Google-only actions (Meet links, notes docs); Mac calendars are read
+/// through EventKit and never leave the machine.
+enum EventSource: String, Hashable, Codable {
+ case google
+ case eventKit
+}
+
struct CalendarRef: Hashable {
var id: String
var googleId: String
var primary: Bool
var backgroundColor: String
+ var source: EventSource = .google
+}
+
+/// A non-meeting link found on an event (a spec doc, a design file, a pull
+/// request) so the card can surface what the meeting is about.
+struct EventLink: Hashable {
+ var title: String
+ var url: String
}
struct AccountRef: Hashable {
@@ -40,6 +56,7 @@ struct CalendarEvent: Identifiable, Hashable {
var colorId: String
var transparency: String
var htmlLink: String
+ var links: [EventLink]
// Parsed once at construction from the source ISO strings. These were previously
// computed properties that reparsed the ISO strings on every access; they are
@@ -50,6 +67,8 @@ struct CalendarEvent: Identifiable, Hashable {
var actionKey: String { "\(account.email)::\(calendar.googleId)::\(id)" }
+ var source: EventSource { calendar.source }
+
/// Designated init taking the ISO strings; dates are parsed exactly once here.
/// Returns nil if either bound fails to parse, so callers can drop the event
/// instead of substituting a bogus date (which would render as "happening now").
@@ -74,7 +93,8 @@ struct CalendarEvent: Identifiable, Hashable {
notesUrl: String,
colorId: String,
transparency: String,
- htmlLink: String
+ htmlLink: String,
+ links: [EventLink] = []
) {
guard let start = ISO8601DateFormatter.shared.date(fromAnyInternetDate: startISO),
let end = ISO8601DateFormatter.shared.date(fromAnyInternetDate: endISO) else {
@@ -100,6 +120,7 @@ struct CalendarEvent: Identifiable, Hashable {
self.colorId = colorId
self.transparency = transparency
self.htmlLink = htmlLink
+ self.links = links
self.startDate = start
self.endDate = end
}
@@ -138,12 +159,16 @@ struct EventActionSet: Equatable {
attached.append(.openMeetingNotes)
}
+ // Adding a Meet link or a notes doc writes to the Google event, so Mac
+ // calendar events only get the attached and common actions.
var addable: [Item] = []
- if !hasConference && !event.allDay {
- addable.append(.addGoogleMeet)
- }
- if !hasNotes {
- addable.append(.createNotes)
+ if event.source == .google {
+ if !hasConference && !event.allDay {
+ addable.append(.addGoogleMeet)
+ }
+ if !hasNotes {
+ addable.append(.createNotes)
+ }
}
return EventActionSet(
@@ -264,6 +289,12 @@ struct CalendarSummary: Identifiable, Hashable {
var backgroundColor: String
var selected: Bool
var accountEmail: String
+ var source: EventSource = .google
+ /// The macOS account a Mac calendar belongs to ("iCloud", an email, …).
+ var sourceTitle: String = ""
+ /// A Mac calendar whose account is also connected directly to Google, so
+ /// its events would otherwise appear twice. Off by default.
+ var duplicatesGoogleAccount: Bool = false
}
struct AccountState: Identifiable, Hashable {
@@ -515,6 +546,25 @@ struct AppConfig: Codable, Hashable {
/// value is the event's `endDate`, kept only so expired entries can be
/// purged automatically instead of accumulating forever.
var skippedMenubarEvents: [String: Date]
+ /// Read events from the Calendar app through EventKit (iCloud, Exchange,
+ /// Google, CalDAV — whatever macOS already syncs). No sign-in needed.
+ var macCalendarsEnabled: Bool
+ /// Explicit on/off choices for Mac calendars, keyed by `CalendarSummary.id`.
+ /// Calendars without a choice follow the default (on, unless the account is
+ /// also connected directly through Google).
+ var macCalendarSelections: [String: Bool]
+ /// A second global shortcut that joins the current or next meeting.
+ var joinHotkeyEnabled: Bool
+ var joinHotkeyPreset: String
+ /// Open Zoom and Teams links in their desktop apps when installed.
+ var openMeetingsInApps: Bool
+ /// "off", "floating", or "fullScreen": an on-screen alert as a meeting starts.
+ var startAlertStyle: String
+ var startAlertLeadMinutes: Int
+ var startAlertVideoOnly: Bool
+ /// Show the event title next to the countdown. Off keeps only the time,
+ /// which is handy while presenting.
+ var menubarShowsTitle: Bool
// Google OAuth client credentials, injected at build/package time rather than
// committed to source. Packaged builds read them from Info.plist (written by
@@ -536,6 +586,21 @@ struct AppConfig: Codable, Hashable {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
+ /// Build-time credentials win over persisted values so rotating .env takes
+ /// effect without clearing config.
+ private static func decodedCredentials(
+ from container: KeyedDecodingContainer,
+ defaults: AppConfig
+ ) throws -> (clientId: String, clientSecret: String) {
+ let oauth = try container.nestedContainer(keyedBy: OAuthKeys.self, forKey: .oauth)
+ let storedClientId = try oauth.decodeIfPresent(String.self, forKey: .clientId)
+ let storedClientSecret = try oauth.decodeIfPresent(String.self, forKey: .clientSecret)
+ return (
+ resolvedCredential(buildTime: defaults.oauthClientId, stored: storedClientId),
+ resolvedCredential(buildTime: defaults.oauthClientSecret, stored: storedClientSecret)
+ )
+ }
+
private static func resolvedCredential(buildTime: String, stored: String?) -> String {
if !buildTime.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return buildTime }
let stored = stored?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -585,7 +650,16 @@ struct AppConfig: Codable, Hashable {
meetingNotesTitleTemplatesByAccount: [:],
meetingNotesTemplateDocsByAccount: [:],
didApplyDefaultLaunchAtLogin: false,
- skippedMenubarEvents: [:]
+ skippedMenubarEvents: [:],
+ macCalendarsEnabled: false,
+ macCalendarSelections: [:],
+ joinHotkeyEnabled: false,
+ joinHotkeyPreset: "ctrl-opt-j",
+ openMeetingsInApps: false,
+ startAlertStyle: StartAlertStyle.off.rawValue,
+ startAlertLeadMinutes: 1,
+ startAlertVideoOnly: true,
+ menubarShowsTitle: true
)
enum CodingKeys: String, CodingKey {
@@ -601,6 +675,11 @@ struct AppConfig: Codable, Hashable {
case meetingNotesTemplateDocsByAccount
case didApplyDefaultLaunchAtLogin
case skippedMenubarEvents
+ case macCalendarsEnabled, macCalendarSelections
+ case joinHotkeyEnabled, joinHotkeyPreset
+ case openMeetingsInApps
+ case startAlertStyle, startAlertLeadMinutes, startAlertVideoOnly
+ case menubarShowsTitle
}
enum OAuthKeys: String, CodingKey {
@@ -628,7 +707,16 @@ struct AppConfig: Codable, Hashable {
meetingNotesTitleTemplatesByAccount: [String: String],
meetingNotesTemplateDocsByAccount: [String: String],
didApplyDefaultLaunchAtLogin: Bool = false,
- skippedMenubarEvents: [String: Date] = [:]
+ skippedMenubarEvents: [String: Date] = [:],
+ macCalendarsEnabled: Bool = false,
+ macCalendarSelections: [String: Bool] = [:],
+ joinHotkeyEnabled: Bool = false,
+ joinHotkeyPreset: String = "ctrl-opt-j",
+ openMeetingsInApps: Bool = false,
+ startAlertStyle: String = StartAlertStyle.off.rawValue,
+ startAlertLeadMinutes: Int = 1,
+ startAlertVideoOnly: Bool = true,
+ menubarShowsTitle: Bool = true
) {
self.oauthClientId = oauthClientId
self.oauthClientSecret = oauthClientSecret
@@ -651,60 +739,66 @@ struct AppConfig: Codable, Hashable {
self.meetingNotesTemplateDocsByAccount = meetingNotesTemplateDocsByAccount
self.didApplyDefaultLaunchAtLogin = didApplyDefaultLaunchAtLogin
self.skippedMenubarEvents = skippedMenubarEvents
+ self.macCalendarsEnabled = macCalendarsEnabled
+ self.macCalendarSelections = macCalendarSelections
+ self.joinHotkeyEnabled = joinHotkeyEnabled
+ self.joinHotkeyPreset = joinHotkeyPreset
+ self.openMeetingsInApps = openMeetingsInApps
+ self.startAlertStyle = startAlertStyle
+ self.startAlertLeadMinutes = startAlertLeadMinutes
+ self.startAlertVideoOnly = startAlertVideoOnly
+ self.menubarShowsTitle = menubarShowsTitle
}
init(from decoder: Decoder) throws {
let defaults = AppConfig.default
let container = try decoder.container(keyedBy: CodingKeys.self)
- let oauth = try container.nestedContainer(keyedBy: OAuthKeys.self, forKey: .oauth)
- let storedClientId = try oauth.decodeIfPresent(String.self, forKey: .clientId)
- let storedClientSecret = try oauth.decodeIfPresent(String.self, forKey: .clientSecret)
- // Build-time credentials win over persisted values so rotating .env takes
- // effect without clearing config.
- oauthClientId = Self.resolvedCredential(buildTime: defaults.oauthClientId, stored: storedClientId)
- oauthClientSecret = Self.resolvedCredential(buildTime: defaults.oauthClientSecret, stored: storedClientSecret)
- filterRules = try container.decode(.filterRules, default: defaults.filterRules)
- selectedCalendarIds = try container.decode(.selectedCalendarIds, default: defaults.selectedCalendarIds)
- lookaheadHours = try container.decode(.lookaheadHours, default: defaults.lookaheadHours)
- pollIntervalSeconds = try container.decode(.pollIntervalSeconds, default: defaults.pollIntervalSeconds)
- maxTitleLength = try container.decode(.maxTitleLength, default: defaults.maxTitleLength)
+ (oauthClientId, oauthClientSecret) = try Self.decodedCredentials(from: container, defaults: defaults)
+ filterRules = try container.decode(.filterRules, or: \.filterRules)
+ selectedCalendarIds = try container.decode(.selectedCalendarIds, or: \.selectedCalendarIds)
+ lookaheadHours = try container.decode(.lookaheadHours, or: \.lookaheadHours)
+ pollIntervalSeconds = try container.decode(.pollIntervalSeconds, or: \.pollIntervalSeconds)
+ maxTitleLength = try container.decode(.maxTitleLength, or: \.maxTitleLength)
menubarLeadMinutes = AppConfig.snappedMenubarLead(
- try container.decode(.menubarLeadMinutes, default: defaults.menubarLeadMinutes)
+ try container.decode(.menubarLeadMinutes, or: \.menubarLeadMinutes)
)
- menubarShowsNextAlways = try container.decode(.menubarShowsNextAlways, default: defaults.menubarShowsNextAlways)
- menubarPrefersImminentNext = try container.decode(
- .menubarPrefersImminentNext,
- default: defaults.menubarPrefersImminentNext
- )
- notifyEnabled = try container.decode(.notifyEnabled, default: defaults.notifyEnabled)
- notifyVideoOnly = try container.decode(.notifyVideoOnly, default: defaults.notifyVideoOnly)
- notifyLeadMinutes = try container.decode(.notifyLeadMinutes, default: defaults.notifyLeadMinutes)
- hotkeyEnabled = try container.decode(.hotkeyEnabled, default: defaults.hotkeyEnabled)
- hotkeyPreset = try container.decode(.hotkeyPreset, default: defaults.hotkeyPreset)
+ menubarShowsNextAlways = try container.decode(.menubarShowsNextAlways, or: \.menubarShowsNextAlways)
+ menubarPrefersImminentNext = try container.decode(.menubarPrefersImminentNext, or: \.menubarPrefersImminentNext)
+ notifyEnabled = try container.decode(.notifyEnabled, or: \.notifyEnabled)
+ notifyVideoOnly = try container.decode(.notifyVideoOnly, or: \.notifyVideoOnly)
+ notifyLeadMinutes = try container.decode(.notifyLeadMinutes, or: \.notifyLeadMinutes)
+ hotkeyEnabled = try container.decode(.hotkeyEnabled, or: \.hotkeyEnabled)
+ hotkeyPreset = try container.decode(.hotkeyPreset, or: \.hotkeyPreset)
meetingNotesFoldersByAccount = try container.decode(
.meetingNotesFoldersByAccount,
- default: defaults.meetingNotesFoldersByAccount
+ or: \.meetingNotesFoldersByAccount
)
meetingNotesFolderNamesByAccount = try container.decode(
.meetingNotesFolderNamesByAccount,
- default: defaults.meetingNotesFolderNamesByAccount
+ or: \.meetingNotesFolderNamesByAccount
)
meetingNotesTitleTemplatesByAccount = try container.decode(
.meetingNotesTitleTemplatesByAccount,
- default: defaults.meetingNotesTitleTemplatesByAccount
+ or: \.meetingNotesTitleTemplatesByAccount
)
meetingNotesTemplateDocsByAccount = try container.decode(
.meetingNotesTemplateDocsByAccount,
- default: defaults.meetingNotesTemplateDocsByAccount
+ or: \.meetingNotesTemplateDocsByAccount
)
didApplyDefaultLaunchAtLogin = try container.decode(
.didApplyDefaultLaunchAtLogin,
- default: defaults.didApplyDefaultLaunchAtLogin
- )
- skippedMenubarEvents = try container.decode(
- .skippedMenubarEvents,
- default: defaults.skippedMenubarEvents
+ or: \.didApplyDefaultLaunchAtLogin
)
+ skippedMenubarEvents = try container.decode(.skippedMenubarEvents, or: \.skippedMenubarEvents)
+ macCalendarsEnabled = try container.decode(.macCalendarsEnabled, or: \.macCalendarsEnabled)
+ macCalendarSelections = try container.decode(.macCalendarSelections, or: \.macCalendarSelections)
+ joinHotkeyEnabled = try container.decode(.joinHotkeyEnabled, or: \.joinHotkeyEnabled)
+ joinHotkeyPreset = try container.decode(.joinHotkeyPreset, or: \.joinHotkeyPreset)
+ openMeetingsInApps = try container.decode(.openMeetingsInApps, or: \.openMeetingsInApps)
+ startAlertStyle = try container.decode(.startAlertStyle, or: \.startAlertStyle)
+ startAlertLeadMinutes = try container.decode(.startAlertLeadMinutes, or: \.startAlertLeadMinutes)
+ startAlertVideoOnly = try container.decode(.startAlertVideoOnly, or: \.startAlertVideoOnly)
+ menubarShowsTitle = try container.decode(.menubarShowsTitle, or: \.menubarShowsTitle)
}
func encode(to encoder: Encoder) throws {
@@ -733,12 +827,39 @@ struct AppConfig: Codable, Hashable {
try container.encode(meetingNotesTemplateDocsByAccount, forKey: .meetingNotesTemplateDocsByAccount)
try container.encode(didApplyDefaultLaunchAtLogin, forKey: .didApplyDefaultLaunchAtLogin)
try container.encode(skippedMenubarEvents, forKey: .skippedMenubarEvents)
+ try container.encode(macCalendarsEnabled, forKey: .macCalendarsEnabled)
+ try container.encode(macCalendarSelections, forKey: .macCalendarSelections)
+ try container.encode(joinHotkeyEnabled, forKey: .joinHotkeyEnabled)
+ try container.encode(joinHotkeyPreset, forKey: .joinHotkeyPreset)
+ try container.encode(openMeetingsInApps, forKey: .openMeetingsInApps)
+ try container.encode(startAlertStyle, forKey: .startAlertStyle)
+ try container.encode(startAlertLeadMinutes, forKey: .startAlertLeadMinutes)
+ try container.encode(startAlertVideoOnly, forKey: .startAlertVideoOnly)
+ try container.encode(menubarShowsTitle, forKey: .menubarShowsTitle)
+ }
+}
+
+/// How a meeting announces itself on screen as it starts.
+enum StartAlertStyle: String, CaseIterable, Identifiable {
+ case off
+ case floating
+ case fullScreen
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .off: return loc("Off")
+ case .floating: return loc("Floating")
+ case .fullScreen: return loc("Full screen")
+ }
}
}
private extension KeyedDecodingContainer where Key == AppConfig.CodingKeys {
- func decode(_ key: Key, default defaultValue: T) throws -> T {
- try decodeIfPresent(T.self, forKey: key) ?? defaultValue
+ /// Missing keys (older config files) fall back to the default config.
+ func decode(_ key: Key, or defaultValue: KeyPath) throws -> T {
+ try decodeIfPresent(T.self, forKey: key) ?? AppConfig.default[keyPath: defaultValue]
}
}
diff --git a/Sources/Until/Resources/en.lproj/Localizable.strings b/Sources/Until/Resources/en.lproj/Localizable.strings
index a719de9..2b08ee8 100644
--- a/Sources/Until/Resources/en.lproj/Localizable.strings
+++ b/Sources/Until/Resources/en.lproj/Localizable.strings
@@ -340,3 +340,27 @@
"Return to Until." = "Return to Until.";
"Login failed" = "Login failed";
"No authorization code returned." = "No authorization code returned.";
+
+/* MARK: - Calendars on this Mac, onboarding, sample day */
+"Calendars on this Mac" = "Calendars on this Mac";
+"Events from the Calendar app — iCloud, Exchange, Google, and more. No sign-in." = "Events from the Calendar app — iCloud, Exchange, Google, and more. No sign-in.";
+"No calendars found in the Calendar app." = "No calendars found in the Calendar app.";
+"Other" = "Other";
+"Calendar access" = "Calendar access";
+"Also connected as a Google account" = "Also connected as a Google account";
+"Until can't read your calendars. Allow access in System Settings → Privacy & Security → Calendars." = "Until can't read your calendars. Allow access in System Settings → Privacy & Security → Calendars.";
+"Restricted" = "Restricted";
+"Add-only" = "Add-only";
+"Off" = "Off";
+"Floating" = "Floating";
+"Full screen" = "Full screen";
+"Your next meeting, always in the menubar." = "Your next meeting, always in the menubar.";
+"Use the events already in the Calendar app — iCloud, Google, Outlook, and more. No sign-in." = "Use the events already in the Calendar app — iCloud, Google, Outlook, and more. No sign-in.";
+"Use Calendars on This Mac" = "Use Calendars on This Mac";
+"Open System Settings" = "Open System Settings";
+"or" = "or";
+"Google account" = "Google account";
+"Adds one-click meeting notes and Google Meet links." = "Adds one-click meeting notes and Google Meet links.";
+"Try it with a sample day" = "Try it with a sample day";
+"Sample day" = "Sample day";
+"Connect calendars" = "Connect calendars";
diff --git a/Sources/Until/Resources/ja.lproj/Localizable.strings b/Sources/Until/Resources/ja.lproj/Localizable.strings
index 0f27e86..06cd78e 100644
--- a/Sources/Until/Resources/ja.lproj/Localizable.strings
+++ b/Sources/Until/Resources/ja.lproj/Localizable.strings
@@ -340,3 +340,27 @@
"Return to Until." = "Untilに戻ってください。";
"Login failed" = "ログインに失敗しました";
"No authorization code returned." = "認証コードが返されませんでした。";
+
+/* MARK: - Calendars on this Mac, onboarding, sample day */
+"Calendars on this Mac" = "このMacのカレンダー";
+"Events from the Calendar app — iCloud, Exchange, Google, and more. No sign-in." = "カレンダーAppの予定を表示します。iCloud・Exchange・Googleなどに対応し、サインインは不要です。";
+"No calendars found in the Calendar app." = "カレンダーAppにカレンダーが見つかりません。";
+"Other" = "その他";
+"Calendar access" = "カレンダーへのアクセス";
+"Also connected as a Google account" = "Googleアカウントとしても接続済み";
+"Until can't read your calendars. Allow access in System Settings → Privacy & Security → Calendars." = "カレンダーを読み取れません。システム設定 → プライバシーとセキュリティ → カレンダー でUntilへのアクセスを許可してください。";
+"Restricted" = "制限あり";
+"Add-only" = "追加のみ";
+"Off" = "オフ";
+"Floating" = "浮動";
+"Full screen" = "全画面";
+"Your next meeting, always in the menubar." = "次の予定を、いつもメニューバーに。";
+"Use the events already in the Calendar app — iCloud, Google, Outlook, and more. No sign-in." = "カレンダーAppにある予定をそのまま使います。iCloud・Google・Outlookなどに対応し、サインインは不要です。";
+"Use Calendars on This Mac" = "このMacのカレンダーを使う";
+"Open System Settings" = "システム設定を開く";
+"or" = "または";
+"Google account" = "Googleアカウント";
+"Adds one-click meeting notes and Google Meet links." = "ワンクリックの議事録作成とGoogle Meetリンクの追加も使えます。";
+"Try it with a sample day" = "サンプルの一日で試す";
+"Sample day" = "サンプルの一日";
+"Connect calendars" = "カレンダーを接続";
diff --git a/Sources/Until/Views.swift b/Sources/Until/Views.swift
index 201e64a..bd1b24f 100644
--- a/Sources/Until/Views.swift
+++ b/Sources/Until/Views.swift
@@ -48,6 +48,9 @@ struct PanelView: View {
@ViewBuilder
private func content(now: Date) -> some View {
let hasEvents = !(model.state.events.isEmpty && model.state.allDayEvents.isEmpty)
+ if model.isPreviewingSample {
+ SamplePreviewBanner(model: model)
+ }
if !model.state.auth.authenticated {
OnboardingView(model: model)
} else if let error = model.state.lastError, !hasEvents {
@@ -340,117 +343,147 @@ struct GoogleDataDisclosureView: View {
}
}
-/// First-run experience shown in the popover when no Google account is connected.
-/// Lets the user sign in directly instead of digging through Preferences.
+/// First-run experience shown in the popover when no calendar is connected.
+/// Mac calendars come first (no sign-in); Google adds notes and Meet links; a
+/// sample day lets people see the timeline before choosing either.
struct OnboardingView: View {
@ObservedObject var model: AppModel
var body: some View {
- VStack(spacing: Theme.Spacing.xl) {
- Spacer(minLength: 0)
-
- VStack(spacing: Theme.Spacing.md) {
- ZStack {
- Circle()
- .fill(Color.accentColor.opacity(0.12))
- .frame(width: 72, height: 72)
- Image(nsImage: BrandIcon.menubarImage(size: 36))
- .renderingMode(.template)
- .foregroundStyle(Color.accentColor)
- }
- VStack(spacing: Theme.Spacing.xs) {
+ ScrollView(.vertical) {
+ VStack(spacing: Theme.Spacing.lg) {
+ VStack(spacing: Theme.Spacing.sm) {
+ ZStack {
+ Circle()
+ .fill(Color.accentColor.opacity(0.12))
+ .frame(width: 56, height: 56)
+ Image(nsImage: BrandIcon.menubarImage(size: 28))
+ .renderingMode(.template)
+ .foregroundStyle(Color.accentColor)
+ }
Text("Until")
.font(.title2.weight(.semibold))
- Text(loc("Your next Google Calendar event, always in the menubar."))
+ Text(loc("Your next meeting, always in the menubar."))
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
- .frame(maxWidth: 280)
}
- }
- VStack(alignment: .leading, spacing: Theme.Spacing.md) {
- FeatureRow(
- systemImage: "menubar.arrow.up.rectangle",
- title: loc("Glance at what's next"),
- detail: loc("Your upcoming meeting lives in the menubar.")
- )
- FeatureRow(
- systemImage: "bell.badge",
- title: loc("Never miss a join"),
- detail: loc("Native reminders before your video calls start.")
- )
- FeatureRow(
- systemImage: "doc.text",
- title: loc("One-click meeting notes"),
- detail: loc("Open or create notes straight from an event.")
- )
- }
- .frame(maxWidth: 300)
+ VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
+ optionHeader(systemImage: "calendar", title: loc("Calendars on this Mac"))
+ Text(loc("Use the events already in the Calendar app — iCloud, Google, Outlook, and more. No sign-in."))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button {
+ model.enableMacCalendars()
+ } label: {
+ Text(loc("Use Calendars on This Mac"))
+ .frame(maxWidth: .infinity)
+ }
+ .controlSize(.large)
+ .buttonStyle(.borderedProminent)
+ .disabled(model.isSigningIn)
+ if let error = model.macCalendarError {
+ VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
+ InlineErrorView(message: error)
+ Button(loc("Open System Settings")) { model.openCalendarPrivacySettings() }
+ .buttonStyle(.link)
+ .font(.caption)
+ }
+ }
+ }
+ .card(.inset, padding: Theme.Spacing.md)
- VStack(spacing: Theme.Spacing.sm) {
- GoogleDataDisclosureView()
- .card(.inset, padding: Theme.Spacing.md)
+ HStack(spacing: Theme.Spacing.sm) {
+ Rectangle().fill(Color.secondary.opacity(0.2)).frame(height: 1)
+ Text(loc("or"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Rectangle().fill(Color.secondary.opacity(0.2)).frame(height: 1)
+ }
- Button {
- model.startLogin()
- } label: {
- HStack(spacing: Theme.Spacing.sm) {
- if model.isSigningIn {
- ProgressView()
- .controlSize(.small)
- } else {
- Image(systemName: "person.crop.circle.badge.plus")
+ VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
+ optionHeader(systemImage: "person.crop.circle", title: loc("Google account"))
+ Text(loc("Adds one-click meeting notes and Google Meet links."))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ GoogleDataDisclosureView()
+ Button {
+ model.startLogin()
+ } label: {
+ HStack(spacing: Theme.Spacing.sm) {
+ if model.isSigningIn {
+ ProgressView()
+ .controlSize(.small)
+ }
+ Text(model.isSigningIn ? loc("Opening Google sign-in…") : loc("Sign in with Google"))
}
- Text(model.isSigningIn ? loc("Opening Google sign-in…") : loc("Sign in with Google"))
+ .frame(maxWidth: .infinity)
}
- .frame(maxWidth: .infinity)
- }
- .controlSize(.large)
- .buttonStyle(.borderedProminent)
- .disabled(model.isSigningIn)
+ .controlSize(.large)
+ .disabled(model.isSigningIn)
- if model.isSigningIn {
- Button(loc("Cancel")) {
- model.cancelSignIn()
+ if model.isSigningIn {
+ Button(loc("Cancel")) {
+ model.cancelSignIn()
+ }
+ .buttonStyle(.borderless)
+ .frame(maxWidth: .infinity)
+ }
+ if let error = model.signInError ?? model.state.lastError {
+ InlineErrorView(message: error)
}
- .buttonStyle(.borderless)
}
- }
- .frame(maxWidth: 300)
+ .card(.inset, padding: Theme.Spacing.md)
- if let error = model.signInError ?? model.state.lastError {
- InlineErrorView(message: error)
- .frame(maxWidth: 300)
+ Button {
+ model.startSamplePreview()
+ } label: {
+ Label(loc("Try it with a sample day"), systemImage: "sparkles")
+ .font(.callout)
+ }
+ .buttonStyle(.link)
}
+ .frame(maxWidth: 320)
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, Theme.Spacing.xl)
+ .padding(.vertical, Theme.Spacing.lg)
+ }
+ }
- Spacer(minLength: 0)
+ private func optionHeader(systemImage: String, title: String) -> some View {
+ HStack(spacing: Theme.Spacing.sm) {
+ Image(systemName: systemImage)
+ .foregroundStyle(Color.accentColor)
+ Text(title)
+ .font(.callout.weight(.semibold))
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(Theme.Spacing.xl)
}
}
-private struct FeatureRow: View {
- var systemImage: String
- var title: String
- var detail: String
+/// Shown above the timeline while the sample day is on, with the way out.
+private struct SamplePreviewBanner: View {
+ @ObservedObject var model: AppModel
var body: some View {
- HStack(alignment: .top, spacing: Theme.Spacing.md) {
- Image(systemName: systemImage)
- .font(.body)
- .foregroundStyle(Color.accentColor)
- .frame(width: 22)
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(.callout.weight(.medium))
- Text(detail)
- .font(.caption)
- .foregroundStyle(.secondary)
+ HStack(spacing: Theme.Spacing.sm) {
+ Image(systemName: "sparkles")
+ Text(loc("Sample day"))
+ .font(.caption.weight(.semibold))
+ Spacer(minLength: Theme.Spacing.sm)
+ Button(loc("Connect calendars")) {
+ model.endSamplePreview()
}
- Spacer(minLength: 0)
+ .buttonStyle(.link)
+ .font(.caption.weight(.semibold))
}
+ .font(.caption)
+ .foregroundStyle(Color.orange)
+ .padding(.horizontal, Theme.Spacing.md)
+ .padding(.vertical, Theme.Spacing.xs + 2)
+ .background(Color.orange.opacity(0.12))
}
}
@@ -2011,7 +2044,7 @@ struct SettingsView: View {
var label: String {
switch self {
- case .accounts: return loc("Accounts")
+ case .accounts: return loc("Calendars")
case .general: return loc("General")
case .filter: return loc("Filter")
}
@@ -2019,7 +2052,7 @@ struct SettingsView: View {
var systemImage: String {
switch self {
- case .accounts: return "person.crop.circle"
+ case .accounts: return "calendar"
case .general: return "gearshape"
case .filter: return "line.3.horizontal.decrease.circle"
}
@@ -2100,6 +2133,7 @@ struct SettingsView: View {
private var accountTab: some View {
SettingsTab {
+ MacCalendarsPanel(model: model)
ConnectedAccountsPanel(model: model, draft: $draft)
}
.task {
@@ -2435,6 +2469,83 @@ private struct SettingsTab: View {
}
}
+/// Calendars the Mac already syncs (iCloud, Exchange, Google, CalDAV), read
+/// through EventKit. No sign-in; nothing leaves the machine.
+private struct MacCalendarsPanel: View {
+ @ObservedObject var model: AppModel
+
+ var body: some View {
+ SettingsCard(
+ loc("Calendars on this Mac"),
+ subtitle: loc("Events from the Calendar app — iCloud, Exchange, Google, and more. No sign-in."),
+ accessory: {
+ Toggle("", isOn: Binding(
+ get: { model.config.macCalendarsEnabled },
+ set: { $0 ? model.enableMacCalendars() : model.disableMacCalendars() }
+ ))
+ .labelsHidden()
+ .toggleStyle(.switch)
+ },
+ content: {
+ if model.config.macCalendarsEnabled || model.macCalendarAccess == .denied {
+ accessRow
+ }
+ if let error = model.macCalendarError {
+ InlineErrorView(message: error)
+ }
+ if model.usesMacCalendars {
+ let calendars = model.calendars.filter { $0.source == .eventKit }
+ if calendars.isEmpty {
+ Text(loc("No calendars found in the Calendar app."))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(groupedBySource(calendars), id: \.title) { group in
+ VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
+ Text(group.title.isEmpty ? loc("Other") : group.title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ ForEach(group.calendars) { calendar in
+ CalendarSelectionRow(calendar: calendar, model: model)
+ }
+ }
+ .card(.inset, padding: Theme.Spacing.md)
+ }
+ }
+ }
+ })
+ .task {
+ if model.usesMacCalendars && !model.calendars.contains(where: { $0.source == .eventKit }) {
+ await model.refreshCalendars()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var accessRow: some View {
+ SettingRow(loc("Calendar access"), subtitle: loc("Granted in macOS System Settings")) {
+ HStack(spacing: Theme.Spacing.sm) {
+ Circle()
+ .fill(model.macCalendarAccess == .authorized ? Color.green : Color.orange)
+ .frame(width: 8, height: 8)
+ Text(model.macCalendarAccess.label)
+ if model.macCalendarAccess != .authorized {
+ Button(loc("Open")) { model.openCalendarPrivacySettings() }
+ }
+ }
+ }
+ }
+
+ private func groupedBySource(_ calendars: [CalendarSummary]) -> [(title: String, calendars: [CalendarSummary])] {
+ let groups = Dictionary(grouping: calendars, by: \.sourceTitle)
+ return groups.keys.sorted { lhs, rhs in
+ lhs.localizedStandardCompare(rhs) == .orderedAscending
+ }.map { key in
+ (title: key, calendars: groups[key, default: []].sorted { $0.name < $1.name })
+ }
+ }
+}
+
private struct ConnectedAccountsPanel: View {
@ObservedObject var model: AppModel
@Binding var draft: AppConfig
@@ -2764,6 +2875,15 @@ private struct CalendarSelectionRow: View {
var calendar: CalendarSummary
@ObservedObject var model: AppModel
+ private var subtitle: String? {
+ switch calendar.source {
+ case .google:
+ return calendar.primary ? loc("primary") : calendar.googleId
+ case .eventKit:
+ return calendar.duplicatesGoogleAccount ? loc("Also connected as a Google account") : nil
+ }
+ }
+
var body: some View {
HStack(spacing: Theme.Spacing.md) {
Toggle("", isOn: Binding(
@@ -2780,10 +2900,12 @@ private struct CalendarSelectionRow: View {
Text(calendar.name)
.font(.callout.weight(.medium))
.lineLimit(1)
- Text(calendar.primary ? loc("primary") : calendar.googleId)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
+ if let subtitle {
+ Text(subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
}
Spacer()
}
diff --git a/Tests/UntilTests/MacCalendarSourceTests.swift b/Tests/UntilTests/MacCalendarSourceTests.swift
new file mode 100644
index 0000000..7064c27
--- /dev/null
+++ b/Tests/UntilTests/MacCalendarSourceTests.swift
@@ -0,0 +1,204 @@
+import XCTest
+@testable import Until
+
+final class MacCalendarSourceTests: XCTestCase {
+ private let calendar = CalendarSummary(
+ id: macCalendarKey("ek-cal-1"),
+ googleId: "ek-cal-1",
+ name: "Work",
+ primary: false,
+ backgroundColor: "#007AFF",
+ selected: true,
+ accountEmail: "me@example.com",
+ source: .eventKit,
+ sourceTitle: "me@example.com"
+ )
+
+ func testTimedEventMapsFieldsAndMarksSourceAsEventKit() throws {
+ let start = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let input = MacCalendarEventInput(
+ itemIdentifier: "item-1",
+ eventIdentifier: "EV:1",
+ title: "Design review",
+ notes: "Agenda: https://docs.google.com/document/d/abc/edit\nJoin: https://meet.google.com/abc-defg-hij",
+ location: "Room 4",
+ startDate: start,
+ endDate: start.addingTimeInterval(45 * 60),
+ status: "tentative",
+ isFree: true,
+ isRecurring: true,
+ organizerEmail: "boss@example.com",
+ participants: [
+ .init(email: "me@example.com", name: "Me", status: "declined", isCurrentUser: true, isResource: false),
+ .init(email: "room@example.com", name: "Room 4", status: "accepted", isCurrentUser: false, isResource: true)
+ ]
+ )
+
+ let event = try XCTUnwrap(input.makeEvent(calendar: calendar, now: start.addingTimeInterval(-600)))
+
+ XCTAssertEqual(event.source, .eventKit)
+ XCTAssertEqual(event.calendar.id, calendar.id)
+ XCTAssertEqual(event.title, "Design review")
+ XCTAssertEqual(event.durationMinutes, 45)
+ XCTAssertEqual(event.startMinutesFromNow, 10)
+ XCTAssertEqual(event.status, "tentative")
+ XCTAssertEqual(event.transparency, "free")
+ XCTAssertTrue(event.isRecurring)
+ XCTAssertEqual(event.organizer, "boss@example.com")
+ XCTAssertEqual(event.selfResponse, "declined")
+ XCTAssertEqual(event.attendees.filter(\.resource).count, 1)
+ XCTAssertEqual(event.conferenceUrl, "https://meet.google.com/abc-defg-hij")
+ XCTAssertEqual(event.notesUrl, "https://docs.google.com/document/d/abc/edit")
+ XCTAssertEqual(event.htmlLink, "ical://ekevent/EV:1?method=show&options=more")
+ XCTAssertEqual(event.account.email, "me@example.com")
+ }
+
+ func testMeetingLinkInURLFieldWinsOverNotes() throws {
+ let start = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let input = MacCalendarEventInput(
+ itemIdentifier: "item-2",
+ title: "Sync",
+ notes: "https://zoom.us/j/111",
+ url: "https://teams.microsoft.com/l/meetup-join/abc",
+ startDate: start,
+ endDate: start.addingTimeInterval(1800)
+ )
+
+ let event = try XCTUnwrap(input.makeEvent(calendar: calendar, now: start))
+
+ XCTAssertEqual(event.conferenceUrl, "https://teams.microsoft.com/l/meetup-join/abc")
+ XCTAssertEqual(event.selfResponse, "none")
+ }
+
+ func testAllDayEventEndingAtLastSecondBecomesExclusiveMidnight() throws {
+ let day = makeDate(year: 2026, month: 9, day: 30)
+ let input = MacCalendarEventInput(
+ itemIdentifier: "item-3",
+ title: "Offsite",
+ startDate: day,
+ endDate: makeDate(year: 2026, month: 10, day: 1, hour: 23, minute: 59, second: 59),
+ isAllDay: true
+ )
+
+ let event = try XCTUnwrap(input.makeEvent(calendar: calendar, now: day))
+
+ XCTAssertTrue(event.allDay)
+ XCTAssertEqual(event.startDate, day)
+ XCTAssertEqual(event.endDate, makeDate(year: 2026, month: 10, day: 2))
+ }
+
+ func testAllDayEventEndingAtNextMidnightKeepsItsLength() throws {
+ let day = makeDate(year: 2026, month: 9, day: 30)
+ let input = MacCalendarEventInput(
+ itemIdentifier: "item-4",
+ title: "Holiday",
+ startDate: day,
+ endDate: makeDate(year: 2026, month: 10, day: 1),
+ isAllDay: true
+ )
+
+ let event = try XCTUnwrap(input.makeEvent(calendar: calendar, now: day))
+
+ XCTAssertEqual(event.endDate, makeDate(year: 2026, month: 10, day: 1))
+ XCTAssertEqual(event.durationMinutes, 24 * 60)
+ }
+
+ func testUntitledEventGetsPlaceholderTitle() throws {
+ let start = makeDate(year: 2026, month: 9, day: 30, hour: 9)
+ let input = MacCalendarEventInput(
+ itemIdentifier: "item-5",
+ title: "",
+ startDate: start,
+ endDate: start.addingTimeInterval(600)
+ )
+
+ XCTAssertEqual(try XCTUnwrap(input.makeEvent(calendar: calendar, now: start)).title, "(no title)")
+ }
+
+ func testMacCalendarDefaultsOnUnlessItDuplicatesAGoogleAccount() {
+ let key = macCalendarKey("abc")
+ XCTAssertTrue(isMacCalendarSelected(key, duplicatesGoogleAccount: false, selections: [:]))
+ XCTAssertFalse(isMacCalendarSelected(key, duplicatesGoogleAccount: true, selections: [:]))
+ XCTAssertTrue(isMacCalendarSelected(key, duplicatesGoogleAccount: true, selections: [key: true]))
+ XCTAssertFalse(isMacCalendarSelected(key, duplicatesGoogleAccount: false, selections: [key: false]))
+ }
+
+ func testMergingDropsMacCopiesOfGoogleEventsAndRepeatedMacCopies() {
+ let macRef = CalendarRef(id: "eventkit::a", googleId: "a", primary: false, backgroundColor: "#888", source: .eventKit)
+ let otherMacRef = CalendarRef(id: "eventkit::b", googleId: "b", primary: false, backgroundColor: "#888", source: .eventKit)
+ let google = makeEvent(id: "g1", title: "Standup")
+ let macCopy = makeEvent(id: "m1", title: " standup ", calendar: macRef)
+ let macOnly = makeEvent(id: "m2", title: "Dentist", calendar: macRef)
+ let macOnlyAgain = makeEvent(id: "m3", title: "Dentist", calendar: otherMacRef)
+ let laterMac = makeEvent(
+ id: "m4",
+ title: "Standup",
+ startISO: "2026-07-06T10:00:00Z",
+ endISO: "2026-07-06T11:00:00Z",
+ calendar: macRef
+ )
+
+ let merged = mergingCalendarSources([google, macCopy, macOnly, macOnlyAgain, laterMac])
+
+ XCTAssertEqual(merged.map(\.id), ["g1", "m2", "m4"])
+ }
+
+ func testMacEventsOfferNoGoogleOnlyActions() {
+ let macRef = CalendarRef(id: "eventkit::a", googleId: "a", primary: false, backgroundColor: "#888", source: .eventKit)
+ let actions = EventActionSet.make(event: makeEvent(calendar: macRef), noteURL: nil, isSkipped: false)
+
+ XCTAssertTrue(actions.addable.isEmpty)
+ XCTAssertEqual(actions.common, [.copyDetails, .openInCalendar, .skipInMenubar])
+ }
+
+ func testEventLinksSkipMeetingAndNotesLinksAndCapAtThree() {
+ let description = """
+ Spec https://docs.google.com/document/d/spec/edit
+ Design https://www.figma.com/file/xyz
+ PR https://github.com/acme/app/pull/847
+ Board https://linear.app/acme/issue/ACME-1
+ Call https://zoom.us/j/123
+ """
+ let links = eventLinks(
+ description: description,
+ excluding: ["https://docs.google.com/document/d/spec/edit"]
+ )
+
+ XCTAssertEqual(links.map(\.title), ["Figma", "PR #847", "Linear"])
+ }
+
+ func testLinkTitlesAreRecognizable() {
+ XCTAssertEqual(linkTitle(for: "https://docs.google.com/spreadsheets/d/1"), "Google Sheet")
+ XCTAssertEqual(linkTitle(for: "https://github.com/acme/app/issues/12"), "Issue #12")
+ XCTAssertEqual(linkTitle(for: "https://acme.atlassian.net/browse/X-1"), "Jira")
+ XCTAssertEqual(linkTitle(for: "https://www.example.com/page"), "example.com")
+ }
+
+ func testLegacyConfigDecodesNewSettingsWithDefaults() throws {
+ let json = #"{"oauth":{"clientId":"x"},"filterRules":{"kind":"group","op":"and","children":[]}}"#
+ let config = try JSONDecoder().decode(AppConfig.self, from: Data(json.utf8))
+
+ XCTAssertFalse(config.macCalendarsEnabled)
+ XCTAssertTrue(config.macCalendarSelections.isEmpty)
+ XCTAssertFalse(config.joinHotkeyEnabled)
+ XCTAssertEqual(config.joinHotkeyPreset, "ctrl-opt-j")
+ XCTAssertFalse(config.openMeetingsInApps)
+ XCTAssertEqual(config.startAlertStyle, StartAlertStyle.off.rawValue)
+ XCTAssertTrue(config.menubarShowsTitle)
+ }
+
+ func testNewSettingsRoundTrip() throws {
+ var config = AppConfig.default
+ config.macCalendarsEnabled = true
+ config.macCalendarSelections = [macCalendarKey("a"): false]
+ config.startAlertStyle = StartAlertStyle.fullScreen.rawValue
+ config.menubarShowsTitle = false
+
+ let decoded = try JSONDecoder().decode(AppConfig.self, from: JSONEncoder().encode(config))
+
+ XCTAssertTrue(decoded.macCalendarsEnabled)
+ XCTAssertEqual(decoded.macCalendarSelections, [macCalendarKey("a"): false])
+ XCTAssertEqual(decoded.startAlertStyle, StartAlertStyle.fullScreen.rawValue)
+ XCTAssertFalse(decoded.menubarShowsTitle)
+ }
+}
diff --git a/scripts/entitlements/app.entitlements b/scripts/entitlements/app.entitlements
index bffae23..9a50be0 100644
--- a/scripts/entitlements/app.entitlements
+++ b/scripts/entitlements/app.entitlements
@@ -3,6 +3,8 @@
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+ com.apple.security.personal-information.calendars
+
com.apple.security.application-groups
__WIDGET_GROUP__
diff --git a/scripts/entitlements/mas.entitlements b/scripts/entitlements/mas.entitlements
index 2ecf5a8..1e9f24f 100644
--- a/scripts/entitlements/mas.entitlements
+++ b/scripts/entitlements/mas.entitlements
@@ -9,6 +9,8 @@
com.apple.security.network.server
+ com.apple.security.personal-information.calendars
+
com.apple.security.application-groups
__WIDGET_GROUP__
diff --git a/scripts/package-app.sh b/scripts/package-app.sh
index de2b811..5c69925 100755
--- a/scripts/package-app.sh
+++ b/scripts/package-app.sh
@@ -171,6 +171,10 @@ cat > "$APP_DIR/Contents/Info.plist" <
NSUserNotificationAlertStyle
alert
+ NSCalendarsUsageDescription
+ Until shows your upcoming events from the Calendar app in the menu bar. Nothing leaves your Mac.
+ NSCalendarsFullAccessUsageDescription
+ Until shows your upcoming events from the Calendar app in the menu bar. Nothing leaves your Mac.
UntilWidgetGroupIdentifier
${WIDGET_GROUP_ID}
GoogleOAuthClientID
From d3fe8472df7716c734a545d1f64f964c0d40c2d8 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 11:45:03 +0900
Subject: [PATCH 02/11] Localize the Calendar access prompt
Ship en/ja InfoPlist.strings for the NSCalendars usage descriptions and
declare both localizations in the app's Info.plist.
---
scripts/package-app.sh | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/scripts/package-app.sh b/scripts/package-app.sh
index 5c69925..a7e0d49 100755
--- a/scripts/package-app.sh
+++ b/scripts/package-app.sh
@@ -157,6 +157,13 @@ cat > "$APP_DIR/Contents/Info.plist" <CFBundleURLSchemesuntil
+ CFBundleDevelopmentRegion
+ en
+ CFBundleLocalizations
+
+ en
+ ja
+
CFBundleShortVersionString
${APP_VERSION}
CFBundleVersion
@@ -186,6 +193,17 @@ ${SPARKLE_PLIST_KEYS}
PLIST
+# Localized Info.plist strings (the Calendar access prompt).
+mkdir -p "$APP_DIR/Contents/Resources/en.lproj" "$APP_DIR/Contents/Resources/ja.lproj"
+cat > "$APP_DIR/Contents/Resources/en.lproj/InfoPlist.strings" <<'STRINGS'
+"NSCalendarsUsageDescription" = "Until shows your upcoming events from the Calendar app in the menu bar. Nothing leaves your Mac.";
+"NSCalendarsFullAccessUsageDescription" = "Until shows your upcoming events from the Calendar app in the menu bar. Nothing leaves your Mac.";
+STRINGS
+cat > "$APP_DIR/Contents/Resources/ja.lproj/InfoPlist.strings" <<'STRINGS'
+"NSCalendarsUsageDescription" = "カレンダーAppの予定をメニューバーに表示するために使います。予定がこのMacの外に送られることはありません。";
+"NSCalendarsFullAccessUsageDescription" = "カレンダーAppの予定をメニューバーに表示するために使います。予定がこのMacの外に送られることはありません。";
+STRINGS
+
# App icon. Generated once from scripts/make-icon.swift, then reused so dev
# rebuilds stay fast; delete scripts/Until.icns to regenerate after a redesign.
ICON_SRC="$ROOT/scripts/Until.icns"
From 53923d9e0a7e4b26d2f900e1d222cc827740d041 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 11:45:03 +0900
Subject: [PATCH 03/11] Add meeting workflow features: join shortcut, start
alert, more services
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- A second global shortcut joins the menubar meeting, or the joinable
meeting that is running or starts within 15 minutes.
- An optional start alert puts the meeting on screen as it starts, as a
floating card or a full-screen takeover on every display, until the
user joins, snoozes a minute, or dismisses it.
- Meeting link detection covers 30+ services (Slack huddles, Discord,
Jitsi, FaceTime, Chime, Gather, Tuple, Lark, Tencent Meeting, …), with
path checks for hosts that also serve ordinary pages. Zoom and Teams
links can open in their desktop apps.
- The menubar can hide the event title and show only the countdown.
- The popover footer summarizes today (events, booked time, longest
free stretch left), and the upcoming card shows up to three links
from the invite (docs, designs, PRs).
---
Sources/Until/AppDelegate.swift | 20 +-
Sources/Until/AppModel+Timeline.swift | 63 +++-
Sources/Until/AppModel.swift | 60 +++-
Sources/Until/DemoCalendarData.swift | 10 +-
Sources/Until/DemoFixture.swift | 6 +-
Sources/Until/EventLinks.swift | 167 ++++++++-
Sources/Until/EventNotifier.swift | 21 +-
Sources/Until/HotkeyManager.swift | 42 ++-
.../Resources/en.lproj/Localizable.strings | 23 ++
.../Resources/ja.lproj/Localizable.strings | 23 ++
Sources/Until/StartAlert.swift | 323 ++++++++++++++++++
Sources/Until/Views.swift | 168 ++++++++-
Tests/UntilTests/MeetingWorkflowTests.swift | 196 +++++++++++
13 files changed, 1070 insertions(+), 52 deletions(-)
create mode 100644 Sources/Until/StartAlert.swift
create mode 100644 Tests/UntilTests/MeetingWorkflowTests.swift
diff --git a/Sources/Until/AppDelegate.swift b/Sources/Until/AppDelegate.swift
index 27ef7c1..588de0a 100644
--- a/Sources/Until/AppDelegate.swift
+++ b/Sources/Until/AppDelegate.swift
@@ -77,6 +77,7 @@ final class StatusBarController: NSObject, NSPopoverDelegate {
private var cancellables = Set()
private var settingsWindow: NSWindow?
private var hotkeyManager: HotkeyManager?
+ private var joinHotkeyManager: HotkeyManager?
/// When true the menubar shows only the icon, hiding the event text. Toggled by
/// right-clicking the status item; in-memory only (resets on relaunch).
private var collapsed = false
@@ -87,9 +88,13 @@ final class StatusBarController: NSObject, NSPopoverDelegate {
init(model: AppModel) {
self.model = model
super.init()
- hotkeyManager = HotkeyManager { [weak self] in
+ hotkeyManager = HotkeyManager(id: 1) { [weak self] in
self?.hotkeyToggle()
}
+ joinHotkeyManager = HotkeyManager(id: 2) { [weak self] in
+ guard let self else { return }
+ if !self.model.joinNextMeeting() { self.shakeStatusItem() }
+ }
popover.behavior = .transient
popover.delegate = self
popover.contentSize = NSSize(width: 390, height: 520)
@@ -114,6 +119,17 @@ final class StatusBarController: NSObject, NSPopoverDelegate {
self?.applyHotkey(enabled: enabled, preset: preset)
}
.store(in: &cancellables)
+ model.$config
+ .map { ($0.joinHotkeyEnabled, $0.joinHotkeyPreset) }
+ .removeDuplicates { $0 == $1 }
+ .sink { [weak self] enabled, preset in
+ if enabled {
+ self?.joinHotkeyManager?.register(presetId: preset)
+ } else {
+ self?.joinHotkeyManager?.unregister()
+ }
+ }
+ .store(in: &cancellables)
observeMenubarAvailability()
}
@@ -252,7 +268,7 @@ final class StatusBarController: NSObject, NSPopoverDelegate {
}
nextContent = .event(
when: when,
- title: next.title,
+ title: config.menubarShowsTitle ? next.title : "",
maximumTitleCharacters: config.maxTitleLength
)
} else if state.auth.authenticated && state.events.isEmpty && state.allDayEvents.isEmpty && state.lastError == nil {
diff --git a/Sources/Until/AppModel+Timeline.swift b/Sources/Until/AppModel+Timeline.swift
index be55860..d7e4410 100644
--- a/Sources/Until/AppModel+Timeline.swift
+++ b/Sources/Until/AppModel+Timeline.swift
@@ -47,7 +47,7 @@ extension AppModel {
/// Gaps at or above this length between two consecutive timed rows earn a
/// free-time divider in the popover list.
- static let freeGapThresholdMinutes = 30
+ nonisolated static let freeGapThresholdMinutes = 30
/// Pure: interleaves `FreeGap` dividers into `rows` wherever two
/// consecutive TIMED rows are separated by at least
@@ -448,3 +448,64 @@ extension AppModel {
return .free(next: next)
}
}
+
+/// Today at a glance, for the line under the timeline's day header.
+struct DaySummary: Equatable {
+ var eventCount: Int
+ var bookedMinutes: Int
+ /// The longest stretch still free between now and the day's last busy
+ /// event; nil once nothing busy is left.
+ var longestFreeMinutes: Int?
+
+ var text: String {
+ var parts = [eventCount == 1 ? loc("1 event") : loc("%d events", eventCount)]
+ if bookedMinutes > 0 {
+ parts.append(loc("%@ booked", relativeWhen(bookedMinutes)))
+ }
+ if let longestFreeMinutes, longestFreeMinutes >= AppModel.freeGapThresholdMinutes {
+ parts.append(loc("longest free %@", relativeWhen(longestFreeMinutes)))
+ }
+ return parts.joined(separator: " · ")
+ }
+}
+
+extension AppModel {
+ /// Pure: summarizes the busy timed events on `day`. Overlapping events are
+ /// counted once toward booked time; free time is measured from `now`.
+ nonisolated static func daySummary(timed: [CalendarEvent], day: Date, now: Date) -> DaySummary? {
+ let calendar = Calendar.current
+ let dayStart = calendar.startOfDay(for: day)
+ guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return nil }
+ let events = timed.filter {
+ !$0.allDay && $0.transparency != "free" && $0.startDate < dayEnd && $0.endDate > dayStart
+ }
+ guard !events.isEmpty else { return nil }
+
+ var merged: [(start: Date, end: Date)] = []
+ for event in events.sorted(by: { $0.startDate < $1.startDate }) {
+ let start = max(event.startDate, dayStart)
+ let end = min(event.endDate, dayEnd)
+ if let last = merged.last, start <= last.end {
+ merged[merged.count - 1].end = max(last.end, end)
+ } else {
+ merged.append((start, end))
+ }
+ }
+ let booked = merged.reduce(0) { $0 + $1.end.timeIntervalSince($1.start) }
+
+ var cursor = now
+ var longestFree: TimeInterval?
+ for interval in merged where interval.end > now {
+ if interval.start > cursor {
+ longestFree = max(longestFree ?? 0, interval.start.timeIntervalSince(cursor))
+ }
+ cursor = max(cursor, interval.end)
+ }
+
+ return DaySummary(
+ eventCount: events.count,
+ bookedMinutes: Int((booked / 60).rounded()),
+ longestFreeMinutes: longestFree.map { Int(($0 / 60).rounded()) }
+ )
+ }
+}
diff --git a/Sources/Until/AppModel.swift b/Sources/Until/AppModel.swift
index 076e59f..377590c 100644
--- a/Sources/Until/AppModel.swift
+++ b/Sources/Until/AppModel.swift
@@ -56,6 +56,7 @@ final class AppModel: ObservableObject {
private let store = ConfigStore()
private let notifier = EventNotifier()
private let macCalendars = MacCalendarSource()
+ private let startAlerts = StartAlertController()
private var macCalendarObserver: NSObjectProtocol?
private var accounts: [GoogleAuth] = []
private var rawEvents: [CalendarEvent] = []
@@ -79,6 +80,8 @@ final class AppModel: ObservableObject {
? (demoFixture?.appConfig(base: demoConfig) ?? demoConfig)
: store.load()
observeWake()
+ startAlerts.onJoin = { [weak self] event in self?.join(event) }
+ startAlerts.onOpen = { [weak self] event in self?.open(event) }
refreshLaunchAtLoginState()
applyDefaultLaunchAtLoginIfNeeded()
if runtimeOptions.demoMode {
@@ -689,6 +692,51 @@ final class AppModel: ObservableObject {
// MARK: - Join menubar meeting
+ /// Shows the start alert for the next meeting right away so the chosen
+ /// style can be seen without waiting for a real meeting.
+ func previewStartAlert() {
+ let now = Date()
+ let sample = state.events.first { $0.endDate > now && !$0.conferenceUrl.isEmpty }
+ ?? state.events.first { $0.endDate > now }
+ ?? DemoCalendarData.events(now: now, selectedIds: [], scenario: .upcoming).first { !$0.allDay }
+ guard let sample else { return }
+ startAlerts.preview(sample, style: StartAlertStyle(rawValue: config.startAlertStyle) ?? .floating)
+ }
+
+ /// The meeting the join shortcut opens: the menubar's meeting when it has a
+ /// link, otherwise the joinable meeting that is running or starts soonest
+ /// within the next 15 minutes.
+ nonisolated static func joinTarget(
+ menubarEvent: CalendarEvent?,
+ timed: [CalendarEvent],
+ now: Date
+ ) -> CalendarEvent? {
+ if let menubarEvent, !menubarEvent.conferenceUrl.isEmpty {
+ return menubarEvent
+ }
+ let soon = now.addingTimeInterval(15 * 60)
+ return timed
+ .filter { !$0.allDay && !$0.conferenceUrl.isEmpty && $0.endDate > now && $0.startDate <= soon }
+ .min { lhs, rhs in
+ // A running meeting beats one that hasn't started; among running
+ // ones the most recently started wins, like the menubar.
+ let lhsRunning = lhs.startDate <= now
+ let rhsRunning = rhs.startDate <= now
+ if lhsRunning != rhsRunning { return lhsRunning }
+ return lhsRunning ? lhs.startDate > rhs.startDate : lhs.startDate < rhs.startDate
+ }
+ }
+
+ /// The global join shortcut. Returns false when there is nothing to join.
+ @discardableResult
+ func joinNextMeeting() -> Bool {
+ guard let event = Self.joinTarget(menubarEvent: state.next, timed: state.events, now: Date()) else {
+ return false
+ }
+ join(event)
+ return true
+ }
+
@discardableResult
/// Join the meeting currently shown in the menubar (`state.next`). Returns
/// false when there's nothing shown or it has no conference URL — the caller
@@ -717,7 +765,7 @@ final class AppModel: ObservableObject {
func join(_ event: CalendarEvent) {
guard let url = EventLinks.conferenceURL(for: event) else { return }
- NSWorkspace.shared.open(url)
+ EventLinks.openMeeting(url, preferDesktopApp: config.openMeetingsInApps)
}
func noteURL(for event: CalendarEvent) -> String {
@@ -949,10 +997,20 @@ final class AppModel: ObservableObject {
state.next = AppModel.pickMenubarEvent(config: config, timed: timed, allDay: allDay, now: now)
publishWidgetSnapshot()
let activeEvents = timed.filter { $0.endDate > now }
+ // Local UI only, so demo runs may show it too; the sample preview may not.
+ startAlerts.sync(
+ events: isPreviewingSample ? [] : activeEvents,
+ rules: StartAlertSchedule.Rules(
+ style: StartAlertStyle(rawValue: config.startAlertStyle) ?? .off,
+ leadMinutes: config.startAlertLeadMinutes,
+ videoOnly: config.startAlertVideoOnly
+ )
+ )
let notificationEvents = config.notifyVideoOnly
? activeEvents.filter { !$0.conferenceUrl.isEmpty }
: activeEvents
guard runtimeOptions.allowsNotifications, !isPreviewingSample else { return }
+ notifier.prefersDesktopApps = config.openMeetingsInApps
Task {
await notifier.sync(
events: notificationEvents,
diff --git a/Sources/Until/DemoCalendarData.swift b/Sources/Until/DemoCalendarData.swift
index bba8964..fceee55 100644
--- a/Sources/Until/DemoCalendarData.swift
+++ b/Sources/Until/DemoCalendarData.swift
@@ -387,7 +387,12 @@ enum DemoCalendarData {
DemoEventSpec(
id: "design-review",
title: "Design review: website screenshots",
- description: "Pick the strongest Until states for the landing page and press kit.",
+ description: """
+ Pick the strongest Until states for the landing page and press kit.
+ Brief: https://docs.google.com/document/d/until-launch-brief/edit
+ Layouts: https://www.figma.com/design/until-website
+ Site PR: https://github.com/combinatrix-ai/until/pull/12
+ """,
location: "Google Meet",
start: ctx.nextSlot,
end: minutes(from: ctx.nextSlot, 45),
@@ -590,7 +595,8 @@ enum DemoCalendarData {
notesUrl: spec.notesUrl,
colorId: spec.colorId,
transparency: spec.transparency,
- htmlLink: "https://calendar.google.com/calendar/event?eid=demo-\(spec.id)"
+ htmlLink: "https://calendar.google.com/calendar/event?eid=demo-\(spec.id)",
+ links: eventLinks(description: spec.description, excluding: [spec.conferenceUrl, spec.notesUrl])
)
}
diff --git a/Sources/Until/DemoFixture.swift b/Sources/Until/DemoFixture.swift
index afa0846..054924d 100644
--- a/Sources/Until/DemoFixture.swift
+++ b/Sources/Until/DemoFixture.swift
@@ -266,7 +266,11 @@ extension DemoFixture {
notesUrl: event.notesUrl ?? "",
colorId: event.colorId ?? "9",
transparency: event.transparency ?? "busy",
- htmlLink: "https://calendar.google.com/calendar/event?eid=demo-\(event.id)"
+ htmlLink: "https://calendar.google.com/calendar/event?eid=demo-\(event.id)",
+ links: eventLinks(
+ description: event.description,
+ excluding: [event.conferenceUrl ?? "", event.notesUrl ?? ""]
+ )
)
}
diff --git a/Sources/Until/EventLinks.swift b/Sources/Until/EventLinks.swift
index 9aa2ad7..0526312 100644
--- a/Sources/Until/EventLinks.swift
+++ b/Sources/Until/EventLinks.swift
@@ -1,7 +1,8 @@
+import AppKit
import Foundation
enum EventLinks {
- enum MeetingProvider {
+ enum MeetingProvider: CaseIterable {
case googleMeet
case zoom
case teams
@@ -10,6 +11,29 @@ enum EventLinks {
case goToMeeting
case whereby
case around
+ case slackHuddle
+ case discord
+ case jitsi
+ case eightByEight
+ case faceTime
+ case chime
+ case gather
+ case tuple
+ case ringCentral
+ case skype
+ case dialpad
+ case zohoMeeting
+ case larkMeetings
+ case tencentMeeting
+ case livestorm
+ case streamYard
+ case riverside
+ case pop
+ case calVideo
+ case liveKit
+ case demio
+ case huddle01
+ case signal
var label: String {
switch self {
@@ -21,6 +45,81 @@ enum EventLinks {
case .goToMeeting: return "GoTo Meeting"
case .whereby: return "Whereby"
case .around: return "Around"
+ case .slackHuddle: return "Slack huddle"
+ case .discord: return "Discord"
+ case .jitsi: return "Jitsi Meet"
+ case .eightByEight: return "8x8"
+ case .faceTime: return "FaceTime"
+ case .chime: return "Amazon Chime"
+ case .gather: return "Gather"
+ case .tuple: return "Tuple"
+ case .ringCentral: return "RingCentral"
+ case .skype: return "Skype"
+ case .dialpad: return "Dialpad"
+ case .zohoMeeting: return "Zoho Meeting"
+ case .larkMeetings: return "Lark"
+ case .tencentMeeting: return "Tencent Meeting"
+ case .livestorm: return "Livestorm"
+ case .streamYard: return "StreamYard"
+ case .riverside: return "Riverside"
+ case .pop: return "Pop"
+ case .calVideo: return "Cal Video"
+ case .liveKit: return "LiveKit Meet"
+ case .demio: return "Demio"
+ case .huddle01: return "Huddle01"
+ case .signal: return "Signal"
+ }
+ }
+
+ /// Domains whose links are calls, matched exactly or as a subdomain.
+ /// Hosts that also serve ordinary pages (Slack, Discord, Cal.com,
+ /// Signal) additionally need a call-shaped path; see `pathPrefixes`.
+ fileprivate var domains: [String] {
+ switch self {
+ case .googleMeet: return ["meet.google.com"]
+ case .zoom: return ["zoom.us", "zoom.com", "zoomgov.com"]
+ case .teams: return ["teams.microsoft.com", "teams.live.com", "teams.microsoft.us"]
+ case .webex: return ["webex.com"]
+ case .blueJeans: return ["bluejeans.com"]
+ case .goToMeeting: return ["gotomeeting.com", "goto.com", "gotomeet.me"]
+ case .whereby: return ["whereby.com"]
+ case .around: return ["around.co"]
+ case .slackHuddle: return ["slack.com"]
+ case .discord: return ["discord.gg", "discord.com"]
+ case .jitsi: return ["meet.jit.si"]
+ case .eightByEight: return ["8x8.vc"]
+ case .faceTime: return ["facetime.apple.com"]
+ case .chime: return ["chime.aws"]
+ case .gather: return ["gather.town"]
+ case .tuple: return ["tuple.app"]
+ case .ringCentral: return ["meetings.ringcentral.com", "v.ringcentral.com", "video.ringcentral.com"]
+ case .skype: return ["join.skype.com", "meet.lync.com"]
+ case .dialpad: return ["meetings.dialpad.com"]
+ case .zohoMeeting: return ["meeting.zoho.com", "meeting.zoho.eu", "meeting.zoho.in", "meeting.zoho.jp"]
+ case .larkMeetings: return ["vc.larksuite.com", "vc.feishu.cn"]
+ case .tencentMeeting: return ["meeting.tencent.com", "voovmeeting.com"]
+ case .livestorm: return ["app.livestorm.co"]
+ case .streamYard: return ["streamyard.com"]
+ case .riverside: return ["riverside.fm"]
+ case .pop: return ["pop.com"]
+ case .calVideo: return ["app.cal.com"]
+ case .liveKit: return ["meet.livekit.io"]
+ case .demio: return ["demio.com"]
+ case .huddle01: return ["huddle01.com"]
+ case .signal: return ["signal.link"]
+ }
+ }
+
+ /// For hosts that are mostly not calls, the path that marks a call link.
+ fileprivate var pathPrefixes: [String]? {
+ switch self {
+ case .slackHuddle: return ["/huddle"]
+ case .discord: return ["/channels", "/invite"]
+ case .calVideo: return ["/video"]
+ case .signal: return ["/call"]
+ case .pop: return ["/j"]
+ case .riverside: return ["/studio"]
+ default: return nil
}
}
}
@@ -53,16 +152,62 @@ enum EventLinks {
}
static func meetingProvider(for rawValue: String) -> MeetingProvider? {
- guard !rawValue.isEmpty, let host = URL(string: rawValue)?.host?.lowercased() else { return nil }
- if host == "meet.google.com" { return .googleMeet }
- if matches(host, "zoom.us") { return .zoom }
- if matches(host, "teams.microsoft.com") { return .teams }
- if matches(host, "webex.com") { return .webex }
- if matches(host, "bluejeans.com") { return .blueJeans }
- if matches(host, "gotomeeting.com") || matches(host, "goto.com") { return .goToMeeting }
- if matches(host, "whereby.com") { return .whereby }
- if matches(host, "around.co") { return .around }
- return nil
+ guard !rawValue.isEmpty, let url = URL(string: rawValue), let host = url.host?.lowercased() else { return nil }
+ return MeetingProvider.allCases.first { provider in
+ guard provider.domains.contains(where: { matches(host, $0) }) else { return false }
+ guard let prefixes = provider.pathPrefixes else { return true }
+ // discord.gg invites are always calls; discord.com needs a channel path.
+ if provider == .discord && host.hasSuffix("discord.gg") { return true }
+ let path = url.path.lowercased()
+ return prefixes.contains { path.hasPrefix($0) }
+ }
+ }
+
+ /// The desktop-app form of a meeting link, for services whose apps register
+ /// their own URL scheme. Nil when the link should just open as is.
+ static func desktopAppURL(for url: URL) -> URL? {
+ guard let host = url.host?.lowercased() else { return nil }
+ switch meetingProvider(for: url.absoluteString) {
+ case .zoom:
+ return zoomAppURL(for: url, host: host)
+ case .teams:
+ return URL(string: url.absoluteString.replacingOccurrences(of: "https://", with: "msteams://"))
+ default:
+ return nil
+ }
+ }
+
+ /// zoom.us/j/123?pwd=abc → zoommtg://zoom.us/join?action=join&confno=123&pwd=abc
+ private static func zoomAppURL(for url: URL, host: String) -> URL? {
+ let parts = url.path.split(separator: "/")
+ guard parts.count >= 2, parts[0] == "j" || parts[0] == "w" else { return nil }
+ var components = URLComponents()
+ components.scheme = "zoommtg"
+ components.host = host
+ components.path = "/join"
+ var items = [
+ URLQueryItem(name: "action", value: "join"),
+ URLQueryItem(name: "confno", value: String(parts[1]))
+ ]
+ if let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?.first(where: { $0.name == "pwd" })?.value {
+ items.append(URLQueryItem(name: "pwd", value: pwd))
+ }
+ components.queryItems = items
+ return components.url
+ }
+
+ /// Opens a meeting link, in the service's desktop app when asked to and
+ /// the app is installed; otherwise in the browser as before.
+ @MainActor
+ static func openMeeting(_ url: URL, preferDesktopApp: Bool) {
+ if preferDesktopApp,
+ let appURL = desktopAppURL(for: url),
+ NSWorkspace.shared.urlForApplication(toOpen: appURL) != nil {
+ NSWorkspace.shared.open(appURL)
+ return
+ }
+ NSWorkspace.shared.open(url)
}
/// True when `host` is exactly `domain` or a subdomain of it (`*.domain`).
diff --git a/Sources/Until/EventNotifier.swift b/Sources/Until/EventNotifier.swift
index 3c2439e..e3a0cb0 100644
--- a/Sources/Until/EventNotifier.swift
+++ b/Sources/Until/EventNotifier.swift
@@ -19,6 +19,9 @@ final class EventNotifier: NSObject, UNUserNotificationCenterDelegate {
private let backend: Backend
+ /// Mirrors `AppConfig.openMeetingsInApps` for the Join action.
+ var prefersDesktopApps = false
+
/// Script backend only: fire-once timers, since osascript has no
/// UNUserNotificationCenter to schedule against.
private var scriptTimers: [String: Timer] = [:]
@@ -333,10 +336,10 @@ final class EventNotifier: NSObject, UNUserNotificationCenterDelegate {
return "event-reminder-teams"
case .webex:
return "event-reminder-webex"
- case .blueJeans, .goToMeeting, .whereby, .around:
- return "event-reminder-video"
case nil:
return "event-reminder"
+ default:
+ return "event-reminder-video"
}
}
@@ -377,15 +380,15 @@ final class EventNotifier: NSObject, UNUserNotificationCenterDelegate {
}
let userInfo = response.notification.request.content.userInfo
+ let joinURL = (userInfo["joinURL"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let preferredURL: String?
switch response.actionIdentifier {
case "join":
- preferredURL = userInfo["joinURL"] as? String
+ preferredURL = joinURL
case "open":
preferredURL = userInfo["eventURL"] as? String
case UNNotificationDefaultActionIdentifier:
- preferredURL = (userInfo["joinURL"] as? String).flatMap { $0.isEmpty ? nil : $0 }
- ?? (userInfo["eventURL"] as? String)
+ preferredURL = joinURL ?? (userInfo["eventURL"] as? String)
default:
preferredURL = userInfo["eventURL"] as? String
}
@@ -395,8 +398,12 @@ final class EventNotifier: NSObject, UNUserNotificationCenterDelegate {
let url = URL(string: urlString)
else { return }
- _ = await MainActor.run {
- NSWorkspace.shared.open(url)
+ await MainActor.run {
+ if urlString == joinURL {
+ EventLinks.openMeeting(url, preferDesktopApp: prefersDesktopApps)
+ } else {
+ NSWorkspace.shared.open(url)
+ }
}
}
}
diff --git a/Sources/Until/HotkeyManager.swift b/Sources/Until/HotkeyManager.swift
index ebb79e7..2548181 100644
--- a/Sources/Until/HotkeyManager.swift
+++ b/Sources/Until/HotkeyManager.swift
@@ -1,10 +1,11 @@
import AppKit
import Carbon.HIToolbox
-/// Registers a single global hotkey via the Carbon HotKey API. This works on
+/// Registers one global hotkey via the Carbon HotKey API. This works on
/// modern macOS without Accessibility/Input-Monitoring permissions, unlike a
/// global `NSEvent` monitor. The Carbon event handler is a C function pointer,
/// so we route the callback through a stored closure keyed off the hotkey id.
+/// Each manager owns one id, so the popover and join shortcuts can coexist.
@MainActor
final class HotkeyManager {
/// Named modifier + key-code combinations offered in Settings. The raw string
@@ -24,7 +25,13 @@ final class HotkeyManager {
Preset(id: "ctrl-opt-n", label: "⌃⌥N",
keyCode: UInt32(kVK_ANSI_N), modifiers: UInt32(controlKey | optionKey)),
Preset(id: "ctrl-shift-space", label: "⌃⇧Space",
- keyCode: UInt32(kVK_Space), modifiers: UInt32(controlKey | shiftKey))
+ keyCode: UInt32(kVK_Space), modifiers: UInt32(controlKey | shiftKey)),
+ Preset(id: "ctrl-opt-j", label: "⌃⌥J",
+ keyCode: UInt32(kVK_ANSI_J), modifiers: UInt32(controlKey | optionKey)),
+ Preset(id: "cmd-shift-j", label: "⌘⇧J",
+ keyCode: UInt32(kVK_ANSI_J), modifiers: UInt32(cmdKey | shiftKey)),
+ Preset(id: "ctrl-opt-m", label: "⌃⌥M",
+ keyCode: UInt32(kVK_ANSI_M), modifiers: UInt32(controlKey | optionKey))
]
static func preset(for id: String) -> Preset {
@@ -36,15 +43,16 @@ final class HotkeyManager {
private var handler: (() -> Void)?
private var currentPresetId: String?
- /// The Carbon hotkey identifier signature/id used for our single hotkey.
- private static let signature: OSType = {
+ /// The Carbon hotkey signature shared by all of Until's hotkeys.
+ nonisolated fileprivate static let signature: OSType = {
// 'UNTL'
let chars: [UInt8] = [0x55, 0x4E, 0x54, 0x4C]
return chars.reduce(0) { ($0 << 8) | OSType($1) }
}()
- private static let hotKeyID: UInt32 = 1
+ nonisolated fileprivate let hotKeyID: UInt32
- init(handler: @escaping () -> Void) {
+ init(id: UInt32 = 1, handler: @escaping () -> Void) {
+ hotKeyID = id
self.handler = handler
}
@@ -58,7 +66,7 @@ final class HotkeyManager {
installEventHandlerIfNeeded()
var ref: EventHotKeyRef?
- let hotKeyID = EventHotKeyID(signature: Self.signature, id: Self.hotKeyID)
+ let hotKeyID = EventHotKeyID(signature: Self.signature, id: self.hotKeyID)
let status = RegisterEventHotKey(
preset.keyCode,
preset.modifiers,
@@ -113,14 +121,30 @@ final class HotkeyManager {
}
/// C-compatible Carbon event callback. Recovers the `HotkeyManager` from
-/// `userData` and hops to the main actor to run the stored handler.
+/// `userData` and hops to the main actor to run the stored handler. Every
+/// manager's handler sees every Until hotkey, so one that isn't ours is passed
+/// on to the next handler.
private func hotkeyEventCallback(
_: EventHandlerCallRef?,
- _: EventRef?,
+ _ event: EventRef?,
_ userData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let userData else { return noErr }
let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue()
+ var pressed = EventHotKeyID()
+ let status = GetEventParameter(
+ event,
+ EventParamName(kEventParamDirectObject),
+ EventParamType(typeEventHotKeyID),
+ nil,
+ MemoryLayout.size,
+ nil,
+ &pressed
+ )
+ guard status == noErr,
+ pressed.signature == HotkeyManager.signature,
+ pressed.id == manager.hotKeyID
+ else { return OSStatus(eventNotHandledErr) }
DispatchQueue.main.async {
MainActor.assumeIsolated {
manager.fire()
diff --git a/Sources/Until/Resources/en.lproj/Localizable.strings b/Sources/Until/Resources/en.lproj/Localizable.strings
index 2b08ee8..17acb58 100644
--- a/Sources/Until/Resources/en.lproj/Localizable.strings
+++ b/Sources/Until/Resources/en.lproj/Localizable.strings
@@ -364,3 +364,26 @@
"Try it with a sample day" = "Try it with a sample day";
"Sample day" = "Sample day";
"Connect calendars" = "Connect calendars";
+
+/* MARK: - Meetings: join shortcut, start alert, day summary */
+"Dismiss" = "Dismiss";
+"Snooze 1 min" = "Snooze 1 min";
+"Starting now" = "Starting now";
+"Starts in %@" = "Starts in %@";
+"Join shortcut" = "Join shortcut";
+"Join the current or next meeting from anywhere" = "Join the current or next meeting from anywhere";
+"Show event title" = "Show event title";
+"Turn off to show only the countdown, e.g. while presenting" = "Turn off to show only the countdown, e.g. while presenting";
+"Meetings" = "Meetings";
+"Open in desktop apps" = "Open in desktop apps";
+"Zoom and Teams links open in their apps when installed" = "Zoom and Teams links open in their apps when installed";
+"Start alert" = "Start alert";
+"Put the meeting on screen as it starts, until you join or dismiss it" = "Put the meeting on screen as it starts, until you join or dismiss it";
+"Alert timing" = "Alert timing";
+"How long before the meeting the alert appears" = "How long before the meeting the alert appears";
+"Alert for" = "Alert for";
+"Preview alert" = "Preview alert";
+"1 event" = "1 event";
+"%d events" = "%d events";
+"%@ booked" = "%@ booked";
+"longest free %@" = "longest free %@";
diff --git a/Sources/Until/Resources/ja.lproj/Localizable.strings b/Sources/Until/Resources/ja.lproj/Localizable.strings
index 06cd78e..880457c 100644
--- a/Sources/Until/Resources/ja.lproj/Localizable.strings
+++ b/Sources/Until/Resources/ja.lproj/Localizable.strings
@@ -364,3 +364,26 @@
"Try it with a sample day" = "サンプルの一日で試す";
"Sample day" = "サンプルの一日";
"Connect calendars" = "カレンダーを接続";
+
+/* MARK: - Meetings: join shortcut, start alert, day summary */
+"Dismiss" = "閉じる";
+"Snooze 1 min" = "1分後に再通知";
+"Starting now" = "開始";
+"Starts in %@" = "%@後に開始";
+"Join shortcut" = "参加ショートカット";
+"Join the current or next meeting from anywhere" = "どこからでも、進行中または直近の会議に参加";
+"Show event title" = "予定名を表示";
+"Turn off to show only the countdown, e.g. while presenting" = "オフにするとカウントダウンだけを表示します(発表中などに)";
+"Meetings" = "会議";
+"Open in desktop apps" = "デスクトップアプリで開く";
+"Zoom and Teams links open in their apps when installed" = "ZoomとTeamsのリンクを、インストール済みのアプリで開きます";
+"Start alert" = "開始アラート";
+"Put the meeting on screen as it starts, until you join or dismiss it" = "会議の開始時に画面へ表示し、参加するか閉じるまで出し続けます";
+"Alert timing" = "アラートのタイミング";
+"How long before the meeting the alert appears" = "会議の何分前にアラートを出すか";
+"Alert for" = "アラートの対象";
+"Preview alert" = "アラートを試す";
+"1 event" = "予定1件";
+"%d events" = "予定%d件";
+"%@ booked" = "計%@";
+"longest free %@" = "最長の空き%@";
diff --git a/Sources/Until/StartAlert.swift b/Sources/Until/StartAlert.swift
new file mode 100644
index 0000000..4de6b92
--- /dev/null
+++ b/Sources/Until/StartAlert.swift
@@ -0,0 +1,323 @@
+import AppKit
+import SwiftUI
+
+/// Decides which meeting the start alert is for and when it should appear.
+/// Pure so the timing rules can be tested without windows.
+enum StartAlertSchedule {
+ /// A started meeting still gets its alert for this long, e.g. after the Mac
+ /// wakes from sleep a minute late.
+ static let lateGrace: TimeInterval = 5 * 60
+
+ struct Pending: Equatable {
+ var event: CalendarEvent
+ var fireDate: Date
+ }
+
+ /// The user's start-alert settings.
+ struct Rules: Equatable {
+ var style: StartAlertStyle
+ var leadMinutes: Int
+ var videoOnly: Bool
+ }
+
+ static func key(for event: CalendarEvent) -> String {
+ "\(event.actionKey)@\(event.startDate.timeIntervalSince1970)"
+ }
+
+ static func next(
+ events: [CalendarEvent],
+ rules: Rules,
+ handled: Set,
+ snoozed: [String: Date],
+ now: Date
+ ) -> Pending? {
+ let lead = TimeInterval(max(0, rules.leadMinutes)) * 60
+ return events
+ .filter { event in
+ !event.allDay
+ && event.endDate > now
+ && now < event.startDate.addingTimeInterval(lateGrace)
+ && (!rules.videoOnly || !event.conferenceUrl.isEmpty)
+ }
+ .compactMap { event -> Pending? in
+ let key = key(for: event)
+ if let until = snoozed[key] {
+ return Pending(event: event, fireDate: until)
+ }
+ guard !handled.contains(key) else { return nil }
+ return Pending(event: event, fireDate: event.startDate.addingTimeInterval(-lead))
+ }
+ .min { $0.fireDate < $1.fireDate }
+ }
+}
+
+/// Shows an on-screen alert as a meeting starts: a small floating card at the
+/// top of the screen, or a full-screen takeover on every display. It stays
+/// until the user joins, snoozes, or dismisses it.
+@MainActor
+final class StartAlertController {
+ private var timer: Timer?
+ private var handled: Set = []
+ private var snoozed: [String: Date] = [:]
+ private var panels: [NSPanel] = []
+ private var shownKey: String?
+ private var lastEvents: [CalendarEvent] = []
+ private var lastRules: StartAlertSchedule.Rules?
+
+ var onJoin: (CalendarEvent) -> Void = { _ in }
+ var onOpen: (CalendarEvent) -> Void = { _ in }
+
+ func sync(events: [CalendarEvent], rules: StartAlertSchedule.Rules) {
+ lastEvents = events
+ lastRules = rules
+ timer?.invalidate()
+ timer = nil
+ guard rules.style != .off else {
+ close()
+ return
+ }
+ // Keep the alert that's up; the next one waits until it's handled.
+ guard shownKey == nil else { return }
+ let now = Date()
+ guard let pending = StartAlertSchedule.next(
+ events: events,
+ rules: rules,
+ handled: handled,
+ snoozed: snoozed,
+ now: now
+ ) else { return }
+ if pending.fireDate <= now {
+ show(pending.event, style: rules.style)
+ } else {
+ let timer = Timer(fire: pending.fireDate, interval: 0, repeats: false) { [weak self] _ in
+ Task { @MainActor in self?.resync() }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ self.timer = timer
+ }
+ }
+
+ /// Previews the alert for the given event right away (Settings' test button).
+ func preview(_ event: CalendarEvent, style: StartAlertStyle) {
+ close()
+ show(event, style: style == .off ? .floating : style, isPreview: true)
+ }
+
+ private func resync() {
+ guard let lastRules else { return }
+ sync(events: lastEvents, rules: lastRules)
+ }
+
+ private func show(_ event: CalendarEvent, style: StartAlertStyle, isPreview: Bool = false) {
+ let key = StartAlertSchedule.key(for: event)
+ if !isPreview {
+ snoozed.removeValue(forKey: key)
+ handled.insert(key)
+ }
+ shownKey = key
+ let actions = StartAlertActions(
+ join: { [weak self] in
+ self?.onJoin(event)
+ self?.finish()
+ },
+ snooze: { [weak self] in
+ if !isPreview {
+ self?.snoozed[key] = Date().addingTimeInterval(60)
+ }
+ self?.finish()
+ },
+ dismiss: { [weak self] in self?.finish() },
+ open: { [weak self] in
+ self?.onOpen(event)
+ self?.finish()
+ }
+ )
+ switch style {
+ case .off:
+ return
+ case .floating:
+ panels = [makeFloatingPanel(event: event, actions: actions)]
+ panels.forEach { $0.orderFrontRegardless() }
+ case .fullScreen:
+ panels = NSScreen.screens.map { makeFullScreenPanel(event: event, actions: actions, screen: $0) }
+ NSApp.activate(ignoringOtherApps: true)
+ panels.forEach { $0.orderFrontRegardless() }
+ // The main display's panel takes the keyboard so Return joins and
+ // Escape dismisses.
+ panels.first?.makeKey()
+ }
+ }
+
+ private func finish() {
+ close()
+ resync()
+ }
+
+ private func close() {
+ panels.forEach { $0.orderOut(nil) }
+ panels = []
+ shownKey = nil
+ }
+
+ private func makeFloatingPanel(event: CalendarEvent, actions: StartAlertActions) -> NSPanel {
+ let size = NSSize(width: 380, height: 132)
+ let screen = NSScreen.main ?? NSScreen.screens.first
+ let visible = screen?.visibleFrame ?? .zero
+ let origin = NSPoint(x: visible.midX - size.width / 2, y: visible.maxY - size.height - 10)
+ let panel = StartAlertPanel(
+ contentRect: NSRect(origin: origin, size: size),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.level = .statusBar
+ configure(panel)
+ panel.contentView = NSHostingView(rootView: StartAlertView(event: event, style: .floating, actions: actions))
+ return panel
+ }
+
+ private func makeFullScreenPanel(event: CalendarEvent, actions: StartAlertActions, screen: NSScreen) -> NSPanel {
+ let panel = StartAlertPanel(
+ contentRect: screen.frame,
+ styleMask: [.borderless],
+ backing: .buffered,
+ defer: false
+ )
+ // Above full-screen apps and the menubar, like a screen saver would be.
+ panel.level = .screenSaver
+ configure(panel)
+ panel.setFrame(screen.frame, display: false)
+ panel.contentView = NSHostingView(rootView: StartAlertView(event: event, style: .fullScreen, actions: actions))
+ return panel
+ }
+
+ private func configure(_ panel: NSPanel) {
+ panel.isReleasedWhenClosed = false
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = false
+ panel.hidesOnDeactivate = false
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
+ }
+}
+
+/// A borderless panel that can still take the keyboard.
+private final class StartAlertPanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+}
+
+struct StartAlertActions {
+ var join: () -> Void
+ var snooze: () -> Void
+ var dismiss: () -> Void
+ var open: () -> Void
+}
+
+struct StartAlertView: View {
+ var event: CalendarEvent
+ var style: StartAlertStyle
+ var actions: StartAlertActions
+
+ var body: some View {
+ switch style {
+ case .fullScreen:
+ ZStack {
+ Color.black.opacity(0.55)
+ .ignoresSafeArea()
+ .onTapGesture {}
+ card
+ .frame(width: 420)
+ .padding(Theme.Spacing.xl)
+ .background(
+ Color(nsColor: .windowBackgroundColor),
+ in: RoundedRectangle(cornerRadius: 18, style: .continuous)
+ )
+ .shadow(color: .black.opacity(0.35), radius: 30, y: 12)
+ }
+ default:
+ card
+ .padding(Theme.Spacing.md)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .background(
+ Color(nsColor: .windowBackgroundColor),
+ in: RoundedRectangle(cornerRadius: 14, style: .continuous)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08))
+ )
+ .padding(6)
+ .shadow(color: .black.opacity(0.22), radius: 12, y: 6)
+ }
+ }
+
+ private var card: some View {
+ TimelineView(.periodic(from: .now, by: 1)) { context in
+ let started = context.date >= event.startDate
+ let tint: Color = started ? .green : .accentColor
+ VStack(alignment: style == .fullScreen ? .center : .leading, spacing: Theme.Spacing.xs) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(kicker(now: context.date))
+ .font(.caption.weight(.bold))
+ .foregroundStyle(tint)
+ if style != .fullScreen {
+ Spacer(minLength: Theme.Spacing.sm)
+ Text(clock(event.startDate))
+ .font(.caption.weight(.semibold))
+ .monospacedDigit()
+ .foregroundStyle(tint)
+ }
+ }
+ Text(event.title)
+ .font(.system(size: style == .fullScreen ? 22 : 15, weight: .bold))
+ .lineLimit(2)
+ .multilineTextAlignment(style == .fullScreen ? .center : .leading)
+ if !detail.isEmpty {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ buttons(tint: tint)
+ .padding(.top, Theme.Spacing.sm)
+ }
+ .frame(maxWidth: .infinity, alignment: style == .fullScreen ? .center : .leading)
+ }
+ }
+
+ @ViewBuilder
+ private func buttons(tint: Color) -> some View {
+ HStack(spacing: Theme.Spacing.sm) {
+ if !event.conferenceUrl.isEmpty {
+ Button(action: actions.join) {
+ Label(loc("Join"), systemImage: "video.fill")
+ .frame(minWidth: style == .fullScreen ? 90 : nil)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(tint)
+ .controlSize(style == .fullScreen ? .large : .regular)
+ .keyboardShortcut(.defaultAction)
+ } else {
+ Button(loc("Open Event"), action: actions.open)
+ .controlSize(style == .fullScreen ? .large : .regular)
+ .keyboardShortcut(.defaultAction)
+ }
+ Button(loc("Snooze 1 min"), action: actions.snooze)
+ .controlSize(style == .fullScreen ? .large : .regular)
+ Button(loc("Dismiss"), action: actions.dismiss)
+ .controlSize(style == .fullScreen ? .large : .regular)
+ .keyboardShortcut(.cancelAction)
+ }
+ }
+
+ private func kicker(now: Date) -> String {
+ if now >= event.startDate {
+ return loc("Starting now")
+ }
+ return loc("Starts in %@", relativeWhen(max(1, roundedMinutes(from: now, to: event.startDate))))
+ }
+
+ private var detail: String {
+ heroMetadataParts(for: event).joined(separator: " · ")
+ }
+}
diff --git a/Sources/Until/Views.swift b/Sources/Until/Views.swift
index bd1b24f..a55cdeb 100644
--- a/Sources/Until/Views.swift
+++ b/Sources/Until/Views.swift
@@ -284,10 +284,13 @@ struct PanelView: View {
Button(loc("Cancel"), role: .cancel) {}
}
- Text(statusText(now: now))
+ // Today's shape when there is one; the sync time moves to the tooltip.
+ let summary = AppModel.daySummary(timed: model.state.events, day: now, now: now)
+ Text(summary?.text ?? statusText(now: now))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
+ .help(statusText(now: now))
Spacer()
@@ -351,22 +354,26 @@ struct OnboardingView: View {
var body: some View {
ScrollView(.vertical) {
- VStack(spacing: Theme.Spacing.lg) {
- VStack(spacing: Theme.Spacing.sm) {
+ VStack(spacing: Theme.Spacing.md) {
+ VStack(spacing: Theme.Spacing.xs) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.12))
- .frame(width: 56, height: 56)
- Image(nsImage: BrandIcon.menubarImage(size: 28))
+ .frame(width: 44, height: 44)
+ Image(nsImage: BrandIcon.menubarImage(size: 22))
.renderingMode(.template)
.foregroundStyle(Color.accentColor)
}
- Text("Until")
- .font(.title2.weight(.semibold))
Text(loc("Your next meeting, always in the menubar."))
- .font(.callout)
- .foregroundStyle(.secondary)
+ .font(.callout.weight(.medium))
.multilineTextAlignment(.center)
+ Button {
+ model.startSamplePreview()
+ } label: {
+ Label(loc("Try it with a sample day"), systemImage: "sparkles")
+ .font(.caption)
+ }
+ .buttonStyle(.link)
}
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
@@ -437,19 +444,11 @@ struct OnboardingView: View {
}
}
.card(.inset, padding: Theme.Spacing.md)
-
- Button {
- model.startSamplePreview()
- } label: {
- Label(loc("Try it with a sample day"), systemImage: "sparkles")
- .font(.callout)
- }
- .buttonStyle(.link)
}
.frame(maxWidth: 320)
.frame(maxWidth: .infinity)
.padding(.horizontal, Theme.Spacing.xl)
- .padding(.vertical, Theme.Spacing.lg)
+ .padding(.vertical, Theme.Spacing.md)
}
}
@@ -804,6 +803,10 @@ private struct HeroTimelineRow: View {
}
}
+ if !event.links.isEmpty {
+ EventLinkChips(event: event)
+ }
+
// Without an attached action the row would hold nothing but the
// hover-revealed ellipsis, stretching the card for no content.
if hasAttachedActions {
@@ -926,6 +929,41 @@ private struct HeroTimelineRow: View {
}
+/// What the meeting is about — the doc, design, or PR linked from the invite.
+private struct EventLinkChips: View {
+ var event: CalendarEvent
+
+ var body: some View {
+ HStack(spacing: Theme.Spacing.xs) {
+ ForEach(event.links, id: \.url) { link in
+ Button {
+ if let url = EventLinks.authenticatedURL(from: link.url, accountEmail: event.account.email) {
+ NSWorkspace.shared.open(url)
+ }
+ } label: {
+ Label(link.title, systemImage: systemImage(for: link))
+ .font(.caption)
+ .lineLimit(1)
+ .padding(.horizontal, Theme.Spacing.sm)
+ .padding(.vertical, 2)
+ .background(Color.primary.opacity(0.06), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ .help(link.url)
+ }
+ }
+ }
+
+ private func systemImage(for link: EventLink) -> String {
+ let title = link.title
+ if title.hasPrefix("Google") { return "doc.text" }
+ if title == "Figma" || title == "Miro" { return "paintpalette" }
+ if title.hasPrefix("PR #") { return "arrow.triangle.pull" }
+ if title.hasPrefix("Issue #") || title == "Jira" || title == "Linear" { return "checklist" }
+ return "link"
+ }
+}
+
private struct CondensedHeroStrip: View {
var event: CalendarEvent
@ObservedObject var model: AppModel
@@ -2180,6 +2218,21 @@ struct SettingsView: View {
.labelsHidden()
}
}
+ Divider()
+ SettingRow(loc("Join shortcut"), subtitle: loc("Join the current or next meeting from anywhere")) {
+ HStack(spacing: Theme.Spacing.sm) {
+ Picker("", selection: $draft.joinHotkeyPreset) {
+ ForEach(joinHotkeyPresets) { preset in
+ Text(preset.label).tag(preset.id)
+ }
+ }
+ .labelsHidden()
+ .frame(width: 130)
+ .disabled(!draft.joinHotkeyEnabled)
+ Toggle("", isOn: $draft.joinHotkeyEnabled)
+ .labelsHidden()
+ }
+ }
#if SPARKLE
Divider()
SettingRow(
@@ -2226,11 +2279,20 @@ struct SettingsView: View {
}
SettingsCard(loc("Menubar")) {
+ SettingRow(
+ loc("Show event title"),
+ subtitle: loc("Turn off to show only the countdown, e.g. while presenting")
+ ) {
+ Toggle("", isOn: $draft.menubarShowsTitle)
+ .labelsHidden()
+ }
+ Divider()
stepperRow(
loc("Max title length"),
subtitle: loc("Longer event titles are shortened with “…”"),
value: $draft.maxTitleLength, range: 10...120, step: 1, unit: loc("characters")
)
+ .disabled(!draft.menubarShowsTitle)
Divider()
SettingRow(loc("Show upcoming event"), subtitle: loc("When the next event appears in the menubar")) {
Picker("", selection: menubarWindowSelection) {
@@ -2259,6 +2321,8 @@ struct SettingsView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
+ meetingsCard
+
SettingsCard(loc("Notifications")) {
SettingRow(loc("Event reminders"), subtitle: loc("Send a notification before an event starts")) {
Toggle("", isOn: $draft.notifyEnabled)
@@ -2336,6 +2400,74 @@ struct SettingsView: View {
}
}
+ /// The join shortcut can't reuse the popover shortcut's keys while that
+ /// one is on.
+ private var joinHotkeyPresets: [HotkeyManager.Preset] {
+ HotkeyManager.presets.filter { !draft.hotkeyEnabled || $0.id != draft.hotkeyPreset }
+ }
+
+ private var startAlertStyle: Binding {
+ Binding(
+ get: { StartAlertStyle(rawValue: draft.startAlertStyle) ?? .off },
+ set: { draft.startAlertStyle = $0.rawValue }
+ )
+ }
+
+ private var meetingsCard: some View {
+ SettingsCard(loc("Meetings")) {
+ SettingRow(
+ loc("Open in desktop apps"),
+ subtitle: loc("Zoom and Teams links open in their apps when installed")
+ ) {
+ Toggle("", isOn: $draft.openMeetingsInApps)
+ .labelsHidden()
+ }
+ Divider()
+ SettingRow(
+ loc("Start alert"),
+ subtitle: loc("Put the meeting on screen as it starts, until you join or dismiss it")
+ ) {
+ Picker("", selection: startAlertStyle) {
+ ForEach(StartAlertStyle.allCases) { style in
+ Text(style.label).tag(style)
+ }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .frame(width: 240)
+ }
+ Divider()
+ stepperRow(
+ loc("Alert timing"),
+ subtitle: loc("How long before the meeting the alert appears"),
+ value: $draft.startAlertLeadMinutes, range: 0...10, step: 1, unit: loc("min before")
+ )
+ .disabled(startAlertStyle.wrappedValue == .off)
+ Divider()
+ SettingRow(loc("Alert for"), subtitle: nil) {
+ Picker("", selection: $draft.startAlertVideoOnly) {
+ Text(loc("All events")).tag(false)
+ Text(loc("Video only")).tag(true)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .frame(width: 210)
+ }
+ .disabled(startAlertStyle.wrappedValue == .off)
+ Divider()
+ HStack {
+ Button {
+ model.saveConfig(draft)
+ model.previewStartAlert()
+ } label: {
+ Label(loc("Preview alert"), systemImage: "rectangle.inset.topright.filled")
+ }
+ .disabled(startAlertStyle.wrappedValue == .off)
+ Spacer()
+ }
+ }
+ }
+
private var filtersTab: some View {
let preview = model.filterPreview(for: draft.filterRules)
return SettingsTab(maxColumnWidth: .infinity) {
diff --git a/Tests/UntilTests/MeetingWorkflowTests.swift b/Tests/UntilTests/MeetingWorkflowTests.swift
new file mode 100644
index 0000000..f28674e
--- /dev/null
+++ b/Tests/UntilTests/MeetingWorkflowTests.swift
@@ -0,0 +1,196 @@
+import XCTest
+@testable import Until
+
+final class MeetingWorkflowTests: XCTestCase {
+ // MARK: - Meeting services
+
+ func testAddedServicesAreDetected() {
+ let cases: [(String, EventLinks.MeetingProvider)] = [
+ ("https://app.slack.com/huddle/T123/C456", .slackHuddle),
+ ("https://discord.gg/abc", .discord),
+ ("https://discord.com/channels/1/2", .discord),
+ ("https://meet.jit.si/Room", .jitsi),
+ ("https://facetime.apple.com/join#v=1", .faceTime),
+ ("https://app.chime.aws/meetings/123", .chime),
+ ("https://app.gather.town/app/x", .gather),
+ ("https://zoomgov.com/j/123", .zoom),
+ ("https://teams.live.com/meet/123", .teams),
+ ("https://vc.larksuite.com/j/123", .larkMeetings),
+ ("https://meeting.tencent.com/dm/abc", .tencentMeeting),
+ ("https://app.cal.com/video/abc", .calVideo),
+ ("https://signal.link/call/#key=abc", .signal)
+ ]
+ for (url, provider) in cases {
+ XCTAssertEqual(EventLinks.meetingProvider(for: url), provider, url)
+ }
+ XCTAssertGreaterThanOrEqual(EventLinks.MeetingProvider.allCases.count, 30)
+ }
+
+ func testOrdinaryPagesOnCallHostsAreNotMeetings() {
+ XCTAssertNil(EventLinks.meetingProvider(for: "https://acme.slack.com/archives/C123"))
+ XCTAssertNil(EventLinks.meetingProvider(for: "https://discord.com/developers"))
+ XCTAssertNil(EventLinks.meetingProvider(for: "https://app.cal.com/team/acme"))
+ XCTAssertNil(EventLinks.meetingProvider(for: "https://signal.link/"))
+ }
+
+ func testZoomLinkBecomesAppJoinURLWithPassword() {
+ let url = URL(string: "https://acme.zoom.us/j/123456789?pwd=secret")!
+ XCTAssertEqual(
+ EventLinks.desktopAppURL(for: url)?.absoluteString,
+ "zoommtg://acme.zoom.us/join?action=join&confno=123456789&pwd=secret"
+ )
+ }
+
+ func testTeamsLinkUsesTeamsScheme() {
+ let url = URL(string: "https://teams.microsoft.com/l/meetup-join/abc")!
+ XCTAssertEqual(EventLinks.desktopAppURL(for: url)?.absoluteString, "msteams://teams.microsoft.com/l/meetup-join/abc")
+ }
+
+ func testOtherLinksStayInTheBrowser() {
+ XCTAssertNil(EventLinks.desktopAppURL(for: URL(string: "https://meet.google.com/abc-defg-hij")!))
+ XCTAssertNil(EventLinks.desktopAppURL(for: URL(string: "https://zoom.us/my/vanity")!))
+ }
+
+ // MARK: - Join shortcut
+
+ func testJoinPrefersTheMenubarMeetingWhenItHasALink() {
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let menubar = event("menubar", now: now, startOffset: 20, link: true)
+ let running = event("running", now: now, startOffset: -5, link: true)
+
+ XCTAssertEqual(AppModel.joinTarget(menubarEvent: menubar, timed: [running, menubar], now: now)?.id, "menubar")
+ }
+
+ func testJoinFallsBackToTheRunningMeeting() {
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let menubar = event("menubar", now: now, startOffset: 3, link: false)
+ let older = event("older", now: now, startOffset: -40, link: true)
+ let recent = event("recent", now: now, startOffset: -5, link: true)
+ let soon = event("soon", now: now, startOffset: 5, link: true)
+
+ XCTAssertEqual(
+ AppModel.joinTarget(menubarEvent: menubar, timed: [older, recent, soon, menubar], now: now)?.id,
+ "recent"
+ )
+ }
+
+ func testJoinTakesTheSoonestUpcomingMeetingWithinFifteenMinutes() {
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let soon = event("soon", now: now, startOffset: 10, link: true)
+ let later = event("later", now: now, startOffset: 30, link: true)
+
+ XCTAssertEqual(AppModel.joinTarget(menubarEvent: nil, timed: [later, soon], now: now)?.id, "soon")
+ XCTAssertNil(AppModel.joinTarget(menubarEvent: nil, timed: [later], now: now))
+ }
+
+ // MARK: - Start alert
+
+ func testStartAlertFiresLeadMinutesBeforeTheMeeting() {
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let meeting = event("m", now: now, startOffset: 10, link: true)
+
+ let pending = StartAlertSchedule.next(
+ events: [meeting], rules: .init(style: .floating, leadMinutes: 2, videoOnly: true), handled: [], snoozed: [:], now: now
+ )
+
+ XCTAssertEqual(pending?.event.id, "m")
+ XCTAssertEqual(pending?.fireDate, meeting.startDate.addingTimeInterval(-120))
+ }
+
+ func testStartAlertSkipsHandledLinklessAndLongStartedMeetings() {
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let handled = event("handled", now: now, startOffset: 5, link: true)
+ let noLink = event("no-link", now: now, startOffset: 6, link: false)
+ let longStarted = event("long-started", now: now, startOffset: -10, link: true)
+
+ let pending = StartAlertSchedule.next(
+ events: [handled, noLink, longStarted],
+ rules: .init(style: .floating, leadMinutes: 1, videoOnly: true),
+ handled: [StartAlertSchedule.key(for: handled)],
+ snoozed: [:],
+ now: now
+ )
+ XCTAssertNil(pending)
+
+ let allEvents = StartAlertSchedule.next(
+ events: [noLink], rules: .init(style: .floating, leadMinutes: 1, videoOnly: false), handled: [], snoozed: [:], now: now
+ )
+ XCTAssertEqual(allEvents?.event.id, "no-link")
+ }
+
+ func testSnoozedAlertReturnsAtTheSnoozeTime() {
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 10)
+ let meeting = event("m", now: now, startOffset: 0, link: true)
+ let key = StartAlertSchedule.key(for: meeting)
+ let until = now.addingTimeInterval(60)
+
+ let pending = StartAlertSchedule.next(
+ events: [meeting], rules: .init(style: .floating, leadMinutes: 1, videoOnly: true), handled: [key], snoozed: [key: until], now: now
+ )
+
+ XCTAssertEqual(pending?.fireDate, until)
+ }
+
+ // MARK: - Day summary
+
+ func testDaySummaryMergesOverlapsAndFindsTheLongestRemainingGap() throws {
+ let day = makeDate(year: 2026, month: 9, day: 30)
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 9)
+ let events = [
+ timed("a", day: day, from: (9, 0), to: (10, 0)),
+ timed("b", day: day, from: (9, 30), to: (10, 30)),
+ timed("c", day: day, from: (12, 0), to: (13, 0)),
+ timed("d", day: day, from: (13, 30), to: (14, 0)),
+ timed("free", day: day, from: (15, 0), to: (16, 0), transparency: "free")
+ ]
+
+ let summary = try XCTUnwrap(AppModel.daySummary(timed: events, day: day, now: now))
+
+ XCTAssertEqual(summary.eventCount, 4)
+ XCTAssertEqual(summary.bookedMinutes, 180)
+ XCTAssertEqual(summary.longestFreeMinutes, 90)
+ }
+
+ func testDaySummaryHasNoFreeTimeOnceTheDayIsDone() throws {
+ let day = makeDate(year: 2026, month: 9, day: 30)
+ let now = makeDate(year: 2026, month: 9, day: 30, hour: 18)
+ let summary = try XCTUnwrap(
+ AppModel.daySummary(timed: [timed("a", day: day, from: (9, 0), to: (10, 0))], day: day, now: now)
+ )
+
+ XCTAssertNil(summary.longestFreeMinutes)
+ XCTAssertNil(AppModel.daySummary(timed: [], day: day, now: now))
+ }
+
+ // MARK: - Helpers
+
+ private func event(_ id: String, now: Date, startOffset minutes: Int, link: Bool) -> CalendarEvent {
+ let start = now.addingTimeInterval(TimeInterval(minutes) * 60)
+ return makeEvent(
+ id: id,
+ title: id,
+ startISO: isoString(from: start),
+ endISO: isoString(from: start.addingTimeInterval(30 * 60)),
+ conferenceUrl: link ? "https://meet.google.com/\(id)" : ""
+ )
+ }
+
+ private func timed(
+ _ id: String,
+ day: Date,
+ from start: (Int, Int),
+ to end: (Int, Int),
+ transparency: String = "busy"
+ ) -> CalendarEvent {
+ let calendar = Calendar.current
+ let startDate = calendar.date(bySettingHour: start.0, minute: start.1, second: 0, of: day)!
+ let endDate = calendar.date(bySettingHour: end.0, minute: end.1, second: 0, of: day)!
+ return makeEvent(
+ id: id,
+ title: id,
+ startISO: isoString(from: startDate),
+ endISO: isoString(from: endDate),
+ transparency: transparency
+ )
+ }
+}
From 49b19fa529b61c4b374f3bdeabb90f2b6e314569 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 11:48:07 +0900
Subject: [PATCH 04/11] Build universal releases and publish a stable Until.dmg
- UNIVERSAL=1 builds the app and the widget extension for Apple Silicon
and Intel; release.sh turns it on by default.
- The release workflow also uploads the installer as Until.dmg so
releases/latest/download/Until.dmg always points at the newest one.
- Add a Homebrew cask template for the combinatrix-ai/homebrew-tap repo.
- Document the new sources and the release steps in AGENTS.md.
---
.github/workflows/release.yml | 5 ++++-
AGENTS.md | 19 +++++++++++++++----
packaging/homebrew/until.rb | 30 ++++++++++++++++++++++++++++++
scripts/package-app.sh | 26 +++++++++++++++++++++-----
scripts/release.sh | 2 ++
5 files changed, 72 insertions(+), 10 deletions(-)
create mode 100644 packaging/homebrew/until.rb
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cd45175..22cbd83 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -79,6 +79,9 @@ jobs:
run: |
TAG="${GITHUB_REF_NAME:-$(git rev-parse --short HEAD)}"
cp .build/release/Until.dmg "Until-${TAG}.dmg"
+ # A stable name too, so releases/latest/download/Until.dmg always
+ # points at the newest installer (the website and README link it).
+ cp .build/release/Until.dmg Until.dmg
# Sparkle updates from a .zip of the notarized + stapled .app (the .dmg
# is only for first-time install). Stapled ticket rides inside the zip
# so the update validates offline.
@@ -110,7 +113,7 @@ jobs:
# releases/latest/download/appcast.xml URL that the app's SUFeedURL
# points at; the .zip is the update payload, the .dmg the installer.
gh release create "$GITHUB_REF_NAME" \
- Until-*.dmg Until-*.zip appcast.xml \
+ Until.dmg Until-*.dmg Until-*.zip appcast.xml \
--title "$GITHUB_REF_NAME" --generate-notes
- name: Upload app artifact
diff --git a/AGENTS.md b/AGENTS.md
index 16c7e1f..75dfabc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,8 @@
# Until - build & run
-macOS menubar calendar app (Swift + SwiftUI/AppKit). Shows your next Google
-Calendar event in the menubar, filtered by user-authored structured rules.
+macOS menubar calendar app (Swift + SwiftUI/AppKit). Shows your next event in
+the menubar — from the Mac's Calendar app (EventKit) and/or Google accounts —
+filtered by user-authored structured rules.
The SwiftPM package, target, and product are **Until** (bundle id `ai.combinatrix.until`).
## Development workflow
@@ -45,8 +46,13 @@ checklist. A release-status question stays read-only.
```
scripts/release.sh
```
-Builds release, Developer ID-signs with the hardened runtime, notarizes, and
-staples. Needs a notarytool keychain profile (`until-notary` by default) — see
+Builds a universal (Apple Silicon + Intel) release, Developer ID-signs with the
+hardened runtime, notarizes, and staples. `UNIVERSAL=0` builds only this Mac's
+architecture. The release workflow uploads the installer twice: as
+`Until-vX.Y.Z.dmg` and as `Until.dmg`, which `releases/latest/download/Until.dmg`
+(website and README) resolves to. After a release, update the Homebrew cask:
+copy `packaging/homebrew/until.rb` into `combinatrix-ai/homebrew-tap` as
+`Casks/until.rb` with the new `version` and `sha256`. Needs a notarytool keychain profile (`until-notary` by default) — see
the header of `scripts/release.sh`. The app icon is generated by
`scripts/make-icon.swift` into `scripts/Until.icns` (delete it to regenerate).
@@ -77,6 +83,11 @@ the header of `scripts/release.sh`. The app icon is generated by
- `Views.swift` — SwiftUI panel and preferences UI
- `GoogleAuth.swift` — Google OAuth PKCE + loopback, token refresh
- `CalendarClient.swift` — Calendar REST fetch + event normalization
+ - `MacCalendarSource.swift` — EventKit (Calendar app) calendars and events,
+ cross-source de-duplication, invite link extraction
+ - `EventLinks.swift` — meeting service detection and desktop-app links
+ - `StartAlert.swift` — the optional floating / full-screen start alert
+ - `HotkeyManager.swift` — Carbon global hotkeys (popover toggle, join)
- `RuleEngine.swift` — native structured filter evaluator
- `AppModel.swift` — config, polling, derived `AppState`, notification sync
- `EventNotifier.swift` — native UserNotifications reminders
diff --git a/packaging/homebrew/until.rb b/packaging/homebrew/until.rb
new file mode 100644
index 0000000..60c3e61
--- /dev/null
+++ b/packaging/homebrew/until.rb
@@ -0,0 +1,30 @@
+# Homebrew cask for Until, published from the combinatrix-ai/homebrew-tap
+# repository as Casks/until.rb. After each release, update `version` and
+# `sha256` (shasum -a 256 Until-v.dmg) and push the tap.
+cask "until" do
+ version "1.2.0"
+ sha256 "REPLACE_WITH_SHA256_OF_Until-v1.2.0.dmg"
+
+ url "https://github.com/combinatrix-ai/until/releases/download/v#{version}/Until-v#{version}.dmg"
+ name "Until"
+ desc "Next meeting countdown, day timeline, and meeting notes in the menu bar"
+ homepage "https://until.combinatrix.ai/"
+
+ livecheck do
+ url :url
+ strategy :github_latest
+ end
+
+ auto_updates true
+ depends_on macos: ">= :ventura"
+
+ app "Until.app"
+
+ zap trash: [
+ "~/Library/Application Support/Until",
+ "~/Library/Caches/ai.combinatrix.until",
+ "~/Library/Group Containers/3Y275A5TZ8.ai.combinatrix.until",
+ "~/Library/HTTPStorages/ai.combinatrix.until",
+ "~/Library/Preferences/ai.combinatrix.until.plist",
+ ]
+end
diff --git a/scripts/package-app.sh b/scripts/package-app.sh
index a7e0d49..7aaf8b8 100755
--- a/scripts/package-app.sh
+++ b/scripts/package-app.sh
@@ -19,6 +19,8 @@ CONFIGURATION="${CONFIGURATION:-debug}"
APP_VERSION="${APP_VERSION:-0.1.0}"
BUILD_NUMBER="${BUILD_NUMBER:-1}"
MAS="${MAS:-0}"
+# UNIVERSAL=1 builds one binary for Apple Silicon and Intel (release.sh sets it).
+UNIVERSAL="${UNIVERSAL:-0}"
APP_NAME="Until"
APP_DIR="$ROOT/.build/$CONFIGURATION/$APP_NAME.app"
EXECUTABLE="$ROOT/.build/$CONFIGURATION/Until"
@@ -83,6 +85,9 @@ fi
if [[ "$MAS" == "1" ]]; then
build_args+=(--disable-default-traits)
fi
+if [[ "$UNIVERSAL" == "1" ]]; then
+ build_args+=(--arch arm64 --arch x86_64)
+fi
swift build "${build_args[@]}"
rm -rf "$APP_DIR"
@@ -225,7 +230,6 @@ widget_compile_args=(
# when WidgetKit asks ExtensionFoundation to initialize the widget.
-Xlinker -e -Xlinker _NSExtensionMain
-swift-version 5
- -target "$(uname -m)-apple-macos14.0"
-module-cache-path "$ROOT/.build/widget-module-cache"
-framework SwiftUI
-framework WidgetKit
@@ -233,10 +237,22 @@ widget_compile_args=(
if [[ "$CONFIGURATION" == "release" ]]; then
widget_compile_args+=(-O)
fi
-xcrun swiftc "${widget_compile_args[@]}" \
- "$ROOT/Sources/Until/WidgetAgendaSnapshot.swift" \
- "$ROOT/Sources/UntilWidget/UntilWidget.swift" \
- -o "$WIDGET_DIR/Contents/MacOS/UntilWidget"
+widget_archs=("$(uname -m)")
+if [[ "$UNIVERSAL" == "1" ]]; then
+ widget_archs=(arm64 x86_64)
+fi
+widget_slices=()
+for arch in "${widget_archs[@]}"; do
+ slice="$ROOT/.build/widget-$arch/UntilWidget"
+ mkdir -p "$(dirname "$slice")"
+ xcrun swiftc "${widget_compile_args[@]}" \
+ -target "$arch-apple-macos14.0" \
+ "$ROOT/Sources/Until/WidgetAgendaSnapshot.swift" \
+ "$ROOT/Sources/UntilWidget/UntilWidget.swift" \
+ -o "$slice"
+ widget_slices+=("$slice")
+done
+lipo -create "${widget_slices[@]}" -output "$WIDGET_DIR/Contents/MacOS/UntilWidget"
cp "$ROOT/Sources/UntilWidget/Info.plist" "$WIDGET_DIR/Contents/Info.plist"
plutil -replace CFBundleShortVersionString -string "$APP_VERSION" "$WIDGET_DIR/Contents/Info.plist"
plutil -replace CFBundleVersion -string "$BUILD_NUMBER" "$WIDGET_DIR/Contents/Info.plist"
diff --git a/scripts/release.sh b/scripts/release.sh
index 56d8a20..3f52600 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -16,6 +16,7 @@ set -euo pipefail
# Usage:
# scripts/release.sh # build + sign + notarize + staple
# NOTARIZE=0 scripts/release.sh # build + Developer ID sign only
+# UNIVERSAL=0 scripts/release.sh # this Mac's architecture only (faster)
#
# Env overrides:
# CODESIGN_IDENTITY signing identity, e.g. "Developer ID Application: Example Corp (TEAMID)"
@@ -33,6 +34,7 @@ CODESIGN_IDENTITY="${CODESIGN_IDENTITY:?Set CODESIGN_IDENTITY to your Developer
APP_DIR="$(
CONFIGURATION=release \
DISTRIBUTION=1 \
+ UNIVERSAL="${UNIVERSAL:-1}" \
TEAM_ID="$TEAM_ID" \
CODESIGN_IDENTITY="$CODESIGN_IDENTITY" \
"$ROOT/scripts/package-app.sh" | tail -n 1
From 087e54c51a3ed1efcfe117406f59bf7dd5fe708f Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 11:48:08 +0900
Subject: [PATCH 05/11] Rewrite the README and add contributor docs
Lead with install, the three reasons to use Until, calendar sources,
a comparison with similar apps, and an FAQ; add a Japanese README,
CONTRIBUTING, SECURITY, issue templates, and a changelog for 1.2.0.
---
.github/ISSUE_TEMPLATE/bug_report.yml | 35 +++++
.github/ISSUE_TEMPLATE/config.yml | 5 +
.github/ISSUE_TEMPLATE/feature_request.yml | 15 ++
.github/ISSUE_TEMPLATE/meeting_link.yml | 28 ++++
CHANGELOG.md | 32 +++++
CONTRIBUTING.md | 42 ++++++
README.ja.md | 77 ++++++++++
README.md | 159 ++++++++++++++-------
SECURITY.md | 8 ++
9 files changed, 346 insertions(+), 55 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/ISSUE_TEMPLATE/meeting_link.yml
create mode 100644 CHANGELOG.md
create mode 100644 CONTRIBUTING.md
create mode 100644 README.ja.md
create mode 100644 SECURITY.md
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..f6f03f7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,35 @@
+name: Bug report
+description: Something in Until doesn't work as expected.
+labels: [bug]
+body:
+ - type: textarea
+ id: what
+ attributes:
+ label: What happened?
+ description: What you did, what you expected, and what you saw. Screenshots help.
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Until version
+ description: Settings → the version under the sidebar.
+ placeholder: "1.2.0"
+ validations:
+ required: true
+ - type: input
+ id: macos
+ attributes:
+ label: macOS version
+ placeholder: "15.5"
+ - type: dropdown
+ id: source
+ attributes:
+ label: Calendar source
+ options:
+ - Calendars on this Mac
+ - Google account
+ - Both
+ - type: markdown
+ attributes:
+ value: Please remove private details (event titles, attendees, links) from anything you paste.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..ba953ed
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Questions and ideas
+ url: https://github.com/combinatrix-ai/until/discussions
+ about: Ask a question or share how you use Until.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..ea87b1e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,15 @@
+name: Feature request
+description: Suggest an idea for Until.
+labels: [enhancement]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: What are you trying to do?
+ description: The situation and what gets in the way today.
+ validations:
+ required: true
+ - type: textarea
+ id: idea
+ attributes:
+ label: What would help?
diff --git a/.github/ISSUE_TEMPLATE/meeting_link.yml b/.github/ISSUE_TEMPLATE/meeting_link.yml
new file mode 100644
index 0000000..74cb531
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/meeting_link.yml
@@ -0,0 +1,28 @@
+name: Meeting link not detected
+description: Until doesn't show a Join button for a meeting service.
+labels: [meeting-service, good first issue]
+body:
+ - type: input
+ id: service
+ attributes:
+ label: Service
+ placeholder: "e.g. Pumble, Butter, a self-hosted Jitsi"
+ validations:
+ required: true
+ - type: input
+ id: example
+ attributes:
+ label: Example link format
+ description: Replace IDs and passwords with placeholders.
+ placeholder: "https://meet.example.com/j/XXXX"
+ validations:
+ required: true
+ - type: dropdown
+ id: field
+ attributes:
+ label: Where the link is in the event
+ options:
+ - Location
+ - Description / notes
+ - URL field
+ - Not sure
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5875e8e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,32 @@
+# Changelog
+
+Release notes for each version are on
+[GitHub Releases](https://github.com/combinatrix-ai/until/releases). This file
+collects what is ready for the next one.
+
+## 1.2.0 (unreleased)
+
+### New
+- **Calendars on this Mac.** Until can show everything the Calendar app
+ already syncs — iCloud, Google, Outlook/Exchange, CalDAV — without signing
+ in. Mac calendars and Google accounts work side by side, and events that
+ appear through both are merged.
+- **Try it with a sample day.** The first-run popover can show a sample day
+ before you connect a calendar.
+- **Start alert.** Optionally put the meeting on screen as it starts, as a
+ floating card or a full-screen takeover on every display, with Join, Snooze
+ 1 min, and Dismiss.
+- **Join shortcut.** A global shortcut joins the meeting in the menubar, or
+ the one that is running or about to start.
+- **Desktop widgets** with today's agenda, in medium and large sizes.
+- **Today at a glance.** The popover footer shows how many events are left,
+ how much of the day is booked, and the longest free stretch.
+- **Links from the invite.** The upcoming card shows up to three docs,
+ designs, or pull requests linked from the event.
+
+### Changed
+- Meeting links from 30+ services are recognized, including Slack huddles,
+ Discord, Jitsi, FaceTime, Amazon Chime, Gather, Tuple, Lark, and Tencent
+ Meeting. Zoom and Teams links can open in their desktop apps.
+- The menubar can show only the countdown, hiding the event title.
+- Until runs natively on Intel Macs as well as Apple Silicon.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..e468314
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,42 @@
+# Contributing to Until
+
+Thanks for helping. Focused fixes, new meeting services, translations, and
+documentation are all welcome.
+
+## Build and run
+
+Until is a plain SwiftPM package (Swift + SwiftUI/AppKit), no Xcode project.
+
+```sh
+swift build # compile
+swift test # run the tests
+scripts/dev.sh # rebuild and relaunch as a real .app bundle
+scripts/dev.sh --demo-mode # the same, with a synthetic calendar
+```
+
+Mac calendars work without any setup. Google sign-in needs your own OAuth
+desktop client:
+
+1. In Google Cloud Console, create a project, enable the **Google Calendar
+ API** (and the Drive and Docs APIs for meeting notes), and create an OAuth
+ client ID of type **Desktop app**.
+2. `cp .env.example .env` and fill in `GOOGLE_OAUTH_CLIENT_ID` and
+ `GOOGLE_OAUTH_CLIENT_SECRET`. The packaging script bakes them into the app's
+ `Info.plist`; `.env` is gitignored.
+
+[AGENTS.md](AGENTS.md) has the full development notes: layout, demo mode for
+screenshots, and the release and Mac App Store builds.
+
+## Good first contributions
+
+- **A meeting service Until doesn't recognize.** Add its domains to
+ `EventLinks.MeetingProvider` in `Sources/Until/EventLinks.swift`, plus a test
+ in `Tests/UntilTests/MeetingWorkflowTests.swift`.
+- **A translation.** Strings live in `Sources/Until/Resources/.lproj/`;
+ English keys double as the source text.
+
+## Pull requests
+
+Work on a branch and open a pull request against `main`. Please run
+`swift test` and `swiftlint` before you push, and keep each pull request to one
+change.
diff --git a/README.ja.md b/README.ja.md
new file mode 100644
index 0000000..5d89991
--- /dev/null
+++ b/README.ja.md
@@ -0,0 +1,77 @@
+
+
+
+
+Until
+
+
+ 次の予定を、いつもメニューバーに。
+ macOS のメニューバーに次の会議までのカウントダウン。クリックすれば一日がひとつのタイムラインに。議事録はワンクリックで。
+
+
+
+ macOS 版をダウンロード
+ · Webサイト
+ · English
+
+
+
+
+
+
+## インストール
+
+**ダウンロード:** [`Until.dmg`](https://github.com/combinatrix-ai/until/releases/latest/download/Until.dmg) を開き、`Until.app` をアプリケーションフォルダへドラッグします。Apple の公証済みで、アップデートは自動です。
+
+**Homebrew:**
+
+```sh
+brew install --cask combinatrix-ai/tap/until
+```
+
+無料・オープンソース。macOS 13 以降、Apple シリコンと Intel の両方に対応。
+
+## Until の特徴
+
+- **カウントダウンはいつも目に入る場所に。** 次の予定をメニューバーに表示し、始まったら残り時間に切り替わります。⌥クリックか参加ショートカットで、すぐ会議に入れます。
+- **一日をひとつのタイムラインで。** メニューバーをクリックすると、終わった予定・いまの時刻・この先の予定が一本のレールに並び、会議の間の空き時間も見えます。下部には今日の予定の件数・合計時間・残りの最長の空き時間を表示します。
+- **議事録はワンクリック。** Google アカウントを接続すると、テンプレートから Google ドキュメントを作り、参加者と共有して、予定に添付するまでをワンクリックで行います。次の予定のカードには、招待に含まれる資料・デザイン・PR へのリンクも表示します。
+
+## 使えるカレンダー
+
+- **このMacのカレンダー** — カレンダーAppに入っている予定(iCloud・Google・Outlook/Exchange・CalDAV)をそのまま表示します。サインイン不要で、予定がこのMacの外に送られることはありません。
+- **Google アカウント** — 直接サインインすると(複数アカウント可)、議事録の作成と Google Meet リンクの追加ができます。
+
+どちらか一方でも、両方でも使えます。両方に出てくる同じ予定は1つにまとめます。
+
+## 機能
+
+- **メニューバーのカウントダウン**。開始後は残り時間を表示。発表中は予定名を隠せます。
+- **開始アラート**(任意)。会議の開始時に、浮動カードか、すべてのディスプレイへの全画面表示で知らせます。参加・1分後に再通知・閉じるを選べます。
+- **ネイティブの通知**(スヌーズ付き)。
+- **ワンクリック参加**。Google Meet・Zoom・Microsoft Teams・Webex・Slack ハドル・Discord・Jitsi・FaceTime など30以上のサービスに対応。Zoom と Teams はデスクトップアプリで開けます。
+- **デスクトップウィジェット**で今日の予定を一覧。
+- **細かいフィルター**。カレンダー・タイトル・参加者・自分の返答・長さなどのルールで、表示する予定を決められます。
+- **接続前に試せる**サンプルの一日。
+- グローバルショートカット、ログイン時に起動、アイコンの右クリックで表示を折りたたみ。
+- **日本語と英語**に対応。
+
+## よくある質問
+
+**無料ですか?** はい。MIT ライセンスで、アカウント登録もサブスクリプションもありません。
+
+**予定のデータはどこかに送られますか?** いいえ。Until のサーバーはありません。Mac のカレンダーは Mac の中で読み取り、Google のデータは Google の API から直接 Mac に届きます。サインイン情報は macOS のキーチェーンに保存します。詳しくは[プライバシーポリシー](https://until.combinatrix.ai/privacy.html)をご覧ください。
+
+**Mac のカレンダーで使えるなら、Google にサインインする理由は?** 議事録ドキュメントの作成と Meet リンクの追加のためだけです。それ以外は Mac のカレンダーだけで使えます。
+
+**会社の Google Workspace でサードパーティ製アプリが許可されていません。** 「このMacのカレンダー」を使ってください。仕事用カレンダーがカレンダーAppに入っていれば、Google へのサインインなしで表示できます。
+
+**Outlook は使えますか?** Exchange や Microsoft 365 のアカウントをカレンダーApp(システム設定 → インターネットアカウント)に追加し、「このMacのカレンダー」をオンにしてください。
+
+## ソースからのビルド
+
+[CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
+
+## ライセンス
+
+[MIT](LICENSE)
diff --git a/README.md b/README.md
index 53c1330..39cfc49 100644
--- a/README.md
+++ b/README.md
@@ -2,78 +2,127 @@
-# Until
+Until
-**Next up, always visible.** Until keeps your next Google Calendar event in the
-macOS menubar, with join links, meeting notes, reminders, and precise filters
-close at hand.
+
+ Next up, always visible.
+ Your next meeting in the macOS menu bar — a live countdown, your whole day on one timeline, and meeting notes in one click.
+
+
+
+
+
+
+
+
-Website: ·
-Privacy policy:
+
+ Download for macOS
+ · Website
+ · 日本語
+
-## Features
+## Install
-- **Menubar countdown** — the next event stays in the menubar all day with a
- live countdown, then shows the time remaining once the meeting starts.
-- **Your day on one timeline** — the popover opens on the present moment: the
- current or next event sits inline as a card, the rest of the day runs above
- and below it on a single rail, and free gaps are called out between them.
-- **Today at a glance** — native macOS widgets show today's agenda, highlighting
- the current or next event alongside all-day and upcoming events.
-- **Never lose the current meeting** — when the next event closes in, the
- countdown switches ahead while the meeting you're still in stays marked NOW
- on the timeline.
-- **One-click join** — click an event's meeting link, or option-click the
- menubar item to join the meeting it's showing (Meet, Zoom, Teams,
- Whereby, and friends).
-- **Native reminders** — sleep-safe notifications before events, with snooze.
-- **Meeting notes** — one click creates a Google Doc from your template,
- shares it with every attendee, and attaches it to the event; docs are
- organized in an app-managed Drive folder.
-- **Precise filters** — a structured rule builder decides which events count
- (by calendar, title, attendees, response status, and more).
-- **Quick access** — global hotkey, launch at login, right-click the menubar
- icon to hide the event text, event context menus.
-- **Localized** — English and Japanese.
-- **Private by design** — OAuth tokens live in the macOS Keychain, the Drive
- scope is the non-sensitive `drive.file` tier (the app only sees files it
- created), and there are no servers: your data goes straight from Google's
- APIs to your Mac.
+**Download:** grab [`Until.dmg`](https://github.com/combinatrix-ai/until/releases/latest/download/Until.dmg),
+open it, and drag `Until.app` into Applications. It is signed and notarized by
+Apple and updates itself.
-## Install
+**Homebrew:**
-Download the latest notarized build from
-[Releases](https://github.com/combinatrix-ai/until/releases/latest), open the
-`.dmg`, and drag `Until.app` into `/Applications`.
+```sh
+brew install --cask combinatrix-ai/tap/until
+```
-Requires macOS 13 or later.
+Free and open source. macOS 13 or later, Apple Silicon and Intel.
-## Build from source
+## Why Until
-Until is a plain SwiftPM package (Swift + SwiftUI/AppKit).
+- **The countdown, where you already look.** The next event sits in the menu
+ bar with a live countdown and flips to time left once it starts. ⌥-click it,
+ or press the join shortcut from anywhere, to jump into the call.
+- **Your day on one timeline.** Click the menu bar and the whole day unfolds on
+ a single rail: finished events above, the current moment in the middle, and
+ free time called out between meetings. The footer sums the day up — how many
+ events, how much is booked, and the longest free stretch left.
+- **Meeting notes in one click.** With a Google account connected, one click
+ creates a Google Doc from your template, shares it with the attendees, and
+ attaches it to the event. The upcoming card also surfaces the docs, designs,
+ and pull requests linked from the invite.
-```sh
-swift build # compile
-swift run Until # run the bare executable
-scripts/dev.sh # rebuild + relaunch as a real .app bundle (recommended)
-scripts/package-app.sh # produce .build/debug/Until.app
-```
+## Works with your calendars
-Google sign-in needs your own OAuth desktop client (one-time setup):
+- **Calendars on this Mac** — everything the Calendar app already syncs:
+ iCloud, Google, Outlook/Exchange, CalDAV. No sign-in; events are read on your
+ Mac and never leave it.
+- **Google accounts** — sign in directly (several accounts at once) to add
+ meeting notes docs and Google Meet links to events.
-1. In Google Cloud Console, create a project, enable the **Google Calendar
- API** (and the Drive/Docs APIs if you use meeting notes), and create an
- OAuth client ID of type **Desktop app**.
-2. `cp .env.example .env` and fill in `GOOGLE_OAUTH_CLIENT_ID` /
- `GOOGLE_OAUTH_CLIENT_SECRET`. The packaging script bakes them into the
- app's `Info.plist`; `.env` is gitignored.
+Use either, or both: events that show up through both are merged.
+
+## Features
+
+- **Menu bar countdown** that switches to time left once a meeting starts, and
+ can hide the title while you present.
+- **Start alert** (optional) — a floating card or a full-screen takeover on
+ every display as a meeting starts, with Join, Snooze, and Dismiss.
+- **Native reminders** before events, with snooze.
+- **One-click join** for 30+ services — Google Meet, Zoom, Microsoft Teams,
+ Webex, Slack huddles, Discord, Jitsi, FaceTime, Whereby, Around, and more.
+ Zoom and Teams can open in their desktop apps.
+- **Desktop widgets** with today's agenda.
+- **Precise filters** — a rule builder decides which events count (calendar,
+ title, attendees, your response, duration, and more).
+- **Try before connecting** — a sample day shows the timeline before you pick a
+ calendar.
+- **Quick access** — global shortcuts, launch at login, right-click the icon to
+ collapse it.
+- **English and Japanese.**
+
+## How it compares
+
+| | Until | MeetingBar | Dato | Dot |
+|---|---|---|---|---|
+| Price | Free, open source | Free, open source | $18 | $14.99 |
+| Calendars | macOS Calendar + Google sign-in | macOS Calendar + Google | macOS Calendar | macOS Calendar |
+| Menu bar countdown | ✓ | ✓ | ✓ | ✓ |
+| One-click join | ✓ | ✓ | ✓ | ✓ |
+| Full-screen alert | ✓ | ✓ | ✓ | ✓ |
+| Day view | One timeline with free time | Event list | Month + event list | Month + event list |
+| Meeting notes doc (create, share, attach) | ✓ | — | — | — |
+| Rule-based filters | ✓ | Toggles | — | — |
+
+Based on each app's own website as of September 2026. They are all good apps;
+Until is for people who want the shape of their day and their meeting notes
+one click away.
+
+## FAQ
+
+**Is it free?** Yes. Until is MIT-licensed, with no account and no subscription.
+
+**Does my calendar data go anywhere?** No. There is no Until server. Mac
+calendars are read locally, Google data goes straight from Google's API to your
+Mac, and sign-in tokens live in the macOS Keychain. See the
+[privacy policy](https://until.combinatrix.ai/privacy.html).
+
+**Why sign in with Google if the Mac calendars work?** Only for the Google
+extras: creating meeting notes docs and adding Meet links. Everything else
+works with the Mac calendars alone.
+
+**My company's Google Workspace blocks third-party apps.** Use Calendars on
+this Mac — if your work calendar is in the Calendar app, Until can show it
+without any Google sign-in.
+
+**Outlook?** Add your Exchange or Microsoft 365 account to the Calendar app
+(System Settings → Internet Accounts), then turn on Calendars on this Mac.
+
+## Build from source
-See [AGENTS.md](AGENTS.md) for the full development notes, including the
-distributable (Developer ID + notarization) build via `scripts/release.sh`.
+See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..2c4cfaf
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,8 @@
+# Security
+
+Please report security issues privately to **until@combinatrix.ai** rather than
+opening a public issue. We will reply as soon as we can.
+
+Until has no server: calendar data is read on your Mac or fetched directly from
+Google, and sign-in tokens are stored in the macOS Keychain. Reports about
+token handling, the OAuth flow, or data leaving the Mac are especially welcome.
From eac8732993e7062c04b7c3ae6e03a365439bf7aa Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 11:52:37 +0900
Subject: [PATCH 06/11] Draw start alert buttons with their own style
The floating alert never becomes the key window, where bordered system
buttons lose their accent color; give Join a fixed tinted fill.
---
Sources/Until/StartAlert.swift | 32 +++++++++++++++++++++++++-------
1 file changed, 25 insertions(+), 7 deletions(-)
diff --git a/Sources/Until/StartAlert.swift b/Sources/Until/StartAlert.swift
index 4de6b92..dd77b78 100644
--- a/Sources/Until/StartAlert.swift
+++ b/Sources/Until/StartAlert.swift
@@ -287,25 +287,23 @@ struct StartAlertView: View {
@ViewBuilder
private func buttons(tint: Color) -> some View {
+ let large = style == .fullScreen
HStack(spacing: Theme.Spacing.sm) {
if !event.conferenceUrl.isEmpty {
Button(action: actions.join) {
Label(loc("Join"), systemImage: "video.fill")
- .frame(minWidth: style == .fullScreen ? 90 : nil)
}
- .buttonStyle(.borderedProminent)
- .tint(tint)
- .controlSize(style == .fullScreen ? .large : .regular)
+ .buttonStyle(StartAlertButtonStyle(fill: tint, large: large))
.keyboardShortcut(.defaultAction)
} else {
Button(loc("Open Event"), action: actions.open)
- .controlSize(style == .fullScreen ? .large : .regular)
+ .buttonStyle(StartAlertButtonStyle(fill: tint, large: large))
.keyboardShortcut(.defaultAction)
}
Button(loc("Snooze 1 min"), action: actions.snooze)
- .controlSize(style == .fullScreen ? .large : .regular)
+ .buttonStyle(StartAlertButtonStyle(fill: nil, large: large))
Button(loc("Dismiss"), action: actions.dismiss)
- .controlSize(style == .fullScreen ? .large : .regular)
+ .buttonStyle(StartAlertButtonStyle(fill: nil, large: large))
.keyboardShortcut(.cancelAction)
}
}
@@ -321,3 +319,23 @@ struct StartAlertView: View {
heroMetadataParts(for: event).joined(separator: " · ")
}
}
+
+/// The floating alert never becomes the key window, where system bordered
+/// buttons lose their accent color, so the alert draws its own.
+private struct StartAlertButtonStyle: ButtonStyle {
+ var fill: Color?
+ var large: Bool
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(large ? .body.weight(.semibold) : .callout.weight(.semibold))
+ .foregroundStyle(fill == nil ? Color.primary : Color.white)
+ .padding(.horizontal, large ? 18 : 12)
+ .padding(.vertical, large ? 8 : 5)
+ .background(
+ (fill ?? Color.primary.opacity(0.08)).opacity(configuration.isPressed ? 0.75 : 1),
+ in: RoundedRectangle(cornerRadius: large ? 9 : 7, style: .continuous)
+ )
+ .contentShape(Rectangle())
+ }
+}
From cc0fdd6d231d4d666c4719ff367f1e1956def978 Mon Sep 17 00:00:00 2001
From: hmirin
Date: Wed, 30 Sep 2026 14:10:12 +0900
Subject: [PATCH 07/11] Rebuild the website around install, calendars, and
comparisons
- Hero links straight to Until.dmg, offers the Homebrew command, and
states the supported Macs; a trust strip follows.
- New sections for calendar sources (Mac calendars / Google), start
alerts, desktop widgets, the smaller details, a comparison with
MeetingBar, Dato, and Dot, and an FAQ.
- A Japanese page at /ja/ with hreflang links, JSON-LD, a sitemap,
robots.txt, and a dedicated 1200x630 Open Graph image.
- Widget and start-alert images are rendered from the real views.
- The privacy policy covers Calendar app access and the widget snapshot.
---
docs/index.html | 186 ++++++++++----
docs/ja/index.html | 452 +++++++++++++++++++++++++++++++++
docs/og-image.png | Bin 0 -> 224181 bytes
docs/privacy.html | 35 ++-
docs/robots.txt | 4 +
docs/script.js | 18 ++
docs/site-preview-desktop.png | Bin 191626 -> 0 bytes
docs/sitemap.xml | 16 ++
docs/styles.css | 341 +++++++++++++++++++++++++
docs/until-start-alert-ja.webp | Bin 0 -> 18102 bytes
docs/until-start-alert.webp | Bin 0 -> 16968 bytes
docs/until-widget-ja.webp | Bin 0 -> 15408 bytes
docs/until-widget.webp | Bin 0 -> 17342 bytes
13 files changed, 990 insertions(+), 62 deletions(-)
create mode 100644 docs/ja/index.html
create mode 100644 docs/og-image.png
create mode 100644 docs/robots.txt
delete mode 100644 docs/site-preview-desktop.png
create mode 100644 docs/sitemap.xml
create mode 100644 docs/until-start-alert-ja.webp
create mode 100644 docs/until-start-alert.webp
create mode 100644 docs/until-widget-ja.webp
create mode 100644 docs/until-widget.webp
diff --git a/docs/index.html b/docs/index.html
index bcaecc3..546f580 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -3,35 +3,32 @@
- Until — Google Calendar in your macOS menubar
-
+ Until — your next meeting in the macOS menu bar
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
-
+
@@ -47,9 +44,9 @@
Until
Next up, always visible.
- Google Calendar in your macOS menubar — countdown above, your day below.
+ Your next meeting in the macOS menu bar — a live countdown, your whole day on one timeline, and meeting notes in one click.
- Free & open source · English / 日本語
+ brew install --cask combinatrix-ai/tap/untilCopy
+ Free & open source · macOS 13+ · Apple Silicon & Intel · English / 日本語
@@ -73,6 +71,10 @@ Next up, always visible.
+
+ No servers — your data stays between your Mac and your calendar Signed and notarized by Apple Open source (MIT) Sign-in tokens in your Keychain
+
+
- ⌥-click the icon to join · right-click to collapse · global shortcut · launch at login
-
-
-
-
-
-
-
-
-
-
+
⌥-click the icon or press the join shortcut to jump into the call · hide the title while you present · launch at login
@@ -145,6 +127,7 @@ Plan your day at a glance.
alt="Until's full-day timeline in a macOS popover, showing the current event, free gaps, and upcoming calendar events."
>
+ The footer sums the day up: how many events are left, how much is booked, and the longest free stretch.
@@ -153,14 +136,10 @@ Plan your day at a glance.
-
- Creates a Google Doc from your template
- Shares it with every attendee
- Attaches it to the calendar event
-
+ Creates a Google Doc from your template Shares it with every attendee Attaches it to the calendar event
@@ -244,6 +223,65 @@ Notes, ready before you are.
+ The upcoming card also surfaces the docs, designs, and pull requests linked from the invite.
+
+
+
+
+
+
+
+
+
+ No sign-in
+ Calendars on this Mac
+ Everything the Calendar app already syncs, read right on your Mac.
+ iCloud, Google, Outlook / Exchange, CalDAV Works when your company blocks third-party apps Events never leave your Mac
+
+
+ Adds notes & Meet
+ Google accounts
+ Sign in directly for the Google extras.
+ Meeting notes docs in one click Add a Google Meet link to any event Several accounts side by side
+
+
+
+
+
+
+
+
+ Alerts
+ Hard to miss, easy to join.
+ A native reminder before every meeting — and, if you want it, an alert that stays on screen as the meeting starts: floating, or full screen on every display.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -332,20 +370,56 @@ Only the events that matter.
+
+
+
+ Details
+ The little things, done.
+
+
30+ meeting services Google Meet, Zoom, Teams, Webex, Slack huddles, Discord, Jitsi, FaceTime, Whereby, and more. Zoom and Teams can open in their apps.
Global shortcuts Open the timeline, or join the current meeting, from anywhere.
Try before connecting A sample day shows the timeline before you pick a calendar.
English and Japanese Every screen, both languages.
+
+
+
+
+
+
+ Compare
+ How Until compares.
+
+
+
+ Until MeetingBar Dato Dot
+ Price Free, open source Free, open source $18 $14.99 Calendars Calendar app + Google sign-in Calendar app + Google Calendar app Calendar app Menu bar countdown ✓ ✓ ✓ ✓ One-click join ✓ ✓ ✓ ✓ Full-screen alert ✓ ✓ ✓ ✓ Day view One timeline with free time Event list Month + event list Month + event list Meeting notes doc (create, share, attach) ✓ — — — Rule-based filters ✓ Toggles — —
+
+
+
From each app's own website, September 2026. They are all good apps — Until is for people who want the shape of their day and their meeting notes one click away.
+
+
+
+
+
+
+ FAQ
+ Questions, answered.
+
+
Is Until free? Yes. It is MIT-licensed open source, with no account and no subscription.
Does my calendar data go anywhere? No. There is no Until server. Mac calendars are read on your Mac, Google data goes straight from Google to your Mac, and sign-in tokens stay in the macOS Keychain. Privacy policy
Do I need a Google account? No. Calendars on this Mac covers iCloud, Google, Outlook/Exchange, and CalDAV without signing in. Sign in with Google only for meeting notes docs and Meet links.
Does it work with Outlook or Microsoft 365? Yes — add the account to the Calendar app (System Settings → Internet Accounts) and turn on Calendars on this Mac.
Which Macs? macOS 13 Ventura or later, on Apple Silicon and Intel. Updates install automatically.
+
+
+
-
Until runs entirely on your Mac, talks straight to Google, has no account with us and no servers in between; tokens stay in the macOS Keychain, and Drive access is limited to files it created (drive.file scope). Full privacy policy →
+
Until runs entirely on your Mac and has no servers and no account with us. Calendar app events are read locally; Google data goes straight from Google to your Mac, tokens stay in the macOS Keychain, and Drive access is limited to files Until created (drive.file scope). Full privacy policy →
-
Look up. You're ready.
+
Look up. You’re ready.
+
brew install --cask combinatrix-ai/tap/untilCopy
@@ -364,9 +439,8 @@ Look up. You're ready.
Until
diff --git a/docs/ja/index.html b/docs/ja/index.html
new file mode 100644
index 0000000..e07e1e8
--- /dev/null
+++ b/docs/ja/index.html
@@ -0,0 +1,452 @@
+
+
+
+
+
+ Until — 次の会議をmacOSのメニューバーに
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 本文へスキップ
+
+
+
+
+
次の予定を、いつも目の前に。
+
macOS のメニューバーに次の会議までのカウントダウン。クリックすれば一日がひとつのタイムラインに。議事録はワンクリックで。
+
+
brew install --cask combinatrix-ai/tap/untilコピー
+
無料・オープンソース · macOS 13 以降 · Apple シリコン / Intel · 日本語 / English
+
+
+
+
+
+
+
+
+
+ サーバーなし。予定は Mac とカレンダーの間だけ Apple の署名・公証済み オープンソース(MIT) サインイン情報はキーチェーンに保存
+
+
+
+
+
+
+
+
+
⌥クリックか参加ショートカットで会議へ · 発表中は予定名を隠せる · ログイン時に起動
+
+
+
+
+
+
+
+
+
+
+
下部には今日の予定の件数・予定の合計時間・残りの最長の空き時間を表示します。
+
+
+
+
+
+
+
+
テンプレートから Google ドキュメントを作成 参加者全員と共有 予定に添付
+
+
+
+
+
+ デザインレビュー
+ 10:00 – 10:45 · Google Meet
+
+
+
+
+ 議事録を作成
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ M
+ Maya Chen
+
+
+
+
+
+
+
+ A
+ Alex Kim
+
+
+
+
+
+
+
+ S
+ Sam Rivera
+
+
+
+
+
+
+
+
+
+
+ デザインレビューに添付済み
+
+
+
+
次の予定のカードには、招待に含まれる資料・デザイン・PR へのリンクも表示します。
+
+
+
+
+
+
+
+
+
+ サインイン不要
+ このMacのカレンダー
+ カレンダーAppに入っている予定を、Mac の中で読み取ります。
+ iCloud・Google・Outlook / Exchange・CalDAV 会社でサードパーティ製アプリが禁止されていても使える 予定が Mac の外に出ない
+
+
+ 議事録と Meet
+ Google アカウント
+ 直接サインインすると、Google ならではの機能が使えます。
+ 議事録ドキュメントをワンクリックで作成 予定に Google Meet リンクを追加 複数アカウントを並べて表示
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
フィルター編集画面のルール例: 自分の回答が「辞退」でない、カレンダーが選択した2つのいずれでもない、終日でない。
+
+
+
+
+ 表示する予定
+
+
+
+
+ すべてに一致
+
+
+
+
+
+
+
+
+
+
+ 自分の回答
+
+
+ でない
+
+
+ 辞退
+
+
+
+
+
+
+
+
+
+ カレンダー
+
+
+ いずれでもない
+
+
+ 2件選択
+
+
+
+
+
+
+
+
+
+ 終日
+
+
+ でない
+
+
+
+
+
+
+
+
+
+
+ ルールを追加
+
+
+ グループを追加
+
+
+
+
+
+
+
+
+
+
30以上の会議サービス Google Meet・Zoom・Teams・Webex・Slack ハドル・Discord・Jitsi・FaceTime・Whereby など。Zoom と Teams はアプリで開けます。
グローバルショートカット どこからでもタイムラインを開いたり、会議に参加したり。
接続前に試せる カレンダーを選ぶ前に、サンプルの一日でタイムラインを確認できます。
+
+
+
+
+
+
+ Compare
+ ほかのアプリとの違い。
+
+
+
+ Until MeetingBar Dato Dot
+ 価格 無料・オープンソース 無料・オープンソース $18 $14.99 カレンダー カレンダーApp + Google サインイン カレンダーApp + Google カレンダーApp カレンダーApp メニューバーのカウントダウン ✓ ✓ ✓ ✓ ワンクリック参加 ✓ ✓ ✓ ✓ 全画面アラート ✓ ✓ ✓ ✓ 一日の表示 空き時間つきの一本のタイムライン 予定の一覧 月表示+一覧 月表示+一覧 議事録ドキュメント(作成・共有・添付) ✓ — — — ルールによるフィルター ✓ トグル — —
+
+
+
2026年9月時点、各アプリの公式サイトの記載によります。どれも良いアプリです。Until は、一日の形と議事録をワンクリックの距離に置きたい人のためのアプリです。
+
+
+
+
+
+
+
無料ですか? はい。MIT ライセンスのオープンソースで、アカウント登録もサブスクリプションもありません。
予定のデータはどこかに送られますか? いいえ。Until のサーバーはありません。Mac のカレンダーは Mac の中で読み取り、Google のデータは Google から直接 Mac に届きます。サインイン情報は macOS のキーチェーンに保存します。プライバシーポリシー
Google アカウントは必要ですか? いいえ。「このMacのカレンダー」で iCloud・Google・Outlook/Exchange・CalDAV の予定をサインインなしで表示できます。Google へのサインインは議事録の作成と Meet リンクの追加に使うときだけです。
Outlook や Microsoft 365 でも使えますか? はい。アカウントをカレンダーApp(システム設定 → インターネットアカウント)に追加し、「このMacのカレンダー」をオンにしてください。
対応する Mac は? macOS 13 Ventura 以降の Apple シリコン搭載 Mac と Intel Mac です。アップデートは自動でインストールされます。
+
+
+
+
+
+
+
Until はすべて Mac の中で動き、サーバーもアカウント登録もありません。カレンダーAppの予定は Mac の中で読み取り、Google のデータは Google から直接 Mac に届きます。サインイン情報は macOS のキーチェーンに保存し、Drive へのアクセスは Until が作成したファイルだけ(drive.file スコープ)に限っています。プライバシーポリシー(英語)→
+
+
+
+
+
+
顔を上げて。準備はできています。
+
+
brew install --cask combinatrix-ai/tap/untilコピー
+
+
+
+
+
+
+
+
+
diff --git a/docs/og-image.png b/docs/og-image.png
new file mode 100644
index 0000000000000000000000000000000000000000..29921f63b34ddc6b08c0411f6266f0b918075c3a
GIT binary patch
literal 224181
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