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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:

permissions:
Expand All @@ -26,18 +29,26 @@ jobs:

- name: Build app bundle
env:
GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.GOOGLE_OAUTH_CLIENT_ID }}
GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
GOOGLE_OAUTH_CLIENT_ID: ${{ github.event_name != 'pull_request' && secrets.GOOGLE_OAUTH_CLIENT_ID || '' }}
GOOGLE_OAUTH_CLIENT_SECRET: ${{ github.event_name != 'pull_request' && secrets.GOOGLE_OAUTH_CLIENT_SECRET || '' }}
run: |
: "${GOOGLE_OAUTH_CLIENT_ID:?Missing GOOGLE_OAUTH_CLIENT_ID secret}"
: "${GOOGLE_OAUTH_CLIENT_SECRET:?Missing GOOGLE_OAUTH_CLIENT_SECRET secret}"
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
GOOGLE_OAUTH_CLIENT_ID=ci-placeholder
GOOGLE_OAUTH_CLIENT_SECRET=ci-placeholder
else
: "${GOOGLE_OAUTH_CLIENT_ID:?Missing GOOGLE_OAUTH_CLIENT_ID secret}"
: "${GOOGLE_OAUTH_CLIENT_SECRET:?Missing GOOGLE_OAUTH_CLIENT_SECRET secret}"
fi
export GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET
CONFIGURATION=release scripts/package-app.sh

- name: Archive app bundle
if: github.event_name != 'pull_request'
run: |
ditto -c -k --keepParent .build/release/Until.app Until-${GITHUB_SHA}.zip

- name: Upload app artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v7
with:
name: Until-${{ github.sha }}
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ macOS menubar calendar app (Swift + SwiftUI/AppKit). Shows your next Google
Calendar event in the menubar, filtered by user-authored structured rules.
The SwiftPM package, target, and product are **Until** (bundle id `ai.combinatrix.until`).

## Development workflow

Make changes on a topic branch, push that branch, and open a pull request
targeting `main`. Do not push directly or force-push to `main`; merge through
the pull request after its checks pass.

