Open a Google Meet link as the account whose calendar carried it

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
This commit is contained in:
abue-ammar
2026-08-28 20:03:39 +06:00
committed by Abue Ammar
parent 558d8ea53d
commit cc418ad2ef
5 changed files with 94 additions and 18 deletions
+36
View File
@@ -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(
@@ -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<Character> = [
@@ -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 {
@@ -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? {
@@ -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.
+10 -2
View File
@@ -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=<address>` — 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`.