diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24ae8a6..b766017 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,9 @@ on: push: branches: - main + pull_request: + branches: + - main workflow_dispatch: permissions: @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index 325d1ed..16c7e1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 4ab3305..53c1330 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Privacy policy: - **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. diff --git a/Sources/Until/AppDelegate.swift b/Sources/Until/AppDelegate.swift index 0fd837e..27ef7c1 100644 --- a/Sources/Until/AppDelegate.swift +++ b/Sources/Until/AppDelegate.swift @@ -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) @@ -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 { @@ -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() { diff --git a/Sources/Until/AppModel.swift b/Sources/Until/AppModel.swift index 212ffc9..c39a27b 100644 --- a/Sources/Until/AppModel.swift +++ b/Sources/Until/AppModel.swift @@ -1,7 +1,9 @@ import AppKit import Combine import Foundation +import OSLog import ServiceManagement +import WidgetKit @MainActor final class AppModel: ObservableObject { @@ -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 } @@ -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. diff --git a/Sources/Until/WidgetAgendaSnapshot.swift b/Sources/Until/WidgetAgendaSnapshot.swift new file mode 100644 index 0000000..86010b5 --- /dev/null +++ b/Sources/Until/WidgetAgendaSnapshot.swift @@ -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 { + 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) + } +} diff --git a/Sources/UntilWidget/Info.plist b/Sources/UntilWidget/Info.plist new file mode 100644 index 0000000..bc07760 --- /dev/null +++ b/Sources/UntilWidget/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableUntilWidget + CFBundleIdentifierai.combinatrix.until.widget + CFBundleInfoDictionaryVersion6.0 + CFBundleNameUntilWidget + CFBundleDisplayNameUntil + CFBundlePackageTypeXPC! + CFBundleSupportedPlatformsMacOSX + CFBundleShortVersionString0.1.0 + CFBundleVersion1 + LSMinimumSystemVersion14.0 + NSExtension + + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + + diff --git a/Sources/UntilWidget/Resources/en.lproj/Localizable.strings b/Sources/UntilWidget/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..0206a3d --- /dev/null +++ b/Sources/UntilWidget/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Sources/UntilWidget/Resources/ja.lproj/Localizable.strings b/Sources/UntilWidget/Resources/ja.lproj/Localizable.strings new file mode 100644 index 0000000..9ed3479 --- /dev/null +++ b/Sources/UntilWidget/Resources/ja.lproj/Localizable.strings @@ -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 の今日の予定"; diff --git a/Sources/UntilWidget/UntilWidget.swift b/Sources/UntilWidget/UntilWidget.swift new file mode 100644 index 0000000..310832d --- /dev/null +++ b/Sources/UntilWidget/UntilWidget.swift @@ -0,0 +1,347 @@ +import SwiftUI +import WidgetKit + +private struct AgendaEntry: TimelineEntry { + let date: Date + let snapshot: WidgetAgendaSnapshot? +} + +private struct AgendaProvider: TimelineProvider { + func placeholder(in context: Context) -> AgendaEntry { + AgendaEntry(date: .now, snapshot: .preview) + } + + func getSnapshot(in context: Context, completion: @escaping (AgendaEntry) -> Void) { + completion(AgendaEntry(date: .now, snapshot: context.isPreview ? .preview : loadSnapshot())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let snapshot = loadSnapshot() + let dates = [now] + (snapshot?.transitionDates(after: now) ?? []).prefix(32) + let entries = dates.map { AgendaEntry(date: $0, snapshot: snapshot) } + completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(3600)))) + } + + private func loadSnapshot() -> WidgetAgendaSnapshot? { + guard let url = WidgetAgendaStore.sharedURL() else { return nil } + return WidgetAgendaStore.read(from: url) + } +} + +private struct AgendaWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: WidgetAgendaStore.widgetKind, provider: AgendaProvider()) { entry in + AgendaWidgetView(entry: entry) + } + .configurationDisplayName("Until") + .description("Today's events from Until") + .supportedFamilies([.systemMedium, .systemLarge]) + .contentMarginsDisabled() + } +} + +@main struct UntilWidgets: WidgetBundle { + var body: some Widget { AgendaWidget() } +} + +private struct AgendaWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: AgendaEntry + + private var presentation: WidgetAgendaPresentation? { + entry.snapshot?.presentation(at: entry.date) + } + + var body: some View { + GeometryReader { geometry in + if family == .systemLarge { + fullDayAgenda(height: geometry.size.height) + } else { + agenda(additionalEventCount: additionalEventCount(height: geometry.size.height)) + } + } + .padding(10) + .containerBackground(Color(nsColor: .textBackgroundColor), for: .widget) + .widgetURL(URL(string: "until://agenda")) + } + + private func additionalEventCount(height: CGFloat) -> Int { + guard let presentation, let hero = presentation.hero else { return 0 } + let reservedHeight = CGFloat(16 + presentation.allDay.count * 17 + 58 + 16 + + (hero.startDate > entry.date ? 13 : 0)) + return min(presentation.additionalEvents.count, max(0, Int((height - reservedHeight) / 18))) + } + + private func agenda(additionalEventCount: Int) -> some View { + VStack(alignment: .leading, spacing: 0) { + header + + if let snapshot = entry.snapshot, snapshot.authenticated, let presentation { + if let hero = presentation.hero { + ForEach(presentation.allDay.indices, id: \.self) { index in + allDayRow(presentation.allDay[index]) + } + if hero.startDate > entry.date { + nowLine() + } + heroRow(hero) + ForEach(0.. some View { + VStack(alignment: .leading, spacing: 0) { + header + if let snapshot = entry.snapshot, snapshot.authenticated, let presentation { + if !presentation.dayEvents.isEmpty || !presentation.allDay.isEmpty { + let heroIndex = presentation.heroDayIndex + let showsNowLine = presentation.hero.map { $0.startDate > entry.date } ?? false + let headerAndAllDayHeight = CGFloat(32 + presentation.allDay.count * 17) + let heroHeight: CGFloat = heroIndex == nil ? 0 : 58 + let nowLineHeight: CGFloat = showsNowLine ? 13 : 0 + let reserved = headerAndAllDayHeight + heroHeight + nowLineHeight + let regularCount = presentation.dayEvents.count - (heroIndex == nil ? 0 : 1) + let available = max(0, height - reserved) + let rowHeight = min(26, max(18, available / CGFloat(max(1, regularCount)))) + let limit = Int(available / rowHeight) + (heroIndex == nil ? 0 : 1) + let range = presentation.dayEventRange(limit: limit) + ForEach(presentation.allDay.indices, id: \.self) { index in + allDayRow(presentation.allDay[index]) + } + ForEach(range, id: \.self) { index in + let event = presentation.dayEvents[index] + if index == heroIndex { + if showsNowLine { nowLine() } + heroRow(event) + } else { + additionalRow(event, height: rowHeight) + } + } + footer(snapshot: snapshot, hiddenCount: presentation.hiddenAllDayCount + + presentation.dayEvents.count - range.count) + } else { + emptyMessage(presentation.coversDay ? localized("No events today") : localized("Open Until to refresh")) + } + } else { + emptyMessage(entry.snapshot?.authenticated == false + ? localized("Open Until to sign in") : localized("Open Until to refresh")) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func allDayRow(_ event: WidgetAgendaEvent) -> some View { + HStack(spacing: 0) { + Text(localized("all-day")) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 52, alignment: .trailing) + railNode(color: Color(hex: event.colorHex) ?? .accentColor, size: 7) + Text(event.title) + .font(.system(size: 12)) + .lineLimit(1) + } + .frame(height: 17) + } + + private func nowLine() -> some View { + HStack(spacing: 0) { + Text(entry.date, format: .dateTime.hour().minute()) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(.green) + .frame(width: 52, alignment: .trailing) + railNode(color: .green, size: 7) + Capsule() + .fill(.green) + .frame(height: 2) + } + .frame(height: 13) + } + + private func heroRow(_ event: WidgetAgendaEvent) -> some View { + let isNow = event.startDate <= entry.date + let tint: Color = isNow ? .green : .accentColor + return HStack(alignment: .top, spacing: 0) { + Text(event.startDate, format: .dateTime.hour().minute()) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(tint) + .frame(width: 52, alignment: .trailing) + .padding(.top, 9) + railNode(color: tint, size: 10) + .padding(.top, 7) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(localized(isNow ? "NOW" : "NEXT")) + .tracking(0.6) + Spacer(minLength: 3) + Text(isNow ? event.endDate : event.startDate, style: .relative) + .monospacedDigit() + } + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(tint) + Text(event.title) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(event.startDate, format: .dateTime.hour().minute()) + + Text("–") + + Text(event.endDate, format: .dateTime.hour().minute()) + } + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + .frame(height: 58, alignment: .top) + } + + private func additionalRow(_ event: WidgetAgendaEvent, height: CGFloat = 18) -> some View { + let isNow = event.startDate <= entry.date && event.endDate > entry.date + let tint: Color = isNow ? .green : (Color(hex: event.colorHex) ?? .accentColor) + return HStack(spacing: 0) { + Text(event.startDate, format: .dateTime.hour().minute()) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(isNow ? Color.green : Color.secondary) + .frame(width: 52, alignment: .trailing) + railNode(color: tint, size: 6) + Text(event.title) + .font(.system(size: 11)) + .foregroundStyle(isNow ? Color.green : Color.primary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + } + .frame(height: height) + .opacity(event.endDate <= entry.date ? 0.45 : 1) + } + + private func footer(snapshot: WidgetAgendaSnapshot, hiddenCount: Int) -> some View { + Group { + if let lastSync = snapshot.lastSync, entry.date.timeIntervalSince(lastSync) > 6 * 3600 { + Text(localized("Open Until to refresh")) + } else if hiddenCount > 0 { + Text(String(format: localized("more_count"), hiddenCount)) + } else { + Text(localized("End")) + } + } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.leading, 74) + .padding(.top, 2) + .frame(height: 16, alignment: .topLeading) + } + + private func emptyMessage(_ message: String) -> some View { + HStack { + Spacer() + Text(message) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxHeight: .infinity) + } + + private func railNode(color: Color, size: CGFloat) -> some View { + ZStack { + Rectangle() + .fill(Color.primary.opacity(0.1)) + .frame(width: 1) + Circle() + .fill(color) + .frame(width: size, height: size) + } + .frame(width: 22) + .frame(maxHeight: .infinity) + } +} + +private func localized(_ key: String) -> String { + NSLocalizedString(key, bundle: .main, comment: "") +} + +private extension Color { + init?(hex: String) { + let value = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard value.count == 6, let number = UInt32(value, radix: 16) else { return nil } + self.init( + .sRGB, + red: Double((number >> 16) & 0xff) / 255, + green: Double((number >> 8) & 0xff) / 255, + blue: Double(number & 0xff) / 255, + opacity: 1 + ) + } +} + +private extension WidgetAgendaSnapshot { + static let preview = WidgetAgendaSnapshot( + authenticated: true, + lastSync: .now, + coverageEnd: .now.addingTimeInterval(48 * 3600), + events: [ + WidgetAgendaEvent( + title: "Design review", + startDate: .now.addingTimeInterval(12 * 60), + endDate: .now.addingTimeInterval(72 * 60), + allDay: false, + colorHex: "#087afb" + ), + WidgetAgendaEvent( + title: "Team sync", + startDate: .now.addingTimeInterval(90 * 60), + endDate: .now.addingTimeInterval(120 * 60), + allDay: false, + colorHex: "#9368c7" + ), + WidgetAgendaEvent( + title: "Focus time", + startDate: .now.addingTimeInterval(150 * 60), + endDate: .now.addingTimeInterval(210 * 60), + allDay: false, + colorHex: "#6ca8a8" + ), + WidgetAgendaEvent( + title: "Wrap up", + startDate: .now.addingTimeInterval(240 * 60), + endDate: .now.addingTimeInterval(270 * 60), + allDay: false, + colorHex: "#4b87e8" + ) + ] + ) +} diff --git a/Tests/UntilTests/WidgetAgendaSnapshotTests.swift b/Tests/UntilTests/WidgetAgendaSnapshotTests.swift new file mode 100644 index 0000000..d3a1fff --- /dev/null +++ b/Tests/UntilTests/WidgetAgendaSnapshotTests.swift @@ -0,0 +1,254 @@ +import XCTest +@testable import Until + +final class WidgetAgendaSnapshotTests: XCTestCase { + private let calendar = Calendar(identifier: .gregorian) + + func testPresentationIncludesSpanningAllDayAndCurrentTimedEvent() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let now = day.addingTimeInterval(12 * 3600) + let allDay = WidgetAgendaEvent( + title: "Multi-day", + startDate: day.addingTimeInterval(-24 * 3600), + endDate: day.addingTimeInterval(24 * 3600), + allDay: true, + colorHex: "#123456" + ) + let elapsed = WidgetAgendaEvent( + title: "Elapsed", + startDate: day.addingTimeInterval(9 * 3600), + endDate: day.addingTimeInterval(10 * 3600), + allDay: false, + colorHex: "#123456" + ) + let current = WidgetAgendaEvent( + title: "Current", + startDate: day.addingTimeInterval(11 * 3600), + endDate: day.addingTimeInterval(13 * 3600), + allDay: false, + colorHex: "#123456" + ) + let later = WidgetAgendaEvent( + title: "Later", + startDate: day.addingTimeInterval(14 * 3600), + endDate: day.addingTimeInterval(15 * 3600), + allDay: false, + colorHex: "#123456" + ) + let snapshot = WidgetAgendaSnapshot( + authenticated: true, + lastSync: now, + coverageEnd: day.addingTimeInterval(24 * 3600), + events: [later, elapsed, current, allDay] + ) + + let view = snapshot.presentation(at: now, calendar: calendar) + XCTAssertEqual(view.allDay, [allDay]) + XCTAssertEqual(view.hero, current) + XCTAssertEqual(view.additionalEvents, [later]) + XCTAssertEqual(view.dayEvents, [elapsed, current, later]) + XCTAssertEqual(view.hiddenCount, 0) + XCTAssertTrue(view.coversDay) + } + + func testAllDayEndDateIsExclusive() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let event = WidgetAgendaEvent( + title: "Yesterday", + startDate: day.addingTimeInterval(-24 * 3600), + endDate: day, + allDay: true, + colorHex: "#123456" + ) + let snapshot = WidgetAgendaSnapshot( + authenticated: true, + lastSync: day, + coverageEnd: day.addingTimeInterval(24 * 3600), + events: [event] + ) + XCTAssertTrue(snapshot.presentation(at: day, calendar: calendar).allDay.isEmpty) + } + + func testPresentationPrefersMostRecentlyStartedCurrentEvent() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let now = day.addingTimeInterval(12 * 3600) + let earlier = WidgetAgendaEvent( + title: "Earlier", + startDate: day.addingTimeInterval(10 * 3600), + endDate: day.addingTimeInterval(13 * 3600), + allDay: false, + colorHex: "#123456" + ) + let later = WidgetAgendaEvent( + title: "Later", + startDate: day.addingTimeInterval(11 * 3600), + endDate: day.addingTimeInterval(14 * 3600), + allDay: false, + colorHex: "#123456" + ) + let next = WidgetAgendaEvent( + title: "Next", + startDate: day.addingTimeInterval(15 * 3600), + endDate: day.addingTimeInterval(16 * 3600), + allDay: false, + colorHex: "#123456" + ) + let snapshot = WidgetAgendaSnapshot( + authenticated: true, + lastSync: now, + coverageEnd: day.addingTimeInterval(24 * 3600), + events: [next, earlier, later] + ) + + XCTAssertEqual(snapshot.presentation(at: now, calendar: calendar).hero, later) + XCTAssertEqual(snapshot.presentation(at: now, calendar: calendar).additionalEvents, [earlier, next]) + XCTAssertEqual(snapshot.presentation(at: later.endDate, calendar: calendar).hero, next) + } + + func testPresentationUsesMenubarImminentNextPreference() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let now = day.addingTimeInterval(12 * 3600) + let current = WidgetAgendaEvent( + title: "Current", + startDate: day.addingTimeInterval(11 * 3600), + endDate: day.addingTimeInterval(13 * 3600), + allDay: false, + colorHex: "#123456" + ) + let next = WidgetAgendaEvent( + title: "Next", + startDate: now.addingTimeInterval(5 * 60), + endDate: day.addingTimeInterval(14 * 3600), + allDay: false, + colorHex: "#123456" + ) + let snapshot = WidgetAgendaSnapshot( + authenticated: true, + lastSync: now, + coverageEnd: day.addingTimeInterval(24 * 3600), + events: [current, next], + imminentNextLeadMinutes: 10 + ) + + XCTAssertEqual(snapshot.presentation(at: now, calendar: calendar).hero, next) + XCTAssertEqual(snapshot.presentation(at: now, calendar: calendar).additionalEvents, [current]) + XCTAssertEqual(snapshot.presentation(at: now.addingTimeInterval(-6 * 60), calendar: calendar).hero, current) + XCTAssertEqual(snapshot.presentation(at: next.startDate, calendar: calendar).hero, next) + XCTAssertTrue(snapshot.transitionDates(after: now.addingTimeInterval(-6 * 60), calendar: calendar) + .contains(next.startDate.addingTimeInterval(-10 * 60))) + } + + func testTransitionsAreFutureSortedAndUnique() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let now = day.addingTimeInterval(12 * 3600) + let first = WidgetAgendaEvent( + title: "First", + startDate: day.addingTimeInterval(13 * 3600), + endDate: day.addingTimeInterval(14 * 3600), + allDay: false, + colorHex: "#123456" + ) + let second = WidgetAgendaEvent( + title: "Second", + startDate: first.endDate, + endDate: day.addingTimeInterval(25 * 3600), + allDay: false, + colorHex: "#123456" + ) + let snapshot = WidgetAgendaSnapshot( + authenticated: true, + lastSync: now, + coverageEnd: day.addingTimeInterval(48 * 3600), + events: [second, first] + ) + XCTAssertEqual(snapshot.transitionDates(after: now, calendar: calendar), [ + first.startDate, first.endDate, day.addingTimeInterval(24 * 3600), second.endDate + ]) + } + + func testAdditionalEventsAreOrderedLimitedAndExcludedFromHiddenCount() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let events = (10...15).map { hour in + WidgetAgendaEvent( + title: "Event \(hour)", + startDate: day.addingTimeInterval(Double(hour) * 3600), + endDate: day.addingTimeInterval(Double(hour + 1) * 3600), + allDay: false, + colorHex: "#123456" + ) + } + let snapshot = WidgetAgendaSnapshot( + authenticated: true, lastSync: day, coverageEnd: day.addingTimeInterval(24 * 3600), + events: events.reversed() + ) + let view = snapshot.presentation(at: day.addingTimeInterval(9 * 3600), calendar: calendar) + XCTAssertEqual(view.hero, events[0]) + XCTAssertEqual(view.additionalEvents, Array(events[1...3])) + XCTAssertEqual(view.hiddenCount, 2) + + let later = snapshot.presentation(at: events[3].endDate, calendar: calendar) + XCTAssertEqual(later.hero, events[4]) + XCTAssertEqual(later.additionalEvents, [events[5]]) + XCTAssertEqual(later.hiddenCount, 0) + } + + func testIdenticalEventsRemainSeparateOccurrences() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let event = WidgetAgendaEvent( + title: "Same event on two calendars", startDate: day.addingTimeInterval(3600), + endDate: day.addingTimeInterval(7200), allDay: false, colorHex: "#123456" + ) + let snapshot = WidgetAgendaSnapshot( + authenticated: true, lastSync: day, coverageEnd: day.addingTimeInterval(24 * 3600), + events: [event, event] + ) + let view = snapshot.presentation(at: day, calendar: calendar) + XCTAssertEqual(view.hero, event) + XCTAssertEqual(view.additionalEvents, [event]) + XCTAssertEqual(view.hiddenCount, 0) + } + + func testFullDayWindowKeepsHighlightedEventOnCrowdedDays() throws { + let day = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let events = (0..<20).map { hour in + WidgetAgendaEvent( + title: "Event \(hour)", startDate: day.addingTimeInterval(Double(hour) * 3600), + endDate: day.addingTimeInterval(Double(hour + 1) * 3600), allDay: false, colorHex: "#123456" + ) + } + let snapshot = WidgetAgendaSnapshot( + authenticated: true, lastSync: day, coverageEnd: day.addingTimeInterval(86400), events: events + ) + let view = snapshot.presentation(at: day.addingTimeInterval(15.5 * 3600), calendar: calendar) + XCTAssertEqual(view.dayEvents, events) + XCTAssertEqual(view.heroDayIndex, 15) + XCTAssertEqual(view.dayEventRange(limit: 20), 0..<20) + XCTAssertEqual(view.dayEventRange(limit: 6), 14..<20) + XCTAssertEqual(view.dayEventRange(limit: 1), 15..<16) + XCTAssertEqual(view.dayEventRange(limit: 0), 0..<0) + + let finished = snapshot.presentation(at: day.addingTimeInterval(22 * 3600), calendar: calendar) + XCTAssertNil(finished.hero) + XCTAssertEqual(finished.dayEvents, events) + XCTAssertEqual(finished.dayEventRange(limit: 20), 0..<20) + } + + func testSnapshotRoundTripsThroughSharedFile() throws { + let snapshot = WidgetAgendaSnapshot( + authenticated: true, + lastSync: Date(timeIntervalSince1970: 1_000), + coverageEnd: Date(timeIntervalSince1970: 3_000), + events: [WidgetAgendaEvent( + title: "Meeting", + startDate: Date(timeIntervalSince1970: 1_500), + endDate: Date(timeIntervalSince1970: 2_000), + allDay: false, + colorHex: "#abcdef" + )] + ) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: url) } + try WidgetAgendaStore.write(snapshot, to: url) + XCTAssertEqual(WidgetAgendaStore.read(from: url), snapshot) + } +} diff --git a/scripts/entitlements/app.entitlements b/scripts/entitlements/app.entitlements new file mode 100644 index 0000000..bffae23 --- /dev/null +++ b/scripts/entitlements/app.entitlements @@ -0,0 +1,9 @@ + + + + + com.apple.security.application-groups + __WIDGET_GROUP__ + + diff --git a/scripts/entitlements/mas.entitlements b/scripts/entitlements/mas.entitlements index 2563988..2ecf5a8 100644 --- a/scripts/entitlements/mas.entitlements +++ b/scripts/entitlements/mas.entitlements @@ -9,5 +9,7 @@ com.apple.security.network.server + com.apple.security.application-groups + __WIDGET_GROUP__ diff --git a/scripts/entitlements/widget.entitlements b/scripts/entitlements/widget.entitlements new file mode 100644 index 0000000..c719eeb --- /dev/null +++ b/scripts/entitlements/widget.entitlements @@ -0,0 +1,11 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + __WIDGET_GROUP__ + + diff --git a/scripts/package-app.sh b/scripts/package-app.sh index a55d02a..de2b811 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -23,6 +23,59 @@ APP_NAME="Until" APP_DIR="$ROOT/.build/$CONFIGURATION/$APP_NAME.app" EXECUTABLE="$ROOT/.build/$CONFIGURATION/Until" +# The App Group's prefix must match the team that signs both bundles. Resolve +# the identity before writing either Info.plist or the signing entitlements. +DISTRIBUTION="${DISTRIBUTION:-0}" +codesign_identity="${CODESIGN_IDENTITY:-}" +if [[ -z "$codesign_identity" ]]; then + if [[ "$MAS" == "1" ]]; then + identity_patterns=('Apple Distribution' '3rd Party Mac Developer Application' 'Apple Development') + elif [[ "$DISTRIBUTION" == "1" ]]; then + identity_patterns=('Developer ID Application') + else + identity_patterns=('Apple Development') + fi + for identity_pattern in "${identity_patterns[@]}"; do + codesign_identity="$( + security find-identity -v -p codesigning 2>/dev/null \ + | sed -n "s/.*\"\\(${identity_pattern}:[^\"]*\\)\".*/\\1/p" \ + | head -n 1 + )" + [[ -n "$codesign_identity" ]] && break + done +fi + +signing_team="" +if [[ -n "$codesign_identity" ]]; then + # The suffix in an Apple Development certificate's common name identifies + # the developer account. Its OU field is the actual signing team. + certificate_subject="$( + security find-certificate -c "$codesign_identity" -p 2>/dev/null \ + | openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null || true + )" + signing_team="$(sed -n 's/.*OU=\([A-Z0-9]\{10\}\).*/\1/p' <<< "$certificate_subject")" +fi +if [[ -n "${TEAM_ID:-}" && ! "$TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then + echo "Error: TEAM_ID must be a 10-character Apple Developer Team ID." >&2 + exit 1 +fi +if [[ -n "$signing_team" && -n "${TEAM_ID:-}" && "$signing_team" != "$TEAM_ID" ]]; then + echo "Error: TEAM_ID does not match the selected signing identity." >&2 + exit 1 +fi +signing_team="${TEAM_ID:-$signing_team}" +if [[ -n "$codesign_identity" && -z "$signing_team" ]]; then + echo "Error: cannot determine signing team; set TEAM_ID or use a full signing identity." >&2 + exit 1 +fi +WIDGET_GROUP_ID="${signing_team:+$signing_team.ai.combinatrix.until}" +entitlements_dir="$(mktemp -d -t until-entitlements)" +trap 'rm -rf "$entitlements_dir"' EXIT +for kind in app widget mas; do + sed "s|__WIDGET_GROUP__|$WIDGET_GROUP_ID|g" \ + "$ROOT/scripts/entitlements/$kind.entitlements" > "$entitlements_dir/$kind.entitlements" +done + build_args=() if [[ -n "$CONFIGURATION" ]]; then build_args=(--configuration "$CONFIGURATION") @@ -48,6 +101,13 @@ if [[ -d "$RESOURCE_BUNDLE" ]]; then # App Store validation (error 90276) rejects nested bundles without a # CFBundleIdentifier, so fill in the standard identity keys here. RB_PLIST="$APP_DIR/Contents/Resources/Until_Until.bundle/Contents/Info.plist" + if [[ ! -f "$RB_PLIST" ]]; then + RB_PLIST="$APP_DIR/Contents/Resources/Until_Until.bundle/Info.plist" + fi + if [[ ! -f "$RB_PLIST" ]]; then + echo "Error: Info.plist not found in $RESOURCE_BUNDLE." >&2 + exit 1 + fi plutil -replace CFBundleIdentifier -string "ai.combinatrix.until.resources" "$RB_PLIST" plutil -replace CFBundleName -string "Until_Until" "$RB_PLIST" plutil -replace CFBundlePackageType -string "BNDL" "$RB_PLIST" @@ -90,6 +150,13 @@ cat > "$APP_DIR/Contents/Info.plist" <Until CFBundlePackageType APPL + CFBundleURLTypes + + + CFBundleURLNameai.combinatrix.until.agenda + CFBundleURLSchemesuntil + + CFBundleShortVersionString ${APP_VERSION} CFBundleVersion @@ -104,6 +171,8 @@ cat > "$APP_DIR/Contents/Info.plist" < NSUserNotificationAlertStyle alert + UntilWidgetGroupIdentifier + ${WIDGET_GROUP_ID} GoogleOAuthClientID ${GOOGLE_OAUTH_CLIENT_ID} GoogleOAuthClientSecret @@ -121,6 +190,37 @@ if [[ ! -f "$ICON_SRC" ]]; then fi cp "$ICON_SRC" "$APP_DIR/Contents/Resources/Until.icns" +# WidgetKit runs in its own process. Build a small extension executable using +# the same pure snapshot model as the app, then embed it at the standard macOS +# app-extension location. The extension never receives OAuth credentials. +WIDGET_DIR="$APP_DIR/Contents/PlugIns/UntilWidget.appex" +mkdir -p "$WIDGET_DIR/Contents/MacOS" "$WIDGET_DIR/Contents/Resources" +widget_compile_args=( + -parse-as-library + -application-extension + # ExtensionKit must bootstrap through Foundation's extension entry point. + # A plain swiftc executable starts at Swift's _main instead and crashes + # 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 +) +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" +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" +plutil -insert UntilWidgetGroupIdentifier -string "$WIDGET_GROUP_ID" "$WIDGET_DIR/Contents/Info.plist" +cp -R "$ROOT/Sources/UntilWidget/Resources/." "$WIDGET_DIR/Contents/Resources/" + if [[ "$MAS" != "1" ]]; then # Embed Sparkle.framework (auto-update). SwiftPM links against the xcframework # but does not copy it into our hand-built bundle, so do it here. The framework's @@ -153,32 +253,6 @@ xattr -cr "$APP_DIR" # hardened runtime and a secure timestamp — both prerequisites for # notarization. Driven by scripts/release.sh. if [[ "$MAS" == "1" ]]; then - codesign_identity="${CODESIGN_IDENTITY:-}" - if [[ -z "$codesign_identity" ]]; then - for identity_pattern in \ - 'Apple Distribution' \ - '3rd Party Mac Developer Application'; do - codesign_identity="$( - security find-identity -v -p codesigning 2>/dev/null \ - | sed -n "s/.*\"\\(${identity_pattern}:[^\"]*\\)\".*/\\1/p" \ - | head -n 1 - )" - [[ -n "$codesign_identity" ]] && break - done - fi - - if [[ -z "$codesign_identity" ]]; then - identity_pattern='Apple Development' - codesign_identity="$( - security find-identity -v -p codesigning 2>/dev/null \ - | sed -n "s/.*\"\\(${identity_pattern}:[^\"]*\\)\".*/\\1/p" \ - | head -n 1 - )" - if [[ -n "$codesign_identity" ]]; then - echo "Warning: no Apple Distribution or 3rd Party Mac Developer Application identity found; using '$codesign_identity'. This is a local-test signature only; the app is still sandboxed but cannot be submitted to the Mac App Store." >&2 - fi - fi - if [[ -n "$codesign_identity" ]]; then if [[ "$codesign_identity" == "Apple Development"* ]]; then echo "Warning: MAS build is signed with Apple Development; this is a local-test signature only and cannot be submitted to the Mac App Store." >&2 @@ -190,14 +264,14 @@ if [[ "$MAS" == "1" ]]; then # authorizing them, AMFI refuses to launch the app (launchd spawn error 163). # So inject them only when both TEAM_ID and a profile are present; the base # file alone gives a locally testable sandboxed app. - entitlements_file="$ROOT/scripts/entitlements/mas.entitlements" + entitlements_file="$entitlements_dir/mas.entitlements" if [[ -n "${TEAM_ID:-}" && -n "${MAS_PROVISIONING_PROFILE:-}" ]]; then - entitlements_file="$(mktemp -t until-mas-entitlements).plist" + entitlements_file="$entitlements_dir/mas-profile.entitlements" sed "s|| com.apple.application-identifier\\ ${TEAM_ID}.ai.combinatrix.until\\ com.apple.developer.team-identifier\\ ${TEAM_ID}\\ -|" "$ROOT/scripts/entitlements/mas.entitlements" > "$entitlements_file" +|" "$entitlements_dir/mas.entitlements" > "$entitlements_file" fi # Sign the nested resource bundle first (no entitlements — it has no code), @@ -206,6 +280,8 @@ if [[ "$MAS" == "1" ]]; then if [[ -d "$RB_BUNDLE" ]]; then codesign --force --sign "$codesign_identity" --timestamp=none "$RB_BUNDLE" >/dev/null fi + codesign --force --sign "$codesign_identity" --timestamp=none \ + --entitlements "$entitlements_dir/widget.entitlements" "$WIDGET_DIR" >/dev/null codesign_args=( --force @@ -223,22 +299,6 @@ if [[ "$MAS" == "1" ]]; then exit 0 fi -DISTRIBUTION="${DISTRIBUTION:-0}" -if [[ "$DISTRIBUTION" == "1" ]]; then - identity_pattern='Developer ID Application' -else - identity_pattern='Apple Development' -fi - -codesign_identity="${CODESIGN_IDENTITY:-}" -if [[ -z "$codesign_identity" ]]; then - codesign_identity="$( - security find-identity -v -p codesigning 2>/dev/null \ - | sed -n "s/.*\"\\(${identity_pattern}:[^\"]*\\)\".*/\\1/p" \ - | head -n 1 - )" -fi - if [[ -n "$codesign_identity" ]]; then codesign_args=(--force --sign "$codesign_identity") if [[ "$DISTRIBUTION" == "1" ]]; then @@ -252,6 +312,8 @@ if [[ -n "$codesign_identity" ]]; then if [[ -d "$RB_BUNDLE" ]]; then codesign "${codesign_args[@]}" "$RB_BUNDLE" >/dev/null fi + codesign "${codesign_args[@]}" \ + --entitlements "$entitlements_dir/widget.entitlements" "$WIDGET_DIR" >/dev/null # Sparkle ships nested helper code (XPC services, the Autoupdate CLI, and the # Updater UI app) that codesign will NOT reach when sealing the outer app @@ -272,13 +334,14 @@ if [[ -n "$codesign_identity" ]]; then codesign "${codesign_args[@]}" "$SPARKLE_FW" >/dev/null fi - codesign "${codesign_args[@]}" "$APP_DIR" >/dev/null + codesign "${codesign_args[@]}" \ + --entitlements "$entitlements_dir/app.entitlements" "$APP_DIR" >/dev/null echo "Signed with: $codesign_identity" elif [[ "$DISTRIBUTION" == "1" ]]; then - echo "Error: no '$identity_pattern' codesigning identity found; cannot build a distributable app." >&2 + echo "Error: no Developer ID Application signing identity found; cannot build a distributable app." >&2 exit 1 else - echo "Warning: no '$identity_pattern' codesigning identity found; Keychain may ask again after rebuilds." >&2 + echo "Warning: no Apple Development signing identity found; Keychain may ask again after rebuilds." >&2 fi echo "$APP_DIR" diff --git a/scripts/release-mas.sh b/scripts/release-mas.sh index 56749e1..47d84ee 100755 --- a/scripts/release-mas.sh +++ b/scripts/release-mas.sh @@ -44,12 +44,13 @@ echo "Built: $APP_DIR" # Fail early if the app signature is malformed or its sandbox entitlements were # not sealed into the bundle. codesign --verify --strict --verbose=2 "$APP_DIR" -entitlements="$(codesign -d --entitlements :- "$APP_DIR" 2>/dev/null)" +entitlements="$(codesign -d --entitlements - "$APP_DIR" 2>/dev/null)" for key in \ 'com.apple.security.app-sandbox' \ + 'com.apple.security.application-groups' \ 'com.apple.security.network.client' \ 'com.apple.security.network.server'; do - [[ "$entitlements" == *"$key"* ]] || { + [[ "$entitlements" == *"[Key] $key"* ]] || { echo "Error: signed app is missing entitlement '$key'." >&2 exit 1 } diff --git a/scripts/release.sh b/scripts/release.sh index 20f24ee..56d8a20 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -33,6 +33,7 @@ CODESIGN_IDENTITY="${CODESIGN_IDENTITY:?Set CODESIGN_IDENTITY to your Developer APP_DIR="$( CONFIGURATION=release \ DISTRIBUTION=1 \ + TEAM_ID="$TEAM_ID" \ CODESIGN_IDENTITY="$CODESIGN_IDENTITY" \ "$ROOT/scripts/package-app.sh" | tail -n 1 )"