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/.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..a0d3124 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 @@ -115,6 +126,18 @@ working tomorrow. Anything wrong in the file stops the app with a message on stderr instead of falling back, so a typo cannot hand back a subtly different shot. See `scripts/demo-data-example.json` for the schema. +## Website and README art + +The site lives in `docs/` (GitHub Pages deploys it on every push to `main`; +`docs/ja/` is the Japanese page). Its UI illustrations are SVGs in `docs/art/`, +drawn by `scripts/make-site-art.py` from one layout with English and Japanese +copy; the README uses the same files. After changing the UI or its wording: + +``` +python3 scripts/make-site-art.py # docs/art/*-en.svg, *-ja.svg +scripts/render-og.sh # docs/og-image.png, docs/og-image-ja.png +``` + ## Filter language The Swift build evaluates structured rule conditions natively. 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..6cb08da --- /dev/null +++ b/README.ja.md @@ -0,0 +1,77 @@ +

+ Until のロゴ +

+ +

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..665fe40 100644 --- a/README.md +++ b/README.md @@ -2,78 +2,127 @@ Until logo

-# 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. - -Website: · -Privacy policy: +

+ 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. +

- Until's menubar countdown expanded into the day timeline, with the next event's Join and Open notes actions inline + Latest release + macOS 13 or later + Apple Silicon and Intel + MIT license

-## Features +

+ Download for macOS + · Website + · 日本語 +

-- **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. +

+ Until's menubar countdown expanded into the day timeline, with the next event's Join and Open notes actions inline +