## Install
```
swift build
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Privacy policy: <https://until.combinatrix.ai/privacy.html>
- **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.
Expand Down
23 changes: 23 additions & 0 deletions Sources/Until/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SwiftUI
final class AppDelegate: NSObject, NSApplicationDelegate {
private let model: AppModel
private var statusController: StatusBarController?
private var showAgendaOnLaunch = false

init(options: AppRuntimeOptions = .fromProcess()) {
model = AppModel(options: options)
Expand All @@ -14,6 +15,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
statusController = StatusBarController(model: model)
if showAgendaOnLaunch {
statusController?.showPopover()
showAgendaOnLaunch = false
}
}

func application(_ application: NSApplication, open urls: [URL]) {
guard urls.contains(where: { $0.scheme == "until" && $0.host == "agenda" }) else { return }
if let statusController {
statusController.showPopover()
} else {
showAgendaOnLaunch = true
}
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
Expand Down Expand Up @@ -182,6 +196,15 @@ final class StatusBarController: NSObject, NSPopoverDelegate {
}
}

func showPopover() {
guard let button = item.button else { return }
NSApp.activate(ignoringOtherApps: true)
if !popover.isShown {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}

/// Toggle the popover from a source other than a mouse click (global hotkey),
/// activating the app so the popover receives key focus.
private func hotkeyToggle() {
Expand Down
39 changes: 39 additions & 0 deletions Sources/Until/AppModel.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import AppKit
import Combine
import Foundation
import OSLog
import ServiceManagement
import WidgetKit

@MainActor
final class AppModel: ObservableObject {
Expand Down Expand Up @@ -797,6 +799,7 @@ final class AppModel: ObservableObject {
state.events = timed
state.allDayEvents = allDay
state.next = AppModel.pickMenubarEvent(config: config, timed: timed, allDay: allDay, now: now)
publishWidgetSnapshot()
let activeEvents = timed.filter { $0.endDate > now }
let notificationEvents = config.notifyVideoOnly
? activeEvents.filter { !$0.conferenceUrl.isEmpty }
Expand All @@ -811,6 +814,42 @@ 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 }
let snapshot = WidgetAgendaSnapshot(
authenticated: !accounts.isEmpty,
lastSync: state.lastSync,
coverageEnd: state.calendarCoverageEnd,
events: (state.allDayEvents + state.events).map { event in
WidgetAgendaEvent(
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
allDay: event.allDay,
colorHex: event.calendar.backgroundColor
)
},
imminentNextLeadMinutes: config.menubarPrefersImminentNext ? config.notifyLeadMinutes : nil
)
let previous = WidgetAgendaStore.read(from: url)
guard previous != snapshot else { return }
do {
try WidgetAgendaStore.write(snapshot, to: url)
let dayEnd = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: .now))
let coverageChanged = dayEnd.map { end in
(previous?.coverageEnd.map { $0 >= end } ?? false) != (snapshot.coverageEnd.map { $0 >= end } ?? false)
} ?? false
if previous?.events != snapshot.events || previous?.authenticated != snapshot.authenticated
|| previous?.imminentNextLeadMinutes != snapshot.imminentNextLeadMinutes || coverageChanged {
WidgetCenter.shared.reloadTimelines(ofKind: WidgetAgendaStore.widgetKind)
}
} catch {
Logger(subsystem: "ai.combinatrix.until", category: "widget")
.error("Could not update widget snapshot")
}
}

/// The event shown in the menubar countdown — literally `state.next`
/// (see `reapplyFilter`/`pickMenubarEvent`). The popover uses this same
/// selection as its inline hero input.
Expand Down
147 changes: 147 additions & 0 deletions Sources/Until/WidgetAgendaSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import Foundation

struct WidgetAgendaEvent: Codable, Equatable, Hashable {
var title: String
var startDate: Date
var endDate: Date
var allDay: Bool
var colorHex: String
}

struct WidgetAgendaSnapshot: Codable, Equatable {
var authenticated: Bool
var lastSync: Date?
var coverageEnd: Date?
var events: [WidgetAgendaEvent]
var imminentNextLeadMinutes: Int?

init(
authenticated: Bool,
lastSync: Date?,
coverageEnd: Date?,
events: [WidgetAgendaEvent],
imminentNextLeadMinutes: Int? = nil
) {
self.authenticated = authenticated
self.lastSync = lastSync
self.coverageEnd = coverageEnd
self.events = events
self.imminentNextLeadMinutes = imminentNextLeadMinutes
}

static let signedOut = WidgetAgendaSnapshot(
authenticated: false,
lastSync: nil,
coverageEnd: nil,
events: []
)

func presentation(at now: Date, calendar: Calendar = .current) -> WidgetAgendaPresentation {
let start = calendar.startOfDay(for: now)
guard let end = calendar.date(byAdding: .day, value: 1, to: start) else {
return WidgetAgendaPresentation(
allDay: [], hero: nil, additionalEvents: [], hiddenCount: 0, coversDay: false,
dayEvents: [], hiddenAllDayCount: 0
)
}
let today = events.filter { $0.startDate < end && $0.endDate > start }
let allDay = today.filter(\.allDay).sorted { $0.startDate < $1.startDate }
let upcoming = today.filter { !$0.allDay && $0.endDate > now }
.sorted { $0.startDate < $1.startDate }
let imminent = imminentNextLeadMinutes.flatMap { leadMinutes in
upcoming.firstIndex { event in
let startsIn = event.startDate.timeIntervalSince(now)
return startsIn >= 0 && startsIn <= TimeInterval(max(0, leadMinutes)) * 60
}
}
let visibleAllDay = Array(allDay.prefix(2))
// Remove only the selected occurrence, including when two calendars have
// identical event details. Keep the remaining agenda in time order.
let heroIndex = imminent ?? upcoming.lastIndex(where: { $0.startDate <= now }) ?? upcoming.indices.first
let additionalEvents = Array(upcoming.enumerated()
.filter { $0.offset != heroIndex }
.map(\.element)
.prefix(3))
return WidgetAgendaPresentation(
allDay: visibleAllDay,
// Match the menubar's default choice when events overlap: the most
// recently started active event, then the next event by start time.
hero: heroIndex.map { upcoming[$0] },
additionalEvents: additionalEvents,
hiddenCount: max(0, allDay.count + upcoming.count - visibleAllDay.count
- (heroIndex == nil ? 0 : 1) - additionalEvents.count),
coversDay: coverageEnd.map { $0 >= end } ?? false,
dayEvents: today.filter { !$0.allDay }.sorted { $0.startDate < $1.startDate },
hiddenAllDayCount: allDay.count - visibleAllDay.count
)
}

func transitionDates(after now: Date, calendar: Calendar = .current) -> [Date] {
let endOfToday = calendar.date(
byAdding: .day,
value: 1,
to: calendar.startOfDay(for: now)
)
let horizon = now.addingTimeInterval(24 * 3600)
let imminentBoundaries = imminentNextLeadMinutes.map { leadMinutes in
events.filter { !$0.allDay }.map { event in
event.startDate.addingTimeInterval(-TimeInterval(max(0, leadMinutes)) * 60)
}
} ?? []
return ([endOfToday].compactMap { $0 } + events.flatMap { [$0.startDate, $0.endDate] }
+ imminentBoundaries)
.filter { $0 > now && $0 <= horizon }
.sorted()
.reduce(into: [Date]()) { result, date in
if result.last != date { result.append(date) }
}
}
}

struct WidgetAgendaPresentation {
var allDay: [WidgetAgendaEvent]
var hero: WidgetAgendaEvent?
var additionalEvents: [WidgetAgendaEvent]
var hiddenCount: Int
var coversDay: Bool
var dayEvents: [WidgetAgendaEvent]
var hiddenAllDayCount: Int

var heroDayIndex: Int? {
hero.flatMap { dayEvents.firstIndex(of: $0) }
}

func dayEventRange(limit: Int) -> Range<Int> {
let count = min(max(0, limit), dayEvents.count)
guard count > 0 else { return 0..<0 }
// Show the full day when it fits. On crowded days keep the highlighted
// event visible with one preceding event for context.
let start = heroDayIndex.map { index in
index < count ? 0 : min(max(0, index - min(1, count - 1)), dayEvents.count - count)
} ?? 0
return start..<(start + count)
}
}

enum WidgetAgendaStore {
static let widgetKind = "ai.combinatrix.until.agenda"
static let fileName = "agenda.json"

static func sharedURL(fileManager: FileManager = .default) -> URL? {
guard let groupIdentifier = Bundle.main.object(
forInfoDictionaryKey: "UntilWidgetGroupIdentifier"
) as? String, !groupIdentifier.isEmpty else { return nil }
return fileManager.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)?
.appendingPathComponent(fileName)
}

static func read(from url: URL) -> WidgetAgendaSnapshot? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(WidgetAgendaSnapshot.self, from: data)
}

static func write(_ snapshot: WidgetAgendaSnapshot, to url: URL) throws {
let data = try JSONEncoder().encode(snapshot)
try data.write(to: url, options: .atomic)
}
}
22 changes: 22 additions & 0 deletions Sources/UntilWidget/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleExecutable</key><string>UntilWidget</string>
<key>CFBundleIdentifier</key><string>ai.combinatrix.until.widget</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>UntilWidget</string>
<key>CFBundleDisplayName</key><string>Until</string>
<key>CFBundlePackageType</key><string>XPC!</string>
<key>CFBundleSupportedPlatforms</key><array><string>MacOSX</string></array>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
10 changes: 10 additions & 0 deletions Sources/UntilWidget/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"Today" = "Today";
"NOW" = "NOW";
"NEXT" = "NEXT";
"all-day" = "all-day";
"No events today" = "No events today";
"Open Until to refresh" = "Open Until to refresh";
"Open Until to sign in" = "Open Until to sign in";
"more_count" = "+%d more";
"End" = "End";
"Today's events from Until" = "Today's events from Until";
10 changes: 10 additions & 0 deletions Sources/UntilWidget/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"Today" = "今日";
"NOW" = "進行中";
"NEXT" = "次の予定";
"all-day" = "終日";
"No events today" = "今日は予定なし";
"Open Until to refresh" = "Until を開いて更新";
"Open Until to sign in" = "Until を開いてログイン";
"more_count" = "ほか%d件";
"End" = "終わり";
"Today's events from Until" = "Until の今日の予定";
Loading
Loading