From cc418ad2efb1c09d7f2f93e18f392bbbe5cc26cc Mon Sep 17 00:00:00 2001 From: abue-ammar Date: Fri, 28 Aug 2026 19:54:46 +0600 Subject: [PATCH] Open a Google Meet link as the account whose calendar carried it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone signed into several Google accounts lands on the account chooser every time. `MeetingLink` now carries the address the current user holds in the invite — their attendee entry, or the organizer for a meeting booked with no guests — and `webURL` appends `?authuser=` for Meet. `url` stays the link as written, so Copy Meeting Link and the failure report carry no address of ours, and a link that already names an authuser is left alone. Closes #365 --- Tests/calendar-test.swift | 36 +++++++++++++++++ .../Features/Calendar/Model/MeetingLink.swift | 40 +++++++++++++++---- .../Calendar/Service/CalendarStore.swift | 22 ++++++---- .../Calendar/Service/MeetingLauncher.swift | 2 +- docs/features/calendar.md | 12 +++++- 5 files changed, 94 insertions(+), 18 deletions(-) diff --git a/Tests/calendar-test.swift b/Tests/calendar-test.swift index 0c331903..47068c46 100644 --- a/Tests/calendar-test.swift +++ b/Tests/calendar-test.swift @@ -21,6 +21,7 @@ struct CalendarTests { fieldPrecedence() linkScanning() appURLRewrites() + accountPrefill() agendaFiltering() cardWindow() chordFallsBackWiderThanTheCard() @@ -137,6 +138,37 @@ struct CalendarTests { expect(link("https://example.com/room")?.appURL == nil, "a bare link opens the web") } + static func accountPrefill() { + expect( + hosted("https://meet.google.com/abc-defg-hij", "user@domain.com")?.webURL.absoluteString + == "https://meet.google.com/abc-defg-hij?authuser=user@domain.com", + "a Meet link opens as the account whose calendar carried it") + expect( + hosted("https://meet.google.com/abc?hs=1", "user@domain.com")?.webURL.absoluteString + == "https://meet.google.com/abc?hs=1&authuser=user@domain.com", + "the account joins a query the invite already had") + expect( + hosted("https://meet.google.com/abc?authuser=1", "user@domain.com")?.webURL + .absoluteString == "https://meet.google.com/abc?authuser=1", + "a link naming its own account is left alone") + expect( + hosted("https://meet.google.com/abc", "a+b@domain.com")?.webURL.absoluteString + == "https://meet.google.com/abc?authuser=a%2Bb@domain.com", + "a plus in the address is encoded, so it cannot be read as a space") + expect( + hosted("https://us02web.zoom.us/j/89", "user@domain.com")?.webURL.absoluteString + == "https://us02web.zoom.us/j/89", + "no other provider takes an account in the URL") + expect( + hosted("https://meet.google.com/abc-defg-hij", nil)?.webURL.absoluteString + == "https://meet.google.com/abc-defg-hij", + "a calendar with no address of its own leaves the link as written") + expect( + hosted("https://meet.google.com/abc-defg-hij", "user@domain.com")?.url.absoluteString + == "https://meet.google.com/abc-defg-hij", + "the link as written is what Copy Meeting Link keeps") + } + // MARK: - The join window static func agendaFiltering() { @@ -416,6 +448,10 @@ struct CalendarTests { static func link(_ text: String) -> MeetingLink? { MeetingLink.detect(in: text) } + static func hosted(_ text: String, _ account: String?) -> MeetingLink? { + MeetingLink.detect(fields: [text], account: account) + } + static func provider(_ text: String) -> MeetingLink.Provider? { link(text)?.provider } static func event( diff --git a/Tinycast/Features/Calendar/Model/MeetingLink.swift b/Tinycast/Features/Calendar/Model/MeetingLink.swift index 8dcf27e7..d8a7cc3d 100644 --- a/Tinycast/Features/Calendar/Model/MeetingLink.swift +++ b/Tinycast/Features/Calendar/Model/MeetingLink.swift @@ -4,17 +4,23 @@ import Foundation struct MeetingLink: Hashable, Sendable { let provider: Provider let url: URL + /// The address of the account whose calendar carried this link, for a provider that preselects it. + let account: String? /// The desktop app's own URL, where one can be formed without guessing. Nil means open the web. var appURL: URL? { provider.appURL(for: url) } + /// What a browser is handed: the link as written, naming the account where the provider takes one. + var webURL: URL { provider.accountURL(for: url, account: account) ?? url } + /// Fields in precedence order; a named provider anywhere beats a bare link found earlier. - static func detect(fields: [String?]) -> MeetingLink? { + static func detect(fields: [String?], account: String? = nil) -> MeetingLink? { var fallback: MeetingLink? for field in fields.compactMap({ $0 }) { for url in webURLs(in: field) { - guard let link = classify(url) else { continue } - if link.provider != .generic { return link } + guard let provider = classify(url) else { continue } + let link = MeetingLink(provider: provider, url: url, account: account) + if provider != .generic { return link } if fallback == nil { fallback = link } } } @@ -27,14 +33,12 @@ struct MeetingLink: Hashable, Sendable { /// A URL on a known host that fails that provider's path rule is rejected, never demoted to /// `.generic` — `zoom.us/download` sits in half the invites people are sent. - private static func classify(_ url: URL) -> MeetingLink? { + private static func classify(_ url: URL) -> Provider? { guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", let host = url.host()?.lowercased() else { return nil } - guard let provider = Provider(host: host) else { - return MeetingLink(provider: .generic, url: url) - } - return provider.admits(path: url.path()) ? MeetingLink(provider: provider, url: url) : nil + guard let provider = Provider(host: host) else { return .generic } + return provider.admits(path: url.path()) ? provider : nil } private static let terminators: Set = [ @@ -140,6 +144,26 @@ extension MeetingLink { } } + private static let accountQuery = "authuser" + /// Meet's own links keep `@` readable; `+` cannot stay, as a server may read it as a space. + private static let accountAllowed = CharacterSet.urlQueryAllowed.subtracting( + CharacterSet(charactersIn: "+&=")) + + /// Google Meet is the only provider whose URL takes an account, and `authuser` is how it + /// picks between several signed-in identities rather than asking. + func accountURL(for url: URL, account: String?) -> URL? { + guard self == .googleMeet, let account, + let encoded = account.addingPercentEncoding(withAllowedCharacters: Self.accountAllowed), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + let items = components.percentEncodedQueryItems ?? [] + // A link naming its own account was written deliberately, and outranks the calendar's. + guard !items.contains(where: { $0.name == Self.accountQuery }) else { return nil } + components.percentEncodedQueryItems = + items + [URLQueryItem(name: Self.accountQuery, value: encoded)] + return components.url + } + /// Only the two rewrites Apple's URL handlers make unambiguous; anything else opens the web. func appURL(for url: URL) -> URL? { switch self { diff --git a/Tinycast/Features/Calendar/Service/CalendarStore.swift b/Tinycast/Features/Calendar/Service/CalendarStore.swift index a17bc916..e54815b2 100644 --- a/Tinycast/Features/Calendar/Service/CalendarStore.swift +++ b/Tinycast/Features/Calendar/Service/CalendarStore.swift @@ -155,9 +155,7 @@ final class CalendarStore { guard event.status != .canceled, let start = event.startDate, let end = event.endDate, let calendar = event.calendar else { return nil } - let declined = - event.attendees? - .first { $0.isCurrentUser }?.participantStatus == .declined + let me = event.attendees?.first { $0.isCurrentUser } return MeetingEvent( id: (event.eventIdentifier ?? event.calendarItemIdentifier) + "|\(start.timeIntervalSinceReferenceDate)", @@ -165,13 +163,23 @@ final class CalendarStore { start: start, end: end, isAllDay: event.isAllDay, - isDeclined: declined, + isDeclined: me?.participantStatus == .declined, calendarID: calendar.calendarIdentifier, calendarName: calendar.title, calendarItemID: event.calendarItemIdentifier, - link: MeetingLink.detect(fields: [ - event.url?.absoluteString, event.location, event.notes - ])) + link: MeetingLink.detect( + fields: [event.url?.absoluteString, event.location, event.notes], + account: accountEmail(of: me ?? event.organizer))) + } + + /// The address this Mac's own account carries in the invite — the organizer covers an event + /// booked with no guests, where there is no attendee list to read. + private static func accountEmail(of participant: EKParticipant?) -> String? { + guard let participant, participant.isCurrentUser, + participant.url.scheme?.lowercased() == "mailto" + else { return nil } + let address = participant.url.path(percentEncoded: false) + return address.contains("@") ? address : nil } func event(id: String) -> MeetingEvent? { diff --git a/Tinycast/Features/Calendar/Service/MeetingLauncher.swift b/Tinycast/Features/Calendar/Service/MeetingLauncher.swift index 886bd11b..1a791728 100644 --- a/Tinycast/Features/Calendar/Service/MeetingLauncher.swift +++ b/Tinycast/Features/Calendar/Service/MeetingLauncher.swift @@ -10,7 +10,7 @@ enum MeetingLauncher { if let appURL = link.appURL, NSWorkspace.shared.urlForApplication(toOpen: appURL) != nil { return NSWorkspace.shared.open(appURL) } - return NSWorkspace.shared.open(link.url) + return NSWorkspace.shared.open(link.webURL) } /// Calendar.app's own handle. A recurring occurrence opens its series, which is all `ical://` takes. diff --git a/docs/features/calendar.md b/docs/features/calendar.md index d09b380a..84adb17c 100644 --- a/docs/features/calendar.md +++ b/docs/features/calendar.md @@ -44,8 +44,8 @@ events as searchable launcher entries. `Model/` holds the whole decision, with every clock read injected: -- **`MeetingLink`** — the join link plus its `Provider`. Ten named services, plus `.generic` for any - other `http(s)` link the event carries. +- **`MeetingLink`** — the join link plus its `Provider` and the account whose calendar carried it. + Ten named services, plus `.generic` for any other `http(s)` link the event carries. - **`MeetingEvent`** — one occurrence, flattened out of `EKEvent`. - **`UpcomingWindow`** — `agenda`, `carded`, `joinable` and `countdown`. - **`MeetingDay`** — the Today / Tomorrow buckets, mirroring the clipboard's `DateBucket`. @@ -75,6 +75,14 @@ is a dial-in helper rather than a meeting. unambiguous scheme, and guessing one would open the wrong thing. If no app claims the scheme, the plain `https` link opens instead. +**A Google Meet link opens as the account whose calendar carried it.** Someone signed into several +Google accounts otherwise lands on the account chooser, so `MeetingLink.webURL` appends +`?authuser=
` — the address the current user carries in the invite, taken from their attendee +entry or, for a meeting booked with no guests, from the organizer. A link that already names an +`authuser` was written deliberately and is left alone, and no other provider takes an account in its +URL. **`MeetingLink.url` stays the link as written**: it is what the failure report quotes and what +`Copy Meeting Link` puts on the pasteboard, so a link shared onwards carries no address of ours. + No brand artwork ships with the app, so every named provider draws `video.fill` and the **name** carries the identity; `.generic` draws `link`.