## Install -Download the latest notarized build from -[Releases](https://github.com/combinatrix-ai/until/releases/latest), open the -`.dmg`, and drag `Until.app` into `/Applications`. - -Requires macOS 13 or later. - -## Build from source +**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. -Until is a plain SwiftPM package (Swift + SwiftUI/AppKit). +**Homebrew:** ```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 +brew install --cask combinatrix-ai/tap/until ``` -Google sign-in needs your own OAuth desktop client (one-time setup): +Free and open source. macOS 13 or later, Apple Silicon and Intel. + +## Why Until + +- **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. + +## Works with your calendars + +- **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. diff --git a/Sources/Until/AppDelegate.swift b/Sources/Until/AppDelegate.swift index 27ef7c1..6ff4008 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() } @@ -247,12 +263,15 @@ final class StatusBarController: NSObject, NSPopoverDelegate { // Event already underway: show time remaining instead of "now". let remaining = roundedMinutes(from: now, to: next.endDate) when = loc("%@ left", relativeWhen(remaining)) + } else if next.startMinutesFromNow <= 0 { + // Starts within the next half minute: "in now" reads wrong. + when = loc("now") } else { when = loc("in %@", relativeWhen(next.startMinutesFromNow)) } 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 c39a27b..377590c 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,9 @@ final class AppModel: ObservableObject { private let runtimeOptions: AppRuntimeOptions 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] = [] private var refreshTimer: Timer? @@ -70,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 { @@ -86,6 +98,7 @@ final class AppModel: ObservableObject { return } accounts = KeychainStore.loadTokens().map { GoogleAuth(config: config, token: $0) } + observeMacCalendarChanges() updateAuthState() startTimers() Task { @@ -98,6 +111,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 +215,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 +243,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 +254,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 +276,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 +359,7 @@ final class AppModel: ObservableObject { } func logout(email: String? = nil) async { - guard !runtimeOptions.demoMode else { + guard !isDemo else { loadDemoData(now: Date()) return } @@ -279,19 +386,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 +415,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 +476,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 +520,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 +535,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 +570,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 +578,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)) } @@ -545,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 @@ -573,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 { @@ -607,8 +799,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 +856,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 +906,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 { @@ -801,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 else { return } + guard runtimeOptions.allowsNotifications, !isPreviewingSample else { return } + notifier.prefersDesktopApps = config.openMeetingsInApps Task { await notifier.sync( events: notificationEvents, @@ -816,9 +1022,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 +1173,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 +1197,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 +1210,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/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..7250b6b 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,71 @@ 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 event's location for display. A location that is just a meeting + /// link (common in invites from the Calendar app) reads better as the + /// service name, which callers show separately, so it becomes empty. + static func displayLocation(for event: CalendarEvent) -> String { + let location = event.location.trimmingCharacters(in: .whitespacesAndNewlines) + guard !location.contains(" "), meetingProvider(for: location) != nil else { return location } + return "" + } + + /// 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..c417412 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] = [:] @@ -320,7 +323,7 @@ final class EventNotifier: NSObject, UNUserNotificationCenterDelegate { private func notificationBody(for event: CalendarEvent) -> String { let when = loc("Starts at %@", Self.shortTimeFormatter.string(from: event.startDate)) let provider = EventLinks.meetingProvider(for: event)?.label ?? "" - return [when, provider, event.location].filter { !$0.isEmpty }.joined(separator: " · ") + return [when, provider, EventLinks.displayLocation(for: event)].filter { !$0.isEmpty }.joined(separator: " · ") } private func notificationCategoryIdentifier(for event: CalendarEvent) -> String { @@ -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/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..17acb58 100644 --- a/Sources/Until/Resources/en.lproj/Localizable.strings +++ b/Sources/Until/Resources/en.lproj/Localizable.strings @@ -340,3 +340,50 @@ "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"; + +/* 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 0f27e86..880457c 100644 --- a/Sources/Until/Resources/ja.lproj/Localizable.strings +++ b/Sources/Until/Resources/ja.lproj/Localizable.strings @@ -340,3 +340,50 @@ "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" = "カレンダーを接続"; + +/* 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..dd77b78 --- /dev/null +++ b/Sources/Until/StartAlert.swift @@ -0,0 +1,341 @@ +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 { + let large = style == .fullScreen + HStack(spacing: Theme.Spacing.sm) { + if !event.conferenceUrl.isEmpty { + Button(action: actions.join) { + Label(loc("Join"), systemImage: "video.fill") + } + .buttonStyle(StartAlertButtonStyle(fill: tint, large: large)) + .keyboardShortcut(.defaultAction) + } else { + Button(loc("Open Event"), action: actions.open) + .buttonStyle(StartAlertButtonStyle(fill: tint, large: large)) + .keyboardShortcut(.defaultAction) + } + Button(loc("Snooze 1 min"), action: actions.snooze) + .buttonStyle(StartAlertButtonStyle(fill: nil, large: large)) + Button(loc("Dismiss"), action: actions.dismiss) + .buttonStyle(StartAlertButtonStyle(fill: nil, large: large)) + .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: " · ") + } +} + +/// 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()) + } +} diff --git a/Sources/Until/Views.swift b/Sources/Until/Views.swift index 201e64a..621fcb7 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 { @@ -281,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() @@ -340,117 +346,143 @@ 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) - + ScrollView(.vertical) { 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) { - Text("Until") - .font(.title2.weight(.semibold)) - Text(loc("Your next Google Calendar event, always in the menubar.")) - .font(.callout) - .foregroundStyle(.secondary) + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.12)) + .frame(width: 44, height: 44) + Image(nsImage: BrandIcon.menubarImage(size: 22)) + .renderingMode(.template) + .foregroundStyle(Color.accentColor) + } + Text(loc("Your next meeting, always in the menubar.")) + .font(.callout.weight(.medium)) .multilineTextAlignment(.center) - .frame(maxWidth: 280) + Button { + model.startSamplePreview() + } label: { + Label(loc("Try it with a sample day"), systemImage: "sparkles") + .font(.caption) + } + .buttonStyle(.link) } - } - 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) } + .card(.inset, padding: Theme.Spacing.md) } - .frame(maxWidth: 300) - - if let error = model.signInError ?? model.state.lastError { - InlineErrorView(message: error) - .frame(maxWidth: 300) - } + .frame(maxWidth: 320) + .frame(maxWidth: .infinity) + .padding(.horizontal, Theme.Spacing.xl) + .padding(.vertical, Theme.Spacing.md) + } + } - 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)) } } @@ -771,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 { @@ -893,6 +929,52 @@ 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 + + /// Shows as many whole chips as fit on one line rather than truncating + /// every chip's title. + var body: some View { + ViewThatFits(in: .horizontal) { + ForEach(Array(stride(from: event.links.count, through: 1, by: -1)), id: \.self) { count in + chips(Array(event.links.prefix(count))) + } + } + } + + private func chips(_ links: [EventLink]) -> some View { + HStack(spacing: Theme.Spacing.xs) { + ForEach(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) + .fixedSize() + .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 @@ -1537,7 +1619,7 @@ struct TimelineSummary: Equatable { /// the collapsed list stays one quiet line per event. Whitespace-only /// locations count as absent, matching the hero's metadata. func timelineSummary(for event: CalendarEvent) -> TimelineSummary? { - let location = event.location.trimmingCharacters(in: .whitespacesAndNewlines) + let location = EventLinks.displayLocation(for: event) if !location.isEmpty { return TimelineSummary(kind: .location, text: location) } @@ -1596,7 +1678,7 @@ func heroMetadataParts(for event: CalendarEvent) -> [String] { parts.append(time) } - let location = event.location.trimmingCharacters(in: .whitespacesAndNewlines) + let location = EventLinks.displayLocation(for: event) if !location.isEmpty { parts.append(location) } @@ -2011,7 +2093,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 +2101,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 +2182,7 @@ struct SettingsView: View { private var accountTab: some View { SettingsTab { + MacCalendarsPanel(model: model) ConnectedAccountsPanel(model: model, draft: $draft) } .task { @@ -2146,6 +2229,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( @@ -2192,11 +2290,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) { @@ -2225,6 +2332,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) @@ -2302,6 +2411,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) { @@ -2435,6 +2612,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 +3018,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 +3043,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/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 + ) + } +} diff --git a/Tests/UntilTests/TimelineSummaryTests.swift b/Tests/UntilTests/TimelineSummaryTests.swift index f6d796e..d7f731e 100644 --- a/Tests/UntilTests/TimelineSummaryTests.swift +++ b/Tests/UntilTests/TimelineSummaryTests.swift @@ -22,6 +22,21 @@ final class TimelineSummaryTests: XCTestCase { XCTAssertEqual(timelineSummary(for: event)?.kind, .meetingProvider) } + func testMeetingLinkLocationShowsAsTheServiceName() { + let meet = "https://meet.google.com/abc-defg-hij" + let event = makeEvent(location: meet, conferenceUrl: meet) + + XCTAssertEqual(timelineSummary(for: event), TimelineSummary(kind: .meetingProvider, text: "Google Meet")) + XCTAssertFalse(heroMetadataParts(for: event).contains(meet)) + XCTAssertTrue(heroMetadataParts(for: event).contains("Google Meet")) + } + + func testOrdinaryLinkLocationIsKept() { + let event = makeEvent(location: "https://example.com/office") + + XCTAssertEqual(timelineSummary(for: event)?.text, "https://example.com/office") + } + func testSummaryIsOmittedWithoutLocationOrProvider() { XCTAssertNil(timelineSummary(for: makeEvent())) } diff --git a/docs/art/hero-en.svg b/docs/art/hero-en.svg new file mode 100644 index 0000000..a74dc6b --- /dev/null +++ b/docs/art/hero-en.svg @@ -0,0 +1,79 @@ + + + + + +Wed Aug 12 9:53 AM + + + +in 7m Design review: website screenshots + + + + + +Today + +all-day + +Launch assets due + +8:55 AM + +Morning run + +Riverside Park +9:53 AM + + + +10:00 AM + + +NEXT +in 7m +Design review: website +screenshots +10:00 AM – 10:45 AM · Google Meet · Maya C… + + +Google Doc + + +Figma + + +PR #12 + + +Join + +Open notes + +45m free +10:45 AM–11:30 AM + +11:30 AM + +Project work block + +Desk + +30m free +12:30 PM–1:00 PM + +1:00 PM + +Product sync + +Google Meet + +60m free +1:30 PM–2:30 PM + +2:30 PM + +Focus: Until copy polish + + diff --git a/docs/art/hero-ja.svg b/docs/art/hero-ja.svg new file mode 100644 index 0000000..954e1f7 --- /dev/null +++ b/docs/art/hero-ja.svg @@ -0,0 +1,79 @@ + + + + + +8月12日(水) 9:53 + + + +7m後 デザインレビュー: サイトのスクショ + + + + + +今日 + +終日 + +ローンチ素材の締切 + +8:55 + +朝のランニング + +河川敷の公園 +9:53 + + + +10:00 + + +次の予定 +7m後 +デザインレビュー: +サイトのスクリーンショット +10:00 – 10:45 · Google Meet · Maya Chen + + +Google Doc + + +Figma + + +PR #12 + + +参加 + +議事録を開く + +45分の空き +10:45–11:30 + +11:30 + +プロジェクト作業 + +デスク + +30分の空き +12:30–13:00 + +13:00 + +プロダクト定例 + +Google Meet + +60分の空き +13:30–14:30 + +14:30 + +集中: 文言の仕上げ + + diff --git a/docs/art/notification-en.svg b/docs/art/notification-en.svg new file mode 100644 index 0000000..9d0f89f --- /dev/null +++ b/docs/art/notification-en.svg @@ -0,0 +1,10 @@ + + + + + + +Design review: website screenshots +Starts at 10:00 AM · Google Meet +now + diff --git a/docs/art/notification-ja.svg b/docs/art/notification-ja.svg new file mode 100644 index 0000000..38033ed --- /dev/null +++ b/docs/art/notification-ja.svg @@ -0,0 +1,10 @@ + + + + + + +デザインレビュー: サイトのスクショ +10:00に開始 · Google Meet +今 + diff --git a/docs/art/start-alert-en.svg b/docs/art/start-alert-en.svg new file mode 100644 index 0000000..22775c1 --- /dev/null +++ b/docs/art/start-alert-en.svg @@ -0,0 +1,16 @@ + + + + +Starts in 7m +10:00 AM +Design review: website screenshots +10:00 AM – 10:45 AM · Google Meet + + +Join + +Snooze 1 min + +Dismiss + diff --git a/docs/art/start-alert-ja.svg b/docs/art/start-alert-ja.svg new file mode 100644 index 0000000..32398ad --- /dev/null +++ b/docs/art/start-alert-ja.svg @@ -0,0 +1,16 @@ + + + + +7m後に開始 +10:00 +デザインレビュー: サイトのスクショ +10:00 – 10:45 · Google Meet + + +参加 + +1分後に再通知 + +閉じる + diff --git a/docs/art/timeline-en.svg b/docs/art/timeline-en.svg new file mode 100644 index 0000000..b88c243 --- /dev/null +++ b/docs/art/timeline-en.svg @@ -0,0 +1,77 @@ + + + + + + + +Today + +all-day + +Launch assets due + +8:55 AM + +Morning run + +Riverside Park +9:53 AM + + + +10:00 AM + + +NEXT +in 7m +Design review: website +screenshots +10:00 AM – 10:45 AM · Google Meet · Maya C… + + +Google Doc + + +Figma + + +PR #12 + + +Join + +Open notes + +45m free +10:45 AM–11:30 AM + +11:30 AM + +Project work block + +Desk + +30m free +12:30 PM–1:00 PM + +1:00 PM + +Product sync + +Google Meet + +60m free +1:30 PM–2:30 PM + +2:30 PM + +Focus: Until copy polish + + + + +5 events · 4h30m booked · longest free 1h + + + diff --git a/docs/art/timeline-ja.svg b/docs/art/timeline-ja.svg new file mode 100644 index 0000000..64ecbb1 --- /dev/null +++ b/docs/art/timeline-ja.svg @@ -0,0 +1,77 @@ + + + + + + + +今日 + +終日 + +ローンチ素材の締切 + +8:55 + +朝のランニング + +河川敷の公園 +9:53 + + + +10:00 + + +次の予定 +7m後 +デザインレビュー: +サイトのスクリーンショット +10:00 – 10:45 · Google Meet · Maya Chen + + +Google Doc + + +Figma + + +PR #12 + + +参加 + +議事録を開く + +45分の空き +10:45–11:30 + +11:30 + +プロジェクト作業 + +デスク + +30分の空き +12:30–13:00 + +13:00 + +プロダクト定例 + +Google Meet + +60分の空き +13:30–14:30 + +14:30 + +集中: 文言の仕上げ + + + + +予定5件 · 計4h30m · 最長の空き1h + + + diff --git a/docs/art/widget-en.svg b/docs/art/widget-en.svg new file mode 100644 index 0000000..bbdab50 --- /dev/null +++ b/docs/art/widget-en.svg @@ -0,0 +1,24 @@ + + + +Today +Wed, Aug 12 + +all-day + +Launch assets due +9:53 AM + + +10:00 AM + + +NEXT +7 min +Design review +10:00 AM–10:45 AM +11:30 AM + +Project work block ++2 more + diff --git a/docs/art/widget-ja.svg b/docs/art/widget-ja.svg new file mode 100644 index 0000000..ea4d4a5 --- /dev/null +++ b/docs/art/widget-ja.svg @@ -0,0 +1,24 @@ + + + +今日 +8月12日(水) + +終日 + +ローンチ素材の締切 +9:53 + + +10:00 + + +次の予定 +7分 +デザインレビュー +10:00–10:45 +11:30 + +プロジェクト作業 +ほか2件 + diff --git a/docs/index.html b/docs/index.html index bcaecc3..4a34e3f 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/until
+

