mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user