Free & open source macOS 13+ Apple Silicon & Intel English / 日本語

Until's macOS menubar view expanded into a continuous day timeline, with the next event prominent and later events continuing below. @@ -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
+
+ - -
-
-
-

Notification

-

Reminded — and one click from joining.

-

A native reminder arrives before every meeting; click it to join instantly, or hold it to open the event or snooze five minutes.

-
- -
- A macOS notification from Until for Design review, showing its start time and Google Meet link. -
+

⌥-click the icon or press the join shortcut to jump into the call · hide the title while you present · launch at login

@@ -138,13 +120,14 @@

Plan your day at a glance.

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.

Meeting notes

Notes, ready before you are.

-

One click on any event:

+

One click on any Google Calendar event:

-
    -
  1. Creates a Google Doc from your template
  2. -
  3. Shares it with every attendee
  4. -
  5. Attaches it to the calendar event
  6. -
+
  1. Creates a Google Doc from your template
  2. Shares it with every attendee
  3. Attaches it to the calendar event
+

The upcoming card also surfaces the docs, designs, and pull requests linked from the invite.

+ + + +
+
+
+

Calendars

+

Works with the calendars you have.

+

Use the Calendar app, a Google account, or both — events that show up twice are merged.

+
+ +
+
+ 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.

+
+ +
+
+ Until's start alert: Design review starts in 8 minutes, with Join, Snooze 1 min, and Dismiss buttons. +
+
+ A macOS notification from Until for Design review, showing its start time and Google Meet link. +
+
+
+
+ +
+
+
+

Widgets

+

Today, on your desktop.

+

Medium and large widgets show today's agenda, with the next meeting highlighted and a live countdown.

+
+ +
+ Until's medium desktop widget: an all-day event, the current time, the next meeting with a countdown, and the following event. +
@@ -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.

+
+
+ + + +
UntilMeetingBarDatoDot
PriceFree, open sourceFree, open source$18$14.99
CalendarsCalendar app + Google sign-inCalendar app + GoogleCalendar appCalendar app
Menu bar countdown✓✓✓✓
One-click join✓✓✓✓
Full-screen alert✓✓✓✓
Day viewOne timeline with free timeEvent listMonth + event listMonth + 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.

+
+
+

Private by design.

-

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/until
@@ -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..33772f0 --- /dev/null +++ b/docs/ja/index.html @@ -0,0 +1,452 @@ + + + + + + Until — 次の会議をmacOSのメニューバーに + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + Until +
+

次の予定を、いつも目の前に。

+

macOS のメニューバーに次の会議までのカウントダウン。クリックすれば一日がひとつのタイムラインに。議事録はワンクリックで。

+ +
brew install --cask combinatrix-ai/tap/until
+

無料・オープンソース macOS 13 以降 Apple シリコン / Intel 日本語 / English

+
+ +
+ メニューバーのカウントダウンから一日のタイムラインを開いたところ。次の予定が大きく表示され、その後の予定が続く。 + +
+
+ +
+
  • サーバーなし。予定は Mac とカレンダーの間だけ
  • Apple の署名・公証済み
  • オープンソース(MIT)
  • サインイン情報はキーチェーンに保存
+
+ + + +
+
+
+

Timeline

+

一日を、ひと目で。

+

メニューバーをクリックすると一日が開きます。会議の一覧ではなく、空き時間まで形になって見える一本のタイムライン。

+
+ +
+ ポップオーバーに表示された一日のタイムライン。進行中の予定、空き時間、この後の予定が並ぶ。 +
+

下部には今日の予定の件数・予定の合計時間・残りの最長の空き時間を表示します。

+
+
+ +
+
+
+

Meeting notes

+

議事録は、会議より先に。

+

Google カレンダーの予定でワンクリックすると:

+
+ +
  1. テンプレートから Google ドキュメントを作成
  2. 参加者全員と共有
  3. 予定に添付
+ +

次の予定のカードには、招待に含まれる資料・デザイン・PR へのリンクも表示します。

+
+
+ +
+
+
+

Calendars

+

いま使っているカレンダーで。

+

カレンダーApp、Google アカウント、その両方。2か所に出てくる同じ予定は1つにまとめます。

+
+ +
+
+ サインイン不要 +

このMacのカレンダー

+

カレンダーAppに入っている予定を、Mac の中で読み取ります。

+
  • iCloud・Google・Outlook / Exchange・CalDAV
  • 会社でサードパーティ製アプリが禁止されていても使える
  • 予定が Mac の外に出ない
+
+
+ 議事録と Meet +

Google アカウント

+

直接サインインすると、Google ならではの機能が使えます。

+
  • 議事録ドキュメントをワンクリックで作成
  • 予定に Google Meet リンクを追加
  • 複数アカウントを並べて表示
+
+
+
+
+ +
+
+
+

Alerts

+

見逃さず、すぐ参加。

+

会議の前にはネイティブの通知。さらに希望すれば、開始時に画面へ出し続けるアラートも。浮動カードか、すべてのディスプレイへの全画面表示を選べます。

+
+ +
+
+ Until の開始アラート。「デザインレビュー」が8分後に開始、参加・1分後に再通知・閉じるのボタン。 +
+
+ Until の macOS 通知。予定の開始時刻と Google Meet のリンクを表示。 +
+
+
+
+ +
+
+
+

Widgets

+

今日の予定を、デスクトップに。

+

中・大サイズのウィジェットで今日の予定を一覧。次の会議はカウントダウン付きで強調表示します。

+
+ +
+ Until の中サイズのウィジェット。終日の予定、現在時刻、カウントダウン付きの次の会議、その次の予定。 +
+
+
+ +
+
+
+

Focus

+

大事な予定だけを。

+

辞退した招待、重要度の低いカレンダー、終日の仮押さえを細かいフィルターで除外。残るのは、注意を向けるべき予定だけです。

+
+ +

フィルター編集画面のルール例: 自分の回答が「辞退」でない、カレンダーが選択した2つのいずれでもない、終日でない。

+ +
+
+ +
+
+
+

Details

+

細部まで、きちんと。

+
+

30以上の会議サービス

Google Meet・Zoom・Teams・Webex・Slack ハドル・Discord・Jitsi・FaceTime・Whereby など。Zoom と Teams はアプリで開けます。

グローバルショートカット

どこからでもタイムラインを開いたり、会議に参加したり。

接続前に試せる

カレンダーを選ぶ前に、サンプルの一日でタイムラインを確認できます。

日本語と英語

すべての画面が両方の言語に対応。

+
+
+ +
+
+
+

Compare

+

ほかのアプリとの違い。

+
+
+ + + +
UntilMeetingBarDatoDot
価格無料・オープンソース無料・オープンソース$18$14.99
カレンダーカレンダーApp + Google サインインカレンダーApp + GoogleカレンダーAppカレンダーApp
メニューバーのカウントダウン✓✓✓✓
ワンクリック参加✓✓✓✓
全画面アラート✓✓✓✓
一日の表示空き時間つきの一本のタイムライン予定の一覧月表示+一覧月表示+一覧
議事録ドキュメント(作成・共有・添付)✓———
ルールによるフィルター✓トグル——
+
+

2026年9月時点、各アプリの公式サイトの記載によります。どれも良いアプリです。Until は、一日の形と議事録をワンクリックの距離に置きたい人のためのアプリです。

+
+
+ +
+
+
+

FAQ

+

よくある質問。

+
+
無料ですか?

はい。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-ja.png b/docs/og-image-ja.png new file mode 100644 index 0000000..02a8490 Binary files /dev/null and b/docs/og-image-ja.png differ diff --git a/docs/og-image.png b/docs/og-image.png new file mode 100644 index 0000000..a031a51 Binary files /dev/null and b/docs/og-image.png differ diff --git a/docs/privacy.html b/docs/privacy.html index b39276b..66bdf27 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -27,12 +27,13 @@