Merge branch 'feat/default-server' into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
22 files changed,
+1252
−180
internal/callstest/iphone_browser_test.go+8 −1internal/callstest/native_auth_browser_test.go+10 −7native/DESIGN.md+16 −3native/README.md+56 −11native/VERIFICATION.md+20 −0native/apple/Sources/CallsInterop/LobbySnapshots.swift+76 −24native/apple/Sources/CallsInterop/SettingsSnapshots.swift+5 −23native/apple/Sources/CallsKit/CallsLobby.swift+84 −24native/apple/Sources/CallsKit/CallsModel.swift+214 −49native/apple/Sources/CallsKit/CallsServer.swift+129 −0native/apple/Sources/CallsKit/CallsSettingsView.swift+5 −3native/apple/Sources/CallsKit/GuestJoinView.swift+114 −0native/apple/Sources/CallsKit/ServerSettings.swift+122 −0native/apple/Sources/CallsMac/CallsMac.swift+1 −1native/apple/Tests/CallsKitTests/DefaultServerTests.swift+288 −0native/apple/Tests/CallsKitTests/KeptSignInTests.swift+3 −2native/apple/Tests/CallsKitTests/MeetingLinkTests.swift+5 −4native/apple/Tests/CallsKitTests/StartMeetingTests.swift+12 −5native/ios/README.md+3 −1native/ios/Sources/CallsPhone.swift+16 −1native/ios/UITests/CallsPhoneUITests.swift+64 −20native/ios/project.yml+1 −1
CI passed — run details
diff --git a/internal/callstest/iphone_browser_test.go b/internal/callstest/iphone_browser_test.go| index 908bc36..70906b2 100644 |
| --- a/internal/callstest/iphone_browser_test.go |
| +++ b/internal/callstest/iphone_browser_test.go |
| @@ -52,7 +52,14 @@ func TestIPhoneSafariAuthentication(t *testing.T) { |
| defer cancel() |
| results := filepath.Join(root, "native/build/iphone-auth-"+name+"-"+time.Now().Format("150405")+".xcresult") |
| cmd := exec.CommandContext(ctx, "xcodebuild", "-project", filepath.Join(root, "native/ios/CallsPhone.xcodeproj"), "-scheme", "CallsPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", filepath.Join(root, "native/build/ios-simulator"), "-resultBundlePath", results, "CODE_SIGNING_ALLOWED=NO", "-only-testing:CallsPhoneUITests/CallsPhoneUITests/testEmailBrowserHandoff", "test") |
| - cmd.Env = append(os.Environ(), "TEST_RUNNER_CALLS_TEST_MEETING="+rig.origin+target, "TEST_RUNNER_CALLS_TEST_CONTROL="+control.URL, "TEST_RUNNER_CALLS_TEST_EMAIL="+memberB) |
| + // A member signs in to the server, which the app is pointed at for |
| + // this run with -CallsServer; a guest pastes the invitation into the |
| + // guest screen. |
| + guestFlag := "0" |
| + if guest { |
| + guestFlag = "1" |
| + } |
| + cmd.Env = append(os.Environ(), "TEST_RUNNER_CALLS_TEST_MEETING="+rig.origin+target, "TEST_RUNNER_CALLS_TEST_ORIGIN="+rig.origin, "TEST_RUNNER_CALLS_TEST_GUEST="+guestFlag, "TEST_RUNNER_CALLS_TEST_CONTROL="+control.URL, "TEST_RUNNER_CALLS_TEST_EMAIL="+memberB) |
| // Xcode's detailed trace contains only synthetic test identities and is |
| // retained locally for diagnosis, rather than flooding the normal gate log. |
| logPath := filepath.Join(root, "native/build/iphone-auth-"+name+".log") |
diff --git a/internal/callstest/native_auth_browser_test.go b/internal/callstest/native_auth_browser_test.go| index 6e6a28d..565942f 100644 |
| --- a/internal/callstest/native_auth_browser_test.go |
| +++ b/internal/callstest/native_auth_browser_test.go |
| @@ -19,19 +19,22 @@ import ( |
| ) |
| func TestNativeBrowserAuthentication(t *testing.T) { |
| - for _, guest := range []bool{false, true} { |
| - name := "member" |
| - if guest { |
| - name = "guest" |
| - } |
| + // "server" is the apps' Sign in button: the server's own address, no |
| + // meeting, landing home. |
| + for _, name := range []string{"member", "guest", "server"} { |
| t.Run(name, func(t *testing.T) { |
| rig := newBrowserRig(t) |
| room, token := rig.createMeeting(t) |
| address := memberB |
| target := "/m/" + room |
| - if guest { |
| + want := room |
| + switch name { |
| + case "guest": |
| rig.ctl.SetAllowlist(memberA) |
| target = "/j/" + token |
| + case "server": |
| + target = "" |
| + want = "" |
| } |
| ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) |
| defer cancel() |
| @@ -88,7 +91,7 @@ func TestNativeBrowserAuthentication(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if result.Room != room || result.Origin != rig.origin || len(result.Cookies) != 1 { |
| + if result.Room != want || result.Origin != rig.origin || len(result.Cookies) != 1 { |
| t.Fatal("wrong native destination") |
| } |
| jar, _ := cookiejar.New(nil) |
diff --git a/native/DESIGN.md b/native/DESIGN.md| index 4b3ea5d..d6085b1 100644 |
| --- a/native/DESIGN.md |
| +++ b/native/DESIGN.md |
| @@ -10,7 +10,15 @@ while their call layouts suit the available space. |
| - `CallsLobby` renders welcome, browser approval, ready-to-join and error states |
| on Mac, iPhone and iPad. The confirmation code and browser approval remain |
| - part of the existing authentication flow. |
| + part of the existing authentication flow. Signed out, the welcome is the |
| + server's name, one prominent **Sign in** (the suite's label, exactly) and a |
| + secondary **Join a call as a guest**; there is no address field until the |
| + person is signed in. A link waiting on a sign-in names the server it is on. |
| +- `GuestJoinView` (a sheet on both platforms) is one invite-link field with a |
| + Paste control, inline plain-words validation, Join and Cancel. |
| +- `ServerSettingsSection` is the **Calls server** setting: a section in the Mac's |
| + Settings scene after the mini-window settings, and the whole of the iPhone's |
| + settings sheet, reached by the home screen's gear. |
| - `MacCallView` defaults to a remote participant filling the stage with a local |
| thumbnail. Focus/Grid switches layouts; a participant menu selects the focus |
| in group calls. Video crops to fill while preserving its aspect ratio. Chat |
| @@ -32,8 +40,13 @@ After `swift build --package-path native/apple`, render the shipping lobby: |
| native/apple/.build/debug/CallsInterop --lobby-snapshots native/build/polish |
| ``` |
| -This generates light and dark welcome, approval, ready and error fixtures |
| -without a server, real account or device permissions. The fixture entry point |
| +This generates light and dark welcome, approval, home, ready, error, expired, |
| +link-on-another-server and visiting fixtures, the signed-out home and the |
| +settings sheet at iPhone width (`phone-*`), the guest screen empty and with an |
| +invalid link (`guest-*`), and the Mac Settings window (`settings-*`), without a |
| +server, real account or device permissions. Models read the server from a |
| +throwaway defaults suite. The off-screen window is never key, so prominent |
| +buttons draw untinted in these fixtures. The fixture entry point |
| is only in CallsInterop, never the application. |
| `TestNativeMacChat` also captures `native/build/call-focus.png`, `call-chat.png` and `call-grid.png` using the |
diff --git a/native/README.md b/native/README.md| index 855cfff..862ace6 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -11,11 +11,50 @@ make native-mac |
| open native/build/Calls.app |
| ``` |
| -Paste a meeting or invitation URL — or your instance address alone |
| -(`https://calls.oficina.works`). Calls opens your default browser for the |
| -existing Home/Calls sign-in. Open the emailed link normally, compare the code in |
| -the browser with the app, and confirm **Sign in to Calls app**. The app |
| -continues automatically. |
| +Signed out, the home screen names the Calls server — `calls.oficina.works` |
| +unless you have chosen another — and offers one button, **Sign in**. It opens |
| +your default browser for the existing Home/Calls sign-in on that server. Open |
| +the emailed link normally, compare the code in the browser with the app, and |
| +confirm **Sign in to Calls app**. The app continues automatically. There is no |
| +address to type. |
| + |
| +**Join a call as a guest**, under it, opens a screen with one field for the |
| +invite link someone sent you (`/j/…` or `/m/…`, on any server), a **Paste** |
| +control, **Join** and **Cancel**. The link is checked before anything opens, |
| +and a problem is said beside the field in plain words. Join runs that meeting's |
| +own browser sign-in — the pending-join for someone who is not a member — on the |
| +server the link names. The clipboard is read only through Paste, so iPhone |
| +never asks whether Calls may paste, and only a Calls link is taken from it. |
| + |
| +**The server is a setting.** On a Mac it is **Calls server** in **Calls → |
| +Settings…**, after the mini-window settings; on iPhone the gear on the home |
| +screen (or **Change** beside the server line) opens a small settings sheet with |
| +the same field. The address must be https — plain http is accepted only for |
| +`localhost`, `127.0.0.1` and `::1`, the rule callscore's transport enforces — with |
| +a host and nothing after it (no path, query or name and password); whitespace |
| +and trailing slashes are trimmed and a bare host is taken to be https. **Reset |
| +to calls.oficina.works** puts the default back. It is kept in UserDefaults (the |
| +default is kept as no value at all, so a later change of default reaches |
| +everyone who never chose). Changing it while signed in asks first, then signs |
| +out of the old server exactly as **Sign out** does — the Keychain copy |
| +forgotten, the old server asked to revoke the session — and it cannot be |
| +changed during a call. `-CallsServer <origin>` on the command line overrides it |
| +for one run without saving it, which is how the iPhone UI tests point the app at |
| +their own server. |
| + |
| +**Links.** A meeting link — `oficina-calls://join?url=…` from the browser, or |
| +one pasted into the signed-in home's field — never changes the server setting. |
| +On the server you are signed in to, it opens with the session the app holds, as |
| +before. Otherwise a link arriving through `oficina-calls://` is offered, not |
| +started: the home names the server the meeting is on with a **Sign in** button |
| +(and **Not now**), so a browser never opens for a server the person has not seen |
| +named. One pasted into the field is the person's own act, so **Open link** goes |
| +straight to that sign-in. A link on another server |
| +signs in there **for that meeting only**, exactly as a guest does: that session |
| +is never kept, starts no meetings, and leaving the meeting (or Sign out) drops |
| +it — asking that server to revoke it — and comes back to the server's own home, |
| +signed in if a member's session is kept, signed out if not. The kept sign-in |
| +for the configured server is not replaced by a visit. |
| Once signed in — however sign-in began — the app is home: **Start meeting**, |
| **Join meeting** when there is a meeting in hand (the link you signed in from, |
| @@ -36,13 +75,19 @@ where one is minted. |
| **The sign-in is kept.** A member's session goes into the Keychain — a generic |
| password, this device only, readable while unlocked — and the next launch |
| -checks it with the instance before offering anything: one `GET /settings`, |
| -member-only, read by status alone. A member's page means signed in; the |
| -sign-in redirect means the session has ended (thirty days, or revoked), so it |
| -is forgotten and the instance address is left in the field; anything else — |
| +checks it with the instance before offering anything ("Checking your |
| +sign-in"): one `GET /settings`, member-only, read by status alone. A member's |
| +page means signed in; the sign-in redirect means the session has ended (thirty |
| +days, or revoked), so it is forgotten and the home says so and points at |
| +**Sign in**; anything else — |
| no network, a 5xx — keeps it, uses it, and offers **Check sign-in again**. Only |
| -a member's session is kept: a guest's, or an invited non-member's, is good for |
| -the meeting it came in through and is signed in again from the next link. |
| +a member's session on the configured server is kept: a guest's, an invited |
| +non-member's, or one on another server is good for the meeting it came in |
| +through and is signed in again from the next link. A session kept for a server |
| +that is no longer the configured one (the setting changed outside the app) is |
| +signed out at launch. A sign-in that fails — no network, a server that does not |
| +answer — says what happened and to choose **Sign in** (or **Join a call as a |
| +guest**) to try again. |
| **Sign out** forgets the Keychain copy at once and asks the instance to revoke |
| the session with the web's own `POST /signout`; if that cannot be reached, the |
| session lapses at its thirty days. |
diff --git a/native/VERIFICATION.md b/native/VERIFICATION.md| index 101bfa3..4cad51b 100644 |
| --- a/native/VERIFICATION.md |
| +++ b/native/VERIFICATION.md |
| @@ -1,3 +1,23 @@ |
| +# Default server, Sign in, guest joining, server setting — 2026-09-24 |
| + |
| +Branch `feat/default-server` off `f91de82`. The signed-out home names the |
| +server (calls.oficina.works by default) with one **Sign in** and a secondary |
| +**Join a call as a guest**; the server is a setting (Mac Settings scene, |
| +iPhone gear sheet). M1 Max, macOS 27. |
| + |
| +| Check | Result | |
| +| --- | --- | |
| +| `build-bridge.sh` | Rebuilt; RNNoise and DeepFilter cache hits at their pins | |
| +| `swift test` | 133 tests, 0 failures, 1 skipped (keychain); adds `DefaultServerTests` (17) | |
| +| `GOTOOLCHAIN=go1.26.5 make ci`, `make native-test` | Passed with `GOFLAGS=-count=1` | |
| +| `make native-mac` | Built; launched and stayed up | |
| +| `make iphone` | Simulator build succeeded | |
| +| `CGO_ENABLED=0 make browser` | 33 of 34 in the full run while an Xcode build shared the machine; `mutualshare` (a frame-flow window stalled) passed alone on an idle machine. No web code changed | |
| +| `make native-browser` | Passed; `TestNativeBrowserAuthentication` gains a `server` case (Sign in's instance-root handoff) | |
| +| iPhone UI tests | `testSignedOutHomeIsSignInAndGuest`, `testInvalidGuestLink` passed on the simulator | |
| +| `TestIPhoneSafariAuthentication` | Member (Sign in, `-CallsServer`) and guest (guest screen) passed. The guest case was already failing on main: the web's guest screen needs its "sign in" link followed, and Safari's autofill bubble could take the tap on the submit button | |
| +| Snapshots | `CallsInterop --lobby-snapshots` renders the new states (see DESIGN.md) | |
| + |
| # Per-call device routing on LiveKit's module — 2026-09-23 |
| Branch `merge/native-call-devices`: `native-call-devices` (`410ddcb`, the |
diff --git a/native/apple/Sources/CallsInterop/LobbySnapshots.swift b/native/apple/Sources/CallsInterop/LobbySnapshots.swift| index 7691373..446bd4a 100644 |
| --- a/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| @@ -3,14 +3,23 @@ import CallsKit |
| import SwiftUI |
| /// Offline visual fixtures of the shipping lobby. Only built into the test executable. |
| +/// |
| +/// Every model reads its server from a throwaway defaults suite, so a |
| +/// fixture neither sees nor changes the setting of whoever runs it. |
| @MainActor enum LobbySnapshots { |
| static func save(to directory: String) async throws { |
| NSApplication.shared.setActivationPolicy(.prohibited) |
| let folder = URL(fileURLWithPath: directory) |
| try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| + let suite = "CallsInterop.LobbySnapshots" |
| + guard let defaults = UserDefaults(suiteName: suite) else { return } |
| + defaults.removePersistentDomain(forName: suite) |
| + defer { defaults.removePersistentDomain(forName: suite) } |
| for scheme in [ColorScheme.light, .dark] { |
| - for state in ["welcome", "signin", "home", "ready", "error"] { |
| - let model = CallsModel() |
| + for state in [ |
| + "welcome", "signin", "home", "ready", "error", "expired", "link-other", "visiting", |
| + ] { |
| + let model = CallsModel(defaults: defaults) |
| if state == "signin" { |
| model.authenticating = true |
| model.confirmationCode = "A1B2-C3D4" |
| @@ -27,32 +36,75 @@ import SwiftUI |
| model.origin = "https://calls.oficina.works" |
| model.room = "team-catchup" |
| } |
| - if state == "error" { model.error = "Enter a full meeting or invitation link." } |
| - let view = NSHostingView( |
| - rootView: CallsLobby(model: model).background( |
| - scheme == .dark ? Color(white: 0.12) : Color(white: 0.98) |
| - ).environment(\.colorScheme, scheme)) |
| - let window = NSWindow( |
| - contentRect: NSRect(x: -2000, y: -2000, width: 900, height: 720), |
| - styleMask: [.borderless], backing: .buffered, defer: false) |
| - window.isReleasedWhenClosed = false |
| - window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) |
| - window.contentView = view |
| - window.orderFront(nil) |
| - try await Task.sleep(for: .milliseconds(300)) |
| - view.layoutSubtreeIfNeeded() |
| - guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { |
| - throw CocoaError(.fileWriteUnknown) |
| + if state == "error" { |
| + model.error = "Could not reach calls.oficina.works. Choose Sign in to try again." |
| } |
| - view.cacheDisplay(in: view.bounds, to: bitmap) |
| - guard let data = bitmap.representation(using: .png, properties: [:]) else { |
| - throw CocoaError(.fileWriteUnknown) |
| + if state == "expired" { |
| + model.error = "Your sign-in to calls.oficina.works has ended. Choose Sign in to continue." |
| } |
| - try data.write( |
| - to: folder.appendingPathComponent("\(state)-\(scheme == .dark ? "dark" : "light").png")) |
| - window.close() |
| + if state == "link-other" { |
| + model.pendingLink = "https://calls.example.org/j/invitation" |
| + } |
| + if state == "visiting" { |
| + model.signedIn = true |
| + model.visiting = true |
| + model.origin = "https://calls.example.org" |
| + model.room = "abc123" |
| + } |
| + try await render( |
| + CallsLobby(model: model), scheme: scheme, size: CGSize(width: 900, height: 720), |
| + to: folder.appendingPathComponent("\(state)-\(name(scheme)).png")) |
| } |
| + // The same signed-out home at iPhone width, with the Change button |
| + // the phone's settings sheet is reached by. |
| + try await render( |
| + CallsLobby(model: CallsModel(defaults: defaults), openSettings: {}), scheme: scheme, |
| + size: CGSize(width: 390, height: 780), |
| + to: folder.appendingPathComponent("phone-welcome-\(name(scheme)).png")) |
| + try await render( |
| + GuestJoinView(model: CallsModel(defaults: defaults)) {}, scheme: scheme, size: nil, |
| + to: folder.appendingPathComponent("guest-\(name(scheme)).png")) |
| + try await render( |
| + GuestJoinView( |
| + model: CallsModel(defaults: defaults), link: "calls.example.org/settings", problem: true |
| + ) {}, scheme: scheme, size: nil, |
| + to: folder.appendingPathComponent("guest-invalid-\(name(scheme)).png")) |
| + try await render( |
| + ServerSettingsSheet(model: CallsModel(defaults: defaults)) {}, scheme: scheme, |
| + size: CGSize(width: 390, height: 520), |
| + to: folder.appendingPathComponent("phone-settings-\(name(scheme)).png")) |
| } |
| try await SettingsSnapshots.save(to: folder) |
| } |
| + |
| + static func name(_ scheme: ColorScheme) -> String { scheme == .dark ? "dark" : "light" } |
| + |
| + /// Draws a view off screen into a PNG. A nil size takes the view's own. |
| + static func render(_ content: some View, scheme: ColorScheme, size: CGSize?, to file: URL) |
| + async throws |
| + { |
| + let view = NSHostingView( |
| + rootView: content.background(scheme == .dark ? Color(white: 0.12) : Color(white: 0.98)) |
| + .environment(\.colorScheme, scheme)) |
| + let frame = size ?? view.fittingSize |
| + view.frame.size = frame |
| + let window = NSWindow( |
| + contentRect: NSRect(origin: CGPoint(x: -2000, y: -2000), size: frame), |
| + styleMask: [.borderless], backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) |
| + window.contentView = view |
| + window.orderFront(nil) |
| + try await Task.sleep(for: .milliseconds(300)) |
| + view.layoutSubtreeIfNeeded() |
| + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { |
| + throw CocoaError(.fileWriteUnknown) |
| + } |
| + view.cacheDisplay(in: view.bounds, to: bitmap) |
| + guard let data = bitmap.representation(using: .png, properties: [:]) else { |
| + throw CocoaError(.fileWriteUnknown) |
| + } |
| + try data.write(to: file) |
| + window.close() |
| + } |
| } |
diff --git a/native/apple/Sources/CallsInterop/SettingsSnapshots.swift b/native/apple/Sources/CallsInterop/SettingsSnapshots.swift| index a9c71f0..1efc43d 100644 |
| --- a/native/apple/Sources/CallsInterop/SettingsSnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/SettingsSnapshots.swift |
| @@ -9,31 +9,13 @@ import SwiftUI |
| static func save(to folder: URL) async throws { |
| let suite = "CallsInterop.SettingsSnapshots" |
| guard let defaults = UserDefaults(suiteName: suite) else { return } |
| + defaults.removePersistentDomain(forName: suite) |
| defer { defaults.removePersistentDomain(forName: suite) } |
| for scheme in [ColorScheme.light, .dark] { |
| - let view = NSHostingView( |
| - rootView: CallsSettingsView().defaultAppStorage(defaults) |
| - .environment(\.colorScheme, scheme)) |
| - view.frame.size = view.fittingSize |
| - let window = NSWindow( |
| - contentRect: NSRect(origin: CGPoint(x: -2000, y: -2000), size: view.fittingSize), |
| - styleMask: [.borderless], backing: .buffered, defer: false) |
| - window.isReleasedWhenClosed = false |
| - window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) |
| - window.contentView = view |
| - window.orderFront(nil) |
| - try await Task.sleep(for: .milliseconds(300)) |
| - view.layoutSubtreeIfNeeded() |
| - guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { |
| - throw CocoaError(.fileWriteUnknown) |
| - } |
| - view.cacheDisplay(in: view.bounds, to: bitmap) |
| - guard let data = bitmap.representation(using: .png, properties: [:]) else { |
| - throw CocoaError(.fileWriteUnknown) |
| - } |
| - try data.write( |
| - to: folder.appendingPathComponent("settings-\(scheme == .dark ? "dark" : "light").png")) |
| - window.close() |
| + try await LobbySnapshots.render( |
| + CallsSettingsView(model: CallsModel(defaults: defaults)).defaultAppStorage(defaults), |
| + scheme: scheme, size: nil, |
| + to: folder.appendingPathComponent("settings-\(LobbySnapshots.name(scheme)).png")) |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift| index 0381acb..37de59e 100644 |
| --- a/native/apple/Sources/CallsKit/CallsLobby.swift |
| +++ b/native/apple/Sources/CallsKit/CallsLobby.swift |
| @@ -4,8 +4,17 @@ import SwiftUI |
| @MainActor public struct CallsLobby: View { |
| @ObservedObject private var model: CallsModel |
| @Environment(\.colorScheme) private var colorScheme |
| + @State private var joiningAsGuest: Bool |
| + private let openSettings: (() -> Void)? |
| - public init(model: CallsModel) { self.model = model } |
| + /// `openSettings` is how the server line's Change button reaches the |
| + /// server setting; on a Mac, where Settings is its own scene, it is left |
| + /// nil and the button opens that scene. |
| + public init(model: CallsModel, openSettings: (() -> Void)? = nil, guestSheet: Bool = false) { |
| + self.model = model |
| + self.openSettings = openSettings |
| + _joiningAsGuest = State(initialValue: guestSheet) |
| + } |
| public var body: some View { |
| ScrollView { |
| @@ -39,6 +48,8 @@ import SwiftUI |
| }.buttonStyle(.borderedProminent) |
| } |
| Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered) |
| + } else if !model.pendingLink.isEmpty { |
| + pendingLink |
| } else if model.signedIn { |
| Label("Signed in to \(model.host)", systemImage: "person.crop.circle.badge.checkmark") |
| .font(.headline).textSelection(.enabled) |
| @@ -62,7 +73,12 @@ import SwiftUI |
| // A link from someone else opens here with the same session: |
| // on this instance it joins straight away, and on another it is |
| // that instance's sign-in. |
| - linkField(submit: "Open link", systemImage: "link", prominent: false) |
| + linkField |
| + if model.visiting { |
| + Text("Signed in for this meeting only. Your Calls server is still \(model.serverHost).") |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + } |
| if model.unchecked { |
| Button("Check sign-in again", systemImage: "arrow.clockwise", action: model.recheck) |
| .buttonStyle(.bordered) |
| @@ -74,11 +90,17 @@ import SwiftUI |
| .font(.footnote).foregroundStyle(.secondary) |
| #endif |
| } else { |
| - linkField(submit: "Continue", systemImage: "arrow.right", prominent: true) |
| - Text( |
| - "Use the meeting or invitation link shared with you — or your own Calls address, to start a meeting of your own." |
| - ) |
| - .font(.callout).foregroundStyle(.secondary) |
| + serverLine |
| + Button(action: model.signIn) { |
| + Label("Sign in", systemImage: "person.crop.circle") |
| + .frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| + .accessibilityIdentifier("signIn") |
| + Button(action: { joiningAsGuest = true }) { |
| + Label("Join a call as a guest", systemImage: "link").frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.bordered).accessibilityIdentifier("joinAsGuest") |
| } |
| if !model.error.isEmpty { |
| Label(model.error, systemImage: "exclamationmark.circle.fill") |
| @@ -99,17 +121,60 @@ import SwiftUI |
| } |
| .background(CallsAppearance.background) |
| .tint(CallsAppearance.accent) |
| + .sheet(isPresented: $joiningAsGuest) { |
| + GuestJoinView(model: model) { joiningAsGuest = false } |
| + } |
| + } |
| + |
| + /// Names the server the Sign in button signs in to, and where to change it. |
| + @ViewBuilder private var serverLine: some View { |
| + HStack(alignment: .firstTextBaseline, spacing: 8) { |
| + Label { |
| + Text("Your Calls server is **\(model.serverHost)**.") |
| + .fixedSize(horizontal: false, vertical: true) |
| + } icon: { |
| + Image(systemName: "server.rack") |
| + } |
| + .foregroundStyle(.secondary) |
| + Spacer(minLength: 0) |
| + if let openSettings { |
| + Button("Change", action: openSettings).buttonStyle(.borderless) |
| + .accessibilityLabel("Change Calls server") |
| + } else { |
| + #if os(macOS) |
| + SettingsLink { Text("Change") }.buttonStyle(.borderless) |
| + .accessibilityLabel("Change Calls server") |
| + #endif |
| + } |
| + } |
| + .font(.callout) |
| } |
| - /// The one address field, signed in or not. Signed out it is the way |
| - /// in; signed in it is how a link someone sent is opened without |
| - /// signing in again, so it stays second to the buttons above it. |
| - @ViewBuilder private func linkField( |
| - submit: LocalizedStringKey, systemImage: String, prominent: Bool |
| - ) -> some View { |
| + /// A meeting link that needs a sign-in first: offered, not started, so |
| + /// the person sees which server it is on before a browser opens. |
| + @ViewBuilder private var pendingLink: some View { |
| + Label("A meeting on \(model.pendingHost)", systemImage: "video") |
| + .font(.headline).textSelection(.enabled) |
| + Text( |
| + model.pendingOnServer |
| + ? "Sign in to join it." |
| + : "This meeting is on another Calls server. Sign in there to join it — just for this meeting. Your own server stays \(model.serverHost)." |
| + ) |
| + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) |
| + Button(action: model.signIn) { |
| + Label("Sign in", systemImage: "person.crop.circle").frame(maxWidth: .infinity) |
| + } |
| + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| + .accessibilityIdentifier("signIn") |
| + Button("Not now", action: model.dismissPendingLink).buttonStyle(.bordered) |
| + } |
| + |
| + /// The signed-in home's address field: how a link someone sent is opened |
| + /// without signing in again, so it stays second to the buttons above it. |
| + @ViewBuilder private var linkField: some View { |
| VStack(alignment: .leading, spacing: 10) { |
| Text("Meeting link").font(.headline) |
| - TextField("Meeting link, or your Calls address", text: $model.address) |
| + TextField("Paste a meeting link", text: $model.address) |
| .textFieldStyle(.roundedBorder) |
| .accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink") |
| #if os(iOS) |
| @@ -118,16 +183,9 @@ import SwiftUI |
| .onSubmit(model.connect) |
| } |
| let empty = model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| - // Two branches for the reason startButton has two: buttonStyle's type |
| - // differs between them. Only the way in takes the default action; |
| - // signed in, that belongs to the meeting buttons above. |
| - if prominent { |
| - Button(submit, systemImage: systemImage, action: model.connect) |
| - .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction).disabled(empty) |
| - } else { |
| - Button(submit, systemImage: systemImage, action: model.connect) |
| - .buttonStyle(.bordered).disabled(empty || model.opening) |
| - } |
| + // The default action belongs to the meeting buttons above it. |
| + Button("Open link", systemImage: "link", action: model.connect) |
| + .buttonStyle(.bordered).disabled(empty || model.opening) |
| } |
| // Lifted out of the body because the type checker will not carry this |
| @@ -154,6 +212,7 @@ import SwiftUI |
| private var heading: LocalizedStringKey { |
| if model.restoring { return "Welcome back." } |
| if model.authenticating { return "One more step." } |
| + if !model.pendingLink.isEmpty { return "Join the meeting." } |
| if model.signedIn { return model.room.isEmpty ? "Ready when you are" : "Ready to join" } |
| return "Meet face to face." |
| } |
| @@ -164,6 +223,7 @@ import SwiftUI |
| return |
| "Continue in your browser, then confirm that the code matches. We’ll continue here automatically." |
| } |
| + if !model.pendingLink.isEmpty { return "Someone shared a meeting with you." } |
| if model.signedIn { |
| return model.room.isEmpty |
| ? "Start a meeting and share the link from inside it, or open a link you were sent." |
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift| index 6aa43ea..b0a441f 100644 |
| --- a/native/apple/Sources/CallsKit/CallsModel.swift |
| +++ b/native/apple/Sources/CallsKit/CallsModel.swift |
| @@ -9,7 +9,19 @@ import Foundation |
| #endif |
| @MainActor public final class CallsModel: ObservableObject { |
| + /// The signed-in home's link field: a link someone sent, opened with |
| + /// the session in hand. |
| @Published public var address = "" |
| + /// The Calls server this app signs in to (see CallsServer). Changed only |
| + /// through changeServer, never by a link. |
| + @Published public private(set) var server: String |
| + /// A meeting link waiting on a sign-in: one that arrived through |
| + /// `oficina-calls://join`, on another server or with nobody signed in. |
| + @Published public var pendingLink = "" |
| + /// Signed in for one meeting only — as a guest, or on a server that is |
| + /// not this app's. Such a session is never kept, and leaving the |
| + /// meeting drops it and comes back to the server's own home. |
| + @Published public var visiting = false |
| @Published public var error = "" |
| @Published public var signin: URL? |
| @Published public var authenticating = false |
| @@ -50,6 +62,7 @@ import Foundation |
| var cookies = "" |
| private let store: SessionStore |
| private let remote: SessionRemote |
| + private let defaults: UserDefaults |
| /// Moved on by anything that makes the kept sign-in's answer stale — |
| /// a fresh sign-in, a sign-out — so a check still in flight lands on |
| /// nothing. |
| @@ -59,15 +72,28 @@ import Foundation |
| /// the check says the session is good, rather than asking again. |
| private var openAfterRestore = false |
| - public convenience init(store: SessionStore = KeychainSessionStore()) { |
| - self.init(store: store, remote: .bridge) |
| + public convenience init( |
| + store: SessionStore = KeychainSessionStore(), defaults: UserDefaults = .standard |
| + ) { |
| + self.init(store: store, remote: .bridge, defaults: defaults) |
| } |
| - init(store: SessionStore, remote: SessionRemote) { |
| + init(store: SessionStore, remote: SessionRemote, defaults: UserDefaults = .standard) { |
| self.store = store |
| self.remote = remote |
| + self.defaults = defaults |
| + server = CallsServer.load(from: defaults) |
| } |
| + /// The server's host, for the line that names it. |
| + public var serverHost: String { CallsServer.host(of: server) } |
| + |
| + /// The host a pending link is on. |
| + public var pendingHost: String { CallsServer.host(of: Self.instance(of: pendingLink) ?? "") } |
| + |
| + /// Whether the pending link is on this app's own server. |
| + public var pendingOnServer: Bool { Self.instance(of: pendingLink) == server } |
| + |
| /// The instance's host, for a line that says where this app is signed |
| /// in. The scheme is dropped: it is https for everyone but a developer |
| /// on loopback, and naming it would only make the line longer. |
| @@ -83,49 +109,83 @@ import Foundation |
| guard url.scheme == "oficina-calls", url.host == "join", |
| let components = URLComponents(url: url, resolvingAgainstBaseURL: false), |
| let target = components.queryItems?.first(where: { $0.name == "url" })?.value, |
| - let meeting = URL(string: target), meeting.scheme == "https", |
| - meeting.host != nil, meeting.user == nil, meeting.password == nil, |
| - meeting.query == nil, meeting.fragment == nil |
| + case .success(let meeting) = MeetingLink.validate(target, allowLoopback: false) |
| else { |
| error = "This meeting link is not valid." |
| return |
| } |
| - let path = meeting.path.split(separator: "/") |
| - guard path.count == 2, path[0] == "m" || path[0] == "j" else { |
| - error = "This meeting link is not valid." |
| - return |
| - } |
| // The link launched the app, and the kept sign-in is still being |
| // checked. check() opens it once the answer is in. |
| if restoring { |
| - address = meeting.absoluteString |
| + pendingLink = meeting.absoluteString |
| openAfterRestore = true |
| return |
| } |
| // Already signed in to that instance: the session in hand is the |
| // person's own, so the link opens with it and no browser is involved. |
| if signedIn, Self.instance(of: meeting.absoluteString) == origin { |
| - address = meeting.absoluteString |
| - open(address) |
| + pendingLink = "" |
| + open(meeting.absoluteString) |
| return |
| } |
| - // Another instance, or nobody signed in: that is a sign-in, and the |
| - // kept one is left alone until a new one replaces it. |
| + // Another server, or nobody signed in: that is a sign-in, offered |
| + // rather than started, and whatever session is in hand stays until a |
| + // new one replaces it. A link never changes the server setting. |
| cancelSignIn() |
| - signedIn = false |
| - canStart = false |
| - room = "" |
| error = "" |
| - address = meeting.absoluteString |
| - // Let the person review the meeting address before starting authorization. |
| + pendingLink = meeting.absoluteString |
| + } |
| + |
| + /// The signed-out home's one button: sign in to this app's server, or — |
| + /// when a link is waiting — to the link's server, landing on its meeting. |
| + public func signIn() { |
| + let link = pendingLink |
| + guard !link.isEmpty else { |
| + begin(server, visit: false, retry: "Choose Sign in to try again.") |
| + return |
| + } |
| + begin(link, visit: Self.instance(of: link) != server, retry: "Choose Sign in to try again.") |
| + } |
| + |
| + /// Puts the waiting link down without signing in. |
| + public func dismissPendingLink() { |
| + pendingLink = "" |
| + openAfterRestore = false |
| } |
| + /// Joins a meeting as a guest from a pasted invite link: that meeting's |
| + /// own browser sign-in, on whatever server the link names. A guest's |
| + /// session is for that meeting only — it is not kept, and it does not |
| + /// change the server setting. Nil on success, otherwise what is wrong |
| + /// with the link. |
| + @discardableResult public func joinAsGuest(_ text: String) -> MeetingLink.Problem? { |
| + switch MeetingLink.validate(text) { |
| + case .failure(let problem): return problem |
| + case .success(let link): |
| + pendingLink = "" |
| + begin( |
| + link.absoluteString, visit: true, |
| + retry: "Choose Join a call as a guest to try again.") |
| + return nil |
| + } |
| + } |
| + |
| + /// The signed-in home's link field. A link on the instance in hand opens |
| + /// with its session; anything else is a sign-in for it, kept only when |
| + /// it is on this app's server. |
| public func connect() { |
| let address = self.address.trimmingCharacters(in: .whitespacesAndNewlines) |
| if signedIn, Self.instance(of: address) == origin { |
| open(address) |
| return |
| } |
| + begin(address, visit: Self.instance(of: address) != server, retry: "") |
| + } |
| + |
| + /// The browser handoff for an address: the server itself, or a meeting |
| + /// link on any server. A visit's session is never kept; `retry` is |
| + /// appended to a failure so the error points at the way to try again. |
| + private func begin(_ address: String, visit: Bool, retry: String) { |
| cancelSignIn() |
| sessionEpoch = UUID() |
| restoring = false |
| @@ -165,31 +225,43 @@ import Foundation |
| return result |
| }.value |
| guard epoch == authEpoch else { return } |
| - let data = Data(result.utf8) |
| - let response = try JSONDecoder().decode(SignInResult.self, from: data) |
| - cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! |
| - origin = response.origin |
| - room = response.room |
| - // Offered whether the address was a link or the instance: see |
| - // canStart. keep() takes it back if the server says otherwise. |
| - canStart = true |
| - unchecked = false |
| - signedIn = true |
| - self.address = "" |
| - authenticating = false |
| - signin = nil |
| - authentication = nil |
| + try adopt(result, visit: visit) |
| #if os(macOS) |
| NSApplication.shared.activate(ignoringOtherApps: true) |
| #endif |
| - keep(StoredSession(origin: origin, cookies: cookies)) |
| } catch { |
| guard epoch == authEpoch else { return } |
| - self.error = error.localizedDescription |
| + self.error = [error.localizedDescription, retry].filter { !$0.isEmpty }.joined( |
| + separator: " ") |
| cancelSignIn() |
| } |
| } |
| } |
| + /// Takes on the session a browser sign-in handed back. A visit's — |
| + /// a guest's, or one on a server that is not this app's — is used for |
| + /// its meeting and never kept; the server's own goes to keep(), which |
| + /// keeps it if it is a member's. |
| + func adopt(_ result: String, visit: Bool) throws { |
| + let response = try JSONDecoder().decode(SignInResult.self, from: Data(result.utf8)) |
| + cookies = String(data: try JSONEncoder().encode(response.cookies), encoding: .utf8)! |
| + origin = response.origin |
| + room = response.room |
| + // Offered whether the address was a link or the instance: see |
| + // canStart. keep() takes it back if the server says otherwise. |
| + // A visit is for its one meeting, so it starts nothing. |
| + let visiting = visit || response.origin != server |
| + canStart = !visiting |
| + unchecked = false |
| + signedIn = true |
| + self.visiting = visiting |
| + address = "" |
| + pendingLink = "" |
| + authenticating = false |
| + signin = nil |
| + authentication = nil |
| + if !visiting { keep(StoredSession(origin: origin, cookies: cookies)) } |
| + } |
| + |
| public func openBrowser(_ url: URL) async -> Bool { |
| #if os(iOS) |
| return await UIApplication.shared.open(url) |
| @@ -273,6 +345,19 @@ import Foundation |
| openAfterRestore = false |
| return |
| } |
| + // Kept for a server that is no longer this app's — the setting was |
| + // changed outside the app. It is signed out, as changing it in |
| + // Settings would have done. |
| + guard kept.origin == server else { |
| + restoring = false |
| + openAfterRestore = false |
| + let remote = self.remote |
| + Task.detached { |
| + store.clear() |
| + remote.signOut(kept.origin, kept.cookies) |
| + } |
| + return |
| + } |
| origin = kept.origin |
| cookies = kept.cookies |
| check(kept, epoch: epoch) |
| @@ -297,18 +382,22 @@ import Foundation |
| switch answer { |
| case .success(SessionRemote.member): |
| signedIn = true |
| + visiting = false |
| unchecked = false |
| canStart = true |
| error = "" |
| - if openAfterRestore, Self.instance(of: address) == origin { open(address) } |
| + if openAfterRestore, Self.instance(of: pendingLink) == origin { |
| + let link = pendingLink |
| + pendingLink = "" |
| + open(link) |
| + } |
| case .success(SessionRemote.signedOut): |
| // The instance says the session has gone: thirty days passed, or |
| - // it was signed out elsewhere. Forget it, and leave the instance |
| - // in the field so signing in again is one button. |
| + // it was signed out elsewhere. Forget it; signing in again is the |
| + // home's one button. |
| let host = self.host |
| forget() |
| - if !openAfterRestore { address = kept.origin } |
| - error = "Your sign-in to \(host) has ended. Sign in again to continue." |
| + error = "Your sign-in to \(host) has ended. Choose Sign in to continue." |
| case .success: |
| // Live, but no longer a member's: only a member's session is |
| // kept, so this one is not either. |
| @@ -317,9 +406,17 @@ import Foundation |
| // Not an answer about the session. Keep it and use it; every |
| // request it makes is gated on the server regardless. |
| signedIn = true |
| + visiting = false |
| unchecked = true |
| canStart = true |
| error = "Could not check your sign-in to \(host): \(failure.localizedDescription)" |
| + // The link that launched the app is still tried with it: open() |
| + // falls back to a browser sign-in if the server says it has ended. |
| + if openAfterRestore, Self.instance(of: pendingLink) == origin { |
| + let link = pendingLink |
| + pendingLink = "" |
| + open(link) |
| + } |
| } |
| openAfterRestore = false |
| } |
| @@ -341,6 +438,9 @@ import Foundation |
| guard epoch == sessionEpoch, cookies == session.cookies else { return } |
| switch answer { |
| case SessionRemote.member?: |
| + // Only this app's server's session is kept: the Keychain holds |
| + // one sign-in, and it is the server's. |
| + guard session.origin == server else { return } |
| await Task.detached { store.save(session) }.value |
| case SessionRemote.participant?: |
| canStart = false |
| @@ -354,8 +454,15 @@ import Foundation |
| /// instance is asked to revoke the session so it is dead and not just |
| /// dropped. If the instance cannot be reached the session lapses at |
| /// its thirty days, and nothing on this device can use it before then. |
| + /// |
| + /// Signing out of a visit ends only the visit: the server's own kept |
| + /// sign-in, if there is one, is untouched and comes back. |
| public func signOut() { |
| guard !joining else { return } |
| + if visiting { |
| + endVisit() |
| + return |
| + } |
| let origin = self.origin, cookies = self.cookies |
| let remote = self.remote |
| forget() |
| @@ -365,17 +472,72 @@ import Foundation |
| Task.detached { remote.signOut(origin, cookies) } |
| } |
| - /// Everything a sign-in left behind, in memory and in the Keychain. |
| - private func forget() { |
| + /// Drops a visit's session — revoked, as a sign-out would — and goes |
| + /// back to the server's home: its kept sign-in if there is one, |
| + /// otherwise signed out. |
| + private func endVisit() { |
| + let origin = self.origin, cookies = self.cookies |
| + let remote = self.remote |
| + forget(keeping: true) |
| + address = "" |
| + if !origin.isEmpty, !cookies.isEmpty { |
| + Task.detached { remote.signOut(origin, cookies) } |
| + } |
| + restore() |
| + } |
| + |
| + /// Whether changing the server would sign this app out. |
| + public var changingServerSignsOut: Bool { signedIn || restoring } |
| + |
| + /// Moves the app to another Calls server. Nil on success, otherwise |
| + /// what is wrong with the address. |
| + /// |
| + /// A session for the old server is signed out, as Sign out does: the |
| + /// Keychain copy forgotten, the instance asked to revoke it. The person |
| + /// has confirmed that before this is called (changingServerSignsOut). |
| + @discardableResult public func changeServer(to text: String) -> CallsServer.Problem? { |
| + let origin: String |
| + switch CallsServer.validate(text) { |
| + case .failure(let problem): return problem |
| + case .success(let valid): origin = valid |
| + } |
| + guard !joining else { |
| + error = "Leave the current meeting before changing the Calls server." |
| + return nil |
| + } |
| + CallsServer.save(origin, to: defaults) |
| + guard origin != server else { return nil } |
| + server = origin |
| + let held = cookies.isEmpty ? nil : StoredSession(origin: self.origin, cookies: cookies) |
| + let store = self.store, remote = self.remote |
| + forget(keeping: true) |
| + address = "" |
| + error = "" |
| + Task.detached { |
| + let kept = store.load() |
| + store.clear() |
| + for session in Set([kept, held].compactMap { $0 }.map { [$0.origin, $0.cookies] }) { |
| + remote.signOut(session[0], session[1]) |
| + } |
| + } |
| + return nil |
| + } |
| + |
| + /// Everything a sign-in left behind, in memory and — unless `keeping`, |
| + /// for a visit whose session was never kept — in the Keychain. |
| + private func forget(keeping: Bool = false) { |
| sessionEpoch = UUID() |
| callEpoch = UUID() |
| cancelSignIn() |
| - let store = self.store |
| - Task.detached { store.clear() } |
| + if !keeping { |
| + let store = self.store |
| + Task.detached { store.clear() } |
| + } |
| cookies = "" |
| origin = "" |
| room = "" |
| signedIn = false |
| + visiting = false |
| canStart = false |
| unchecked = false |
| restoring = false |
| @@ -402,9 +564,9 @@ import Foundation |
| opening = false |
| let link = try JSONDecoder().decode(OpenedLink.self, from: Data(answer.utf8)) |
| if link.signedOut == true { |
| - forget() |
| - self.address = address |
| - connect() |
| + let visit = visiting |
| + forget(keeping: visit) |
| + begin(address, visit: visit || Self.instance(of: address) != server, retry: "") |
| return |
| } |
| self.address = "" |
| @@ -480,6 +642,9 @@ import Foundation |
| await call.leave() |
| guard operation == callEpoch else { return } |
| joining = false |
| + // A guest, or a visitor from another server, was here for that one |
| + // meeting: back to the server's home, signed in or not. |
| + if visiting { endVisit() } |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsServer.swift b/native/apple/Sources/CallsKit/CallsServer.swift| new file mode 100644 |
| index 0000000..44f2d8b |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CallsServer.swift |
| @@ -0,0 +1,129 @@ |
| +import Foundation |
| + |
| +/// The Calls server this app signs in to: calls.oficina.works unless the |
| +/// person has chosen another in Settings. Kept in UserDefaults, because |
| +/// it is a preference and not a secret; the session for it stays in the |
| +/// Keychain, keyed to its own origin. |
| +/// |
| +/// `-CallsServer <origin>` on the command line overrides it for one run |
| +/// (UserDefaults' argument domain), which is how the UI tests point the |
| +/// app at their own server without touching the saved setting. |
| +public enum CallsServer { |
| + public static let defaultOrigin = "https://calls.oficina.works" |
| + public static let key = "CallsServer" |
| + |
| + /// What is wrong with an address, in words a person can act on. |
| + public enum Problem: Error, Equatable { |
| + case empty, notAnAddress, insecure, notJustTheServer, credentials |
| + |
| + public var message: String { |
| + switch self { |
| + case .empty: return "Enter your Calls server’s address." |
| + case .notAnAddress: |
| + return "That isn’t a web address. It should look like https://calls.example.com." |
| + case .insecure: |
| + return "The address must start with https://. Plain http:// works only for this device, for development." |
| + case .notJustTheServer: |
| + return "Enter just the server’s address, without a page or a meeting after it." |
| + case .credentials: |
| + return "Leave out any name or password. You sign in in your browser." |
| + } |
| + } |
| + } |
| + |
| + /// The server address as the app stores and compares it — scheme, host |
| + /// and any port, the way the server's own origin is written — or what |
| + /// is wrong with it. Whitespace and trailing slashes are forgiven, and |
| + /// an address with no scheme is taken to be https. |
| + /// |
| + /// https only, except http on loopback: the same rule callscore's |
| + /// transport enforces, so nothing is accepted here that it would refuse. |
| + public static func validate(_ text: String) -> Result<String, Problem> { |
| + var text = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| + while text.hasSuffix("/"), !text.hasSuffix("://") { text.removeLast() } |
| + if text.isEmpty { return .failure(.empty) } |
| + if !text.contains("://") { text = "https://" + text } |
| + guard let url = URLComponents(string: text), let scheme = url.scheme?.lowercased(), |
| + let host = url.host?.lowercased(), !host.isEmpty, !host.contains(" ") |
| + else { return .failure(.notAnAddress) } |
| + if url.user != nil || url.password != nil { return .failure(.credentials) } |
| + if !url.path.isEmpty || url.query != nil || url.fragment != nil { |
| + return .failure(.notJustTheServer) |
| + } |
| + guard scheme == "https" || (scheme == "http" && isLoopback(host)) else { |
| + return .failure(scheme == "http" ? .insecure : .notAnAddress) |
| + } |
| + let written = host.contains(":") ? "[\(host)]" : host |
| + return .success("\(scheme)://\(written)" + (url.port.map { ":\($0)" } ?? "")) |
| + } |
| + |
| + static func isLoopback(_ host: String) -> Bool { |
| + ["localhost", "127.0.0.1", "::1"].contains(host) |
| + } |
| + |
| + /// The saved server, or the default when nothing valid is saved. |
| + public static func load(from defaults: UserDefaults) -> String { |
| + guard let saved = defaults.string(forKey: key), case .success(let origin) = validate(saved) |
| + else { return defaultOrigin } |
| + return origin |
| + } |
| + |
| + /// Saves a validated origin. The default is saved as no setting at all, |
| + /// so a future change of default reaches everyone who never chose. |
| + static func save(_ origin: String, to defaults: UserDefaults) { |
| + if origin == defaultOrigin { |
| + defaults.removeObject(forKey: key) |
| + } else { |
| + defaults.set(origin, forKey: key) |
| + } |
| + } |
| + |
| + /// The host alone, for a line that names the server. |
| + public static func host(of origin: String) -> String { |
| + URLComponents(string: origin)?.host ?? origin |
| + } |
| +} |
| + |
| +/// A meeting or invitation link — `/m/{sqid}` or `/j/{token}` on some |
| +/// Calls server — checked before anything is done with it. |
| +public enum MeetingLink { |
| + public enum Problem: Error, Equatable { |
| + case empty, notALink, insecure, credentials, notACall |
| + |
| + public var message: String { |
| + switch self { |
| + case .empty: return "Paste the invite link you were sent." |
| + case .notALink: |
| + return "That isn’t a link. Paste the whole invite link, starting with https://." |
| + case .insecure: return "Calls invite links start with https://." |
| + case .credentials: |
| + return "That link has a name or password in it, which a Calls invite never does." |
| + case .notACall: |
| + return "That isn’t a Calls invite link. Invite links end in /j/… or /m/… ." |
| + } |
| + } |
| + } |
| + |
| + /// The link, or what is wrong with it. `allowLoopback` admits plain |
| + /// http to this device (a development server), as callscore does; a |
| + /// link arriving from outside the app through `oficina-calls://` never |
| + /// gets that allowance. |
| + public static func validate(_ text: String, allowLoopback: Bool = true) -> Result<URL, Problem> { |
| + var text = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| + if text.isEmpty { return .failure(.empty) } |
| + // A link copied without its scheme — "calls.example.com/j/…" — is |
| + // taken to be https, as a browser would. |
| + if !text.contains("://"), !text.contains(" ") { text = "https://" + text } |
| + guard let url = URL(string: text), let scheme = url.scheme?.lowercased(), |
| + let host = url.host, !host.isEmpty |
| + else { return .failure(.notALink) } |
| + guard scheme == "https" |
| + || (scheme == "http" && allowLoopback && CallsServer.isLoopback(host.lowercased())) |
| + else { return .failure(scheme == "http" ? .insecure : .notALink) } |
| + if url.user != nil || url.password != nil { return .failure(.credentials) } |
| + let path = url.path.split(separator: "/") |
| + guard url.query == nil, url.fragment == nil, path.count == 2, path[0] == "m" || path[0] == "j" |
| + else { return .failure(.notACall) } |
| + return .success(url) |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallsSettingsView.swift b/native/apple/Sources/CallsKit/CallsSettingsView.swift| index c7bfa51..ec58921 100644 |
| --- a/native/apple/Sources/CallsKit/CallsSettingsView.swift |
| +++ b/native/apple/Sources/CallsKit/CallsSettingsView.swift |
| @@ -1,15 +1,16 @@ |
| #if os(macOS) |
| import SwiftUI |
| - /// Calls → Settings…. One section for now, because there is one thing |
| - /// worth choosing; the next preference belongs beside it, not in a tab. |
| + /// Calls → Settings…. Sections rather than tabs: there are two things |
| + /// worth choosing, and the next preference belongs beside them. |
| public struct CallsSettingsView: View { |
| + @ObservedObject private var model: CallsModel |
| @AppStorage(MiniWindowPreferences.enabledKey) private var miniWindow = |
| MiniWindowPreferences.defaultEnabled |
| @AppStorage(MiniWindowPreferences.cornerKey) private var corner = |
| MiniWindowPreferences.defaultCorner |
| - public init() {} |
| + public init(model: CallsModel) { self.model = model } |
| public var body: some View { |
| Form { |
| @@ -32,6 +33,7 @@ |
| } header: { |
| Text("During a call") |
| } |
| + ServerSettingsSection(model: model) |
| } |
| .formStyle(.grouped) |
| .frame(width: 480) |
diff --git a/native/apple/Sources/CallsKit/GuestJoinView.swift b/native/apple/Sources/CallsKit/GuestJoinView.swift| new file mode 100644 |
| index 0000000..b269b38 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/GuestJoinView.swift |
| @@ -0,0 +1,114 @@ |
| +import SwiftUI |
| + |
| +/// "Join a call as a guest": paste the invite link you were sent, and |
| +/// that meeting's own browser sign-in runs for it, on whatever server the |
| +/// link names. Nothing about it is kept. |
| +/// |
| +/// The clipboard is read only through the Paste control, so iPhone never |
| +/// asks whether Calls may paste, and nothing is read that the person did |
| +/// not hand over. |
| +@MainActor public struct GuestJoinView: View { |
| + @ObservedObject private var model: CallsModel |
| + private let done: () -> Void |
| + @State private var link: String |
| + @State private var problem: String = "" |
| + |
| + /// `problem` shows the link's validation at once — for snapshots of |
| + /// the inline error. |
| + public init( |
| + model: CallsModel, link: String = "", problem: Bool = false, done: @escaping () -> Void |
| + ) { |
| + self.model = model |
| + self.done = done |
| + _link = State(initialValue: link) |
| + var message = "" |
| + if problem, case .failure(let failure) = MeetingLink.validate(link) { |
| + message = failure.message |
| + } |
| + _problem = State(initialValue: message) |
| + } |
| + |
| + public var body: some View { |
| + NavigationStack { |
| + Form { |
| + Section { |
| + TextField("Invite link", text: $link, prompt: Text("https://…/j/…")) |
| + .accessibilityIdentifier("inviteLink") |
| + #if os(iOS) |
| + .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() |
| + #endif |
| + .onSubmit(join) |
| + .onChange(of: link) { problem = "" } |
| + PasteButton(payloadType: String.self) { strings in |
| + guard let text = strings.first else { return } |
| + Task { @MainActor in paste(text) } |
| + } |
| + .accessibilityIdentifier("pasteInvite") |
| + if !problem.isEmpty { |
| + Label(problem, systemImage: "exclamationmark.circle.fill") |
| + .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) |
| + .accessibilityLabel("Error: \(problem)") |
| + } |
| + } header: { |
| + Text("Invite link") |
| + } footer: { |
| + Text( |
| + "Paste the link someone sent you. Your browser opens to confirm who you are for this meeting; nothing is saved on this device, and your Calls server stays \(model.serverHost)." |
| + ) |
| + } |
| + } |
| + .formStyle(.grouped) |
| + #if os(iOS) |
| + .navigationTitle("Join as a guest") |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .cancellationAction) { Button("Cancel", action: done) } |
| + ToolbarItem(placement: .confirmationAction) { joinButton } |
| + } |
| + #else |
| + // A Mac sheet has no title bar to carry these, so they sit under |
| + // the form where a Mac dialog keeps its buttons. |
| + .safeAreaInset(edge: .top) { |
| + Text("Join as a guest").font(.title2.bold()) |
| + .frame(maxWidth: .infinity, alignment: .leading).padding([.horizontal, .top], 20) |
| + } |
| + .safeAreaInset(edge: .bottom) { |
| + HStack { |
| + Spacer() |
| + Button("Cancel", action: done).keyboardShortcut(.cancelAction) |
| + joinButton.keyboardShortcut(.defaultAction) |
| + }.padding([.horizontal, .bottom], 20) |
| + } |
| + #endif |
| + } |
| + #if os(macOS) |
| + .frame(width: 460) |
| + .fixedSize(horizontal: false, vertical: true) |
| + #endif |
| + .tint(CallsAppearance.accent) |
| + } |
| + |
| + private var joinButton: some View { |
| + Button("Join", action: join).accessibilityIdentifier("guestJoin") |
| + .disabled(link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| + } |
| + |
| + /// Fills the field only with a Calls link; anything else on the |
| + /// clipboard is left there, and said so. |
| + private func paste(_ text: String) { |
| + if case .success(let url) = MeetingLink.validate(text) { |
| + link = url.absoluteString |
| + problem = "" |
| + } else { |
| + problem = "The clipboard doesn’t hold a Calls invite link." |
| + } |
| + } |
| + |
| + private func join() { |
| + if let failure = model.joinAsGuest(link) { |
| + problem = failure.message |
| + } else { |
| + done() |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/ServerSettings.swift b/native/apple/Sources/CallsKit/ServerSettings.swift| new file mode 100644 |
| index 0000000..b105cee |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/ServerSettings.swift |
| @@ -0,0 +1,122 @@ |
| +import SwiftUI |
| + |
| +/// The Calls server setting, as one Form section: in Calls → Settings… |
| +/// on a Mac and in the home screen's settings sheet on iPhone. |
| +/// |
| +/// The field is a draft until Use this server; changing the server while |
| +/// signed in asks first, because it signs out. |
| +@MainActor public struct ServerSettingsSection: View { |
| + @ObservedObject private var model: CallsModel |
| + @State private var draft: String |
| + @State private var problem = "" |
| + @State private var confirming: String? |
| + |
| + public init(model: CallsModel) { |
| + self.model = model |
| + _draft = State(initialValue: model.server) |
| + } |
| + |
| + private var changed: Bool { |
| + guard case .success(let origin) = CallsServer.validate(draft) else { return true } |
| + return origin != model.server |
| + } |
| + |
| + public var body: some View { |
| + Section { |
| + TextField("Server address", text: $draft, prompt: Text(CallsServer.defaultOrigin)) |
| + .accessibilityIdentifier("serverAddress") |
| + #if os(iOS) |
| + .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() |
| + #endif |
| + .onSubmit(apply) |
| + .onChange(of: draft) { problem = "" } |
| + if !problem.isEmpty { |
| + Label(problem, systemImage: "exclamationmark.circle.fill") |
| + .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) |
| + .accessibilityLabel("Error: \(problem)") |
| + } |
| + HStack { |
| + Button("Use this server", action: apply) |
| + .disabled(!changed || model.joining).accessibilityIdentifier("useServer") |
| + Spacer() |
| + Button("Reset to \(CallsServer.host(of: CallsServer.defaultOrigin))") { |
| + draft = CallsServer.defaultOrigin |
| + apply() |
| + } |
| + .disabled(model.server == CallsServer.defaultOrigin || model.joining) |
| + } |
| + #if os(iOS) |
| + .buttonStyle(.borderless) |
| + #endif |
| + } header: { |
| + Text("Calls server") |
| + } footer: { |
| + Text( |
| + model.joining |
| + ? "Leave the current meeting to change the server." |
| + : "Sign in goes to this server. If you run your own Calls, enter its address here. Links to meetings on other servers still open, just for that meeting." |
| + ) |
| + } |
| + .confirmationDialog( |
| + "Change server and sign out?", isPresented: Binding( |
| + get: { confirming != nil }, set: { if !$0 { confirming = nil } }), |
| + titleVisibility: .visible |
| + ) { |
| + Button("Change and sign out", role: .destructive) { |
| + if let origin = confirming { model.changeServer(to: origin) } |
| + confirming = nil |
| + } |
| + Button("Cancel", role: .cancel) { |
| + confirming = nil |
| + draft = model.server |
| + } |
| + } message: { |
| + Text( |
| + "You’re signed in to \(model.host). Changing the server signs you out there; you can sign in to \(CallsServer.host(of: confirming ?? "")) next." |
| + ) |
| + } |
| + .onChange(of: model.server) { draft = model.server } |
| + } |
| + |
| + private func apply() { |
| + switch CallsServer.validate(draft) { |
| + case .failure(let failure): |
| + problem = failure.message |
| + case .success(let origin): |
| + draft = origin |
| + guard origin != model.server else { return } |
| + if model.changingServerSignsOut { |
| + confirming = origin |
| + } else { |
| + model.changeServer(to: origin) |
| + } |
| + } |
| + } |
| +} |
| + |
| +/// iPhone's settings: there is no Settings scene, so the home screen's |
| +/// gear opens this. |
| +@MainActor public struct ServerSettingsSheet: View { |
| + @ObservedObject private var model: CallsModel |
| + private let done: () -> Void |
| + |
| + public init(model: CallsModel, done: @escaping () -> Void) { |
| + self.model = model |
| + self.done = done |
| + } |
| + |
| + public var body: some View { |
| + NavigationStack { |
| + Form { ServerSettingsSection(model: model) } |
| + .formStyle(.grouped) |
| + .navigationTitle("Settings") |
| + #if os(iOS) |
| + .navigationBarTitleDisplayMode(.inline) |
| + #endif |
| + .toolbar { |
| + ToolbarItem(placement: .confirmationAction) { Button("Done", action: done) } |
| + } |
| + } |
| + .tint(CallsAppearance.accent) |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 4ca4e22..250a39e 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -28,7 +28,7 @@ import LiveKitWebRTC |
| CommandGroup(replacing: .newItem) {} |
| NativeUpdateCommands(updater: updater) |
| } |
| - Settings { CallsSettingsView() } |
| + Settings { CallsSettingsView(model: model) } |
| } |
| } |
diff --git a/native/apple/Tests/CallsKitTests/DefaultServerTests.swift b/native/apple/Tests/CallsKitTests/DefaultServerTests.swift| new file mode 100644 |
| index 0000000..93cd670 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/DefaultServerTests.swift |
| @@ -0,0 +1,288 @@ |
| +import Foundation |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +private let other = "https://calls.example.org" |
| +private let kept = StoredSession( |
| + origin: CallsServer.defaultOrigin, |
| + cookies: #"[{"name":"__Host-rastrillo_session","value":"kept"}]"#) |
| + |
| +/// A defaults suite of its own per test, so nothing here reads or writes |
| +/// the setting of whoever runs the tests. |
| +private func freshDefaults() -> UserDefaults { |
| + let name = "CallsKitTests.\(UUID().uuidString)" |
| + let defaults = UserDefaults(suiteName: name)! |
| + defaults.removePersistentDomain(forName: name) |
| + return defaults |
| +} |
| + |
| +@MainActor private func settle(_ done: @MainActor () -> Bool) async { |
| + let deadline = Date().addingTimeInterval(5) |
| + while !done(), Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| +} |
| + |
| +/// What a browser sign-in hands back, in the bridge's shape. |
| +private func signInResult(origin: String, room: String = "") -> String { |
| + #"{"origin":"\#(origin)","room":"\#(room)","cookies":[{"name":"__Host-rastrillo_session","value":"fresh"}]}"# |
| +} |
| + |
| +final class DefaultServerTests: XCTestCase { |
| + // MARK: The setting |
| + |
| + @MainActor func testTheServerIsCallsOficinaWorksUntilChosen() { |
| + let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) |
| + XCTAssertEqual(CallsServer.defaultOrigin, "https://calls.oficina.works") |
| + XCTAssertEqual(model.server, "https://calls.oficina.works") |
| + XCTAssertEqual(model.serverHost, "calls.oficina.works") |
| + } |
| + |
| + func testServerAddressesAreValidatedAndWrittenAsAnOrigin() { |
| + let accepted: [(String, String)] = [ |
| + ("https://calls.oficina.works", "https://calls.oficina.works"), |
| + (" https://calls.example.org/ ", "https://calls.example.org"), |
| + ("https://calls.example.org//", "https://calls.example.org"), |
| + ("HTTPS://Calls.Example.org", "https://calls.example.org"), |
| + ("calls.example.org", "https://calls.example.org"), |
| + ("https://calls.example.org:8443", "https://calls.example.org:8443"), |
| + ("http://localhost:8080", "http://localhost:8080"), |
| + ("http://127.0.0.1:8080/", "http://127.0.0.1:8080"), |
| + ] |
| + for (text, origin) in accepted { |
| + XCTAssertEqual(try? CallsServer.validate(text).get(), origin, text) |
| + } |
| + let refused: [(String, CallsServer.Problem)] = [ |
| + ("", .empty), |
| + (" ", .empty), |
| + ("http://calls.example.org", .insecure), |
| + ("ftp://calls.example.org", .notAnAddress), |
| + ("https://", .notAnAddress), |
| + ("not an address", .notAnAddress), |
| + ("https://calls.example.org/m/abc123", .notJustTheServer), |
| + ("https://calls.example.org/?next=1", .notJustTheServer), |
| + ("https://calls.example.org#top", .notJustTheServer), |
| + ("https://paul:secret@calls.example.org", .credentials), |
| + ] |
| + for (text, problem) in refused { |
| + XCTAssertEqual(CallsServer.validate(text), .failure(problem), text) |
| + XCTAssertFalse(problem.message.isEmpty) |
| + } |
| + } |
| + |
| + func testTheSavedServerIsReadBackAndTheDefaultIsSavedAsNothing() { |
| + let defaults = freshDefaults() |
| + CallsServer.save(other, to: defaults) |
| + XCTAssertEqual(CallsServer.load(from: defaults), other) |
| + CallsServer.save(CallsServer.defaultOrigin, to: defaults) |
| + XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| + defaults.set("http://calls.example.org", forKey: CallsServer.key) |
| + XCTAssertEqual( |
| + CallsServer.load(from: defaults), CallsServer.defaultOrigin, |
| + "an invalid saved value falls back to the default") |
| + } |
| + |
| + // MARK: The signed-out home |
| + |
| + @MainActor func testSignedOutHomeIsTheServerAndNothingToType() async { |
| + let server = ScriptedRemote() |
| + let model = CallsModel( |
| + store: MemorySessionStore(), remote: server.remote, defaults: freshDefaults()) |
| + model.restore() |
| + await settle { !model.restoring } |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertFalse(model.authenticating) |
| + XCTAssertEqual(model.pendingLink, "") |
| + XCTAssertEqual(model.address, "") |
| + XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| + XCTAssertTrue(server.requests.isEmpty) |
| + } |
| + |
| + @MainActor func testALinkOnTheServerIsASignInForTheServer() { |
| + let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) |
| + model.openMeetingURL( |
| + URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.oficina.works%2Fj%2Finvitation")!) |
| + XCTAssertEqual(model.pendingLink, "https://calls.oficina.works/j/invitation") |
| + XCTAssertTrue(model.pendingOnServer) |
| + XCTAssertEqual(model.pendingHost, "calls.oficina.works") |
| + XCTAssertFalse(model.authenticating, "the person sees where it is before a browser opens") |
| + } |
| + |
| + // MARK: Changing the server |
| + |
| + @MainActor func testChangingTheServerSignsOutOfTheOldOne() async { |
| + let defaults = freshDefaults() |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| + model.restore() |
| + await settle { model.signedIn } |
| + XCTAssertTrue(model.changingServerSignsOut, "the settings ask first") |
| + XCTAssertNil(model.changeServer(to: " calls.example.org/ ")) |
| + XCTAssertEqual(model.server, other) |
| + XCTAssertEqual(defaults.string(forKey: CallsServer.key), other) |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertEqual(model.origin, "") |
| + await settle { store.session == nil && server.requests.count == 2 } |
| + XCTAssertNil(store.session, "the Keychain copy is forgotten") |
| + XCTAssertEqual( |
| + server.requests.last, "signout \(kept.origin) \(kept.cookies)", |
| + "and the old server is asked to revoke it") |
| + // A new model — the next launch — reads the new server. |
| + XCTAssertEqual(CallsModel(store: store, defaults: defaults).server, other) |
| + } |
| + |
| + @MainActor func testAnInvalidServerChangesNothing() async { |
| + let defaults = freshDefaults() |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| + model.restore() |
| + await settle { model.signedIn } |
| + XCTAssertEqual(model.changeServer(to: "http://calls.example.org"), .insecure) |
| + XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertEqual(store.session, kept) |
| + XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| + } |
| + |
| + @MainActor func testTheServerIsNotChangedDuringACall() { |
| + let defaults = freshDefaults() |
| + let store = MemorySessionStore(kept) |
| + let model = CallsModel(store: store, remote: ScriptedRemote().remote, defaults: defaults) |
| + model.signedIn = true |
| + model.joining = true |
| + XCTAssertNil(model.changeServer(to: other)) |
| + XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertEqual(store.session, kept) |
| + XCTAssertFalse(model.error.isEmpty) |
| + } |
| + |
| + @MainActor func testResettingToTheDefaultRemovesTheSetting() { |
| + let defaults = freshDefaults() |
| + CallsServer.save(other, to: defaults) |
| + let model = CallsModel(store: MemorySessionStore(), defaults: defaults) |
| + XCTAssertEqual(model.server, other) |
| + XCTAssertFalse(model.changingServerSignsOut) |
| + XCTAssertNil(model.changeServer(to: CallsServer.defaultOrigin)) |
| + XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| + XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| + } |
| + |
| + @MainActor func testASessionKeptForAnotherServerIsSignedOutAtLaunch() async { |
| + // The setting was changed outside the app, leaving the Keychain |
| + // holding the old server's session. |
| + let defaults = freshDefaults() |
| + CallsServer.save(other, to: defaults) |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote() |
| + let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| + model.restore() |
| + await settle { !model.restoring && store.session == nil && !server.requests.isEmpty } |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertNil(store.session) |
| + XCTAssertEqual(server.requests, ["signout \(kept.origin) \(kept.cookies)"]) |
| + } |
| + |
| + // MARK: Guests and other servers |
| + |
| + func testInviteLinksAreValidatedInPlainWords() { |
| + for text in [ |
| + "https://calls.oficina.works/j/abcdef", "https://calls.example.org/m/abc123", |
| + " https://calls.example.org/m/abc123/ ", "calls.example.org/j/token", |
| + "http://127.0.0.1:8080/j/token", |
| + ] { |
| + XCTAssertNotNil(try? MeetingLink.validate(text).get(), text) |
| + } |
| + let refused: [(String, MeetingLink.Problem)] = [ |
| + ("", .empty), |
| + ("not a meeting", .notALink), |
| + ("file:///j/token", .notALink), |
| + ("http://calls.example.org/j/token", .insecure), |
| + ("https://paul:secret@calls.example.org/j/token", .credentials), |
| + ("https://calls.example.org", .notACall), |
| + ("https://calls.example.org/settings", .notACall), |
| + ("https://calls.example.org/j/token?x=1", .notACall), |
| + ("https://calls.example.org/x/token", .notACall), |
| + ] |
| + for (text, problem) in refused { |
| + XCTAssertEqual(MeetingLink.validate(text), .failure(problem), text) |
| + XCTAssertFalse(problem.message.isEmpty) |
| + } |
| + XCTAssertEqual( |
| + MeetingLink.validate("http://127.0.0.1:8080/j/token", allowLoopback: false), |
| + .failure(.insecure), "a link from outside the app is https only") |
| + } |
| + |
| + @MainActor func testAnInvalidGuestLinkStartsNothing() { |
| + let model = CallsModel(store: MemorySessionStore(), defaults: freshDefaults()) |
| + XCTAssertEqual(model.joinAsGuest("https://calls.example.org/settings"), .notACall) |
| + XCTAssertFalse(model.authenticating) |
| + XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| + } |
| + |
| + @MainActor func testAGuestSessionIsNeverKeptEvenAMembersOnTheServer() async throws { |
| + let store = MemorySessionStore() |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| + try model.adopt(signInResult(origin: CallsServer.defaultOrigin, room: "abc123"), visit: true) |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertTrue(model.visiting) |
| + XCTAssertFalse(model.canStart, "a guest is here for one meeting") |
| + XCTAssertEqual(model.room, "abc123") |
| + try? await Task.sleep(for: .milliseconds(100)) |
| + XCTAssertNil(store.session) |
| + XCTAssertTrue(server.requests.isEmpty, "not even asked whether it is worth keeping") |
| + } |
| + |
| + @MainActor func testASignInOnAnotherServerIsAVisitAndChangesNoSetting() async throws { |
| + let defaults = freshDefaults() |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote, defaults: defaults) |
| + try model.adopt(signInResult(origin: other, room: "abc123"), visit: false) |
| + XCTAssertTrue(model.visiting, "another server is a visit however it began") |
| + try? await Task.sleep(for: .milliseconds(100)) |
| + XCTAssertEqual(store.session, kept, "the server's own sign-in is not replaced") |
| + XCTAssertEqual(model.server, CallsServer.defaultOrigin) |
| + XCTAssertNil(defaults.object(forKey: CallsServer.key)) |
| + } |
| + |
| + @MainActor func testTheServersOwnSignInIsKept() async throws { |
| + let store = MemorySessionStore() |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| + try model.adopt(signInResult(origin: CallsServer.defaultOrigin), visit: false) |
| + XCTAssertFalse(model.visiting) |
| + XCTAssertTrue(model.canStart) |
| + await settle { store.session != nil } |
| + XCTAssertEqual(store.session?.origin, CallsServer.defaultOrigin) |
| + } |
| + |
| + @MainActor func testEndingAVisitComesBackToTheMemberHome() async throws { |
| + let store = MemorySessionStore(kept) |
| + let server = ScriptedRemote(checks: [.success("member")]) |
| + let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| + try model.adopt(signInResult(origin: other, room: "abc123"), visit: true) |
| + model.signOut() |
| + await settle { model.signedIn && !model.restoring } |
| + XCTAssertFalse(model.visiting) |
| + XCTAssertEqual(model.origin, CallsServer.defaultOrigin, "the kept sign-in is back") |
| + XCTAssertEqual(store.session, kept) |
| + XCTAssertTrue(server.requests.contains { $0.hasPrefix("signout \(other) ") }) |
| + XCTAssertFalse(server.requests.contains { $0.hasPrefix("signout \(kept.origin)") }) |
| + } |
| + |
| + @MainActor func testEndingAVisitWithNothingKeptIsTheSignedOutHome() async throws { |
| + let store = MemorySessionStore() |
| + let server = ScriptedRemote() |
| + let model = CallsModel(store: store, remote: server.remote, defaults: freshDefaults()) |
| + try model.adopt(signInResult(origin: other, room: "abc123"), visit: true) |
| + model.leave() |
| + await settle { !model.signedIn && !model.restoring } |
| + XCTAssertFalse(model.signedIn) |
| + XCTAssertFalse(model.visiting) |
| + XCTAssertEqual(model.room, "") |
| + XCTAssertNil(store.session) |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/KeptSignInTests.swift b/native/apple/Tests/CallsKitTests/KeptSignInTests.swift| index 7d7c0ff..efffd2a 100644 |
| --- a/native/apple/Tests/CallsKitTests/KeptSignInTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/KeptSignInTests.swift |
| @@ -91,7 +91,7 @@ final class KeptSignInTests: XCTestCase { |
| XCTAssertEqual(server.requests, ["check \(kept.origin) \(kept.cookies)"]) |
| } |
| - @MainActor func testAnEndedSessionIsForgottenAndTheInstanceLeftInTheField() async { |
| + @MainActor func testAnEndedSessionIsForgottenAndPointsAtSignIn() async { |
| let store = MemorySessionStore(kept) |
| let server = ScriptedRemote(checks: [.success("signed-out")]) |
| let model = CallsModel(store: store, remote: server.remote) |
| @@ -99,8 +99,9 @@ final class KeptSignInTests: XCTestCase { |
| await settle { !model.restoring && store.session == nil } |
| XCTAssertFalse(model.signedIn) |
| XCTAssertNil(store.session) |
| - XCTAssertEqual(model.address, kept.origin, "signing in again is one button") |
| + XCTAssertEqual(model.pendingLink, "", "signing in again is the home's one button") |
| XCTAssertTrue(model.error.contains("calls.oficina.works")) |
| + XCTAssertTrue(model.error.contains("Choose Sign in")) |
| } |
| @MainActor func testABadConnectionKeepsTheSessionAndOffersToCheckAgain() async { |
diff --git a/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift b/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift| index e076f0b..567f29e 100644 |
| --- a/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/MeetingLinkTests.swift |
| @@ -5,7 +5,8 @@ final class MeetingLinkTests: XCTestCase { |
| @MainActor func testInvitationOpensInLobbyWithoutJoiningOrStartingAuthentication() { |
| let model = CallsModel(store: MemorySessionStore()) |
| model.openMeetingURL(URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.example.com%2Fj%2Finvitation")!) |
| - XCTAssertEqual(model.address, "https://calls.example.com/j/invitation") |
| + XCTAssertEqual(model.pendingLink, "https://calls.example.com/j/invitation") |
| + XCTAssertEqual(model.server, CallsServer.defaultOrigin, "a link never changes the server") |
| XCTAssertFalse(model.joining) |
| XCTAssertFalse(model.authenticating) |
| } |
| @@ -16,13 +17,13 @@ final class MeetingLinkTests: XCTestCase { |
| var link = URLComponents(string: "oficina-calls://join")! |
| link.queryItems = [URLQueryItem(name: "url", value: target)] |
| model.openMeetingURL(link.url!) |
| - XCTAssertEqual(model.address, "") |
| + XCTAssertEqual(model.pendingLink, "") |
| XCTAssertFalse(model.error.isEmpty) |
| } |
| model.joining = true |
| - model.address = "current" |
| + model.pendingLink = "current" |
| model.openMeetingURL(URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.example.com%2Fm%2Froom")!) |
| - XCTAssertEqual(model.address, "current") |
| + XCTAssertEqual(model.pendingLink, "current") |
| XCTAssertTrue(model.joining) |
| } |
| } |
diff --git a/native/apple/Tests/CallsKitTests/StartMeetingTests.swift b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift| index 78ed179..95686c8 100644 |
| --- a/native/apple/Tests/CallsKitTests/StartMeetingTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/StartMeetingTests.swift |
| @@ -48,7 +48,7 @@ final class StartMeetingTests: XCTestCase { |
| XCTAssertFalse(model.starting) |
| } |
| - @MainActor func testALinkToAnotherInstanceClearsWhatTheLastSignInDecided() { |
| + @MainActor func testALinkToAnotherInstanceIsOfferedAndDisturbsNothingUntilSignIn() { |
| let model = CallsModel(store: MemorySessionStore()) |
| model.origin = "https://calls.oficina.works" |
| model.signedIn = true |
| @@ -56,10 +56,17 @@ final class StartMeetingTests: XCTestCase { |
| model.room = "abc123" |
| model.openMeetingURL( |
| URL(string: "oficina-calls://join?url=https%3A%2F%2Fcalls.example.com%2Fj%2Finvitation")!) |
| - XCTAssertFalse(model.canStart, "another instance is another sign-in") |
| - XCTAssertEqual(model.room, "") |
| - XCTAssertFalse(model.signedIn) |
| - XCTAssertEqual(model.address, "https://calls.example.com/j/invitation") |
| + XCTAssertEqual(model.pendingLink, "https://calls.example.com/j/invitation") |
| + XCTAssertFalse(model.pendingOnServer) |
| + XCTAssertFalse(model.authenticating, "offered, not started") |
| + // The session in hand is untouched until a new sign-in replaces it, |
| + // and "Not now" goes straight back to it. |
| + XCTAssertTrue(model.signedIn) |
| + XCTAssertTrue(model.canStart) |
| + XCTAssertEqual(model.room, "abc123") |
| + model.dismissPendingLink() |
| + XCTAssertEqual(model.pendingLink, "") |
| + XCTAssertTrue(model.signedIn) |
| } |
| @MainActor func testHostIsTheInstanceWithoutItsScheme() { |
diff --git a/native/ios/README.md b/native/ios/README.md| index 2c8648c..83fa1fb 100644 |
| --- a/native/ios/README.md |
| +++ b/native/ios/README.md |
| @@ -34,7 +34,9 @@ available to Xcode, with Developer Mode enabled. |
| ## First phone milestone |
| -- Paste a meeting or invitation link; sign in in Safari/the default browser. |
| +- **Sign in** to the Calls server (calls.oficina.works unless changed with the |
| + home screen's gear), or **Join a call as a guest** with a pasted invite link; |
| + sign in in Safari/the default browser. |
| - Open the email link normally, compare the browser's code with Calls's code, |
| approve, then return to Calls. A member's session is kept in the Keychain |
| (this device only) and checked with the server at the next launch; see |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 136c515..db20ceb 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -59,15 +59,30 @@ final class CallsPhoneDelegate: NSObject, UIApplicationDelegate { |
| struct PhoneHome: View { |
| @ObservedObject var model: CallsModel |
| + @State private var showingSettings = false |
| var body: some View { |
| NavigationStack { |
| Group { |
| if model.joining { |
| PhoneCall(call: model.call, leave: model.leave) |
| } else { |
| - CallsLobby(model: model) |
| + CallsLobby(model: model, openSettings: { showingSettings = true }) |
| } |
| }.tint(CallsAppearance.accent).navigationTitle("Calls").navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + // There is no Settings app entry: the gear is where the server |
| + // setting lives, reachable from home and never during a call. |
| + if !model.joining { |
| + ToolbarItem(placement: .topBarTrailing) { |
| + Button("Settings", systemImage: "gearshape") { showingSettings = true } |
| + .accessibilityIdentifier("settings") |
| + } |
| + } |
| + } |
| + .sheet(isPresented: $showingSettings) { |
| + ServerSettingsSheet(model: model) { showingSettings = false } |
| + .presentationDetents([.medium, .large]) |
| + } |
| } |
| } |
| } |
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index ec56916..7a78762 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -1,48 +1,87 @@ |
| import XCTest |
| final class CallsPhoneUITests: XCTestCase { |
| - @MainActor func testInvalidMeetingLink() { |
| + @MainActor func testSignedOutHomeIsSignInAndGuest() { |
| let app = XCUIApplication() |
| app.launchArguments += ["-CallsForgetSignIn", "YES"] |
| app.launch() |
| - let input = app.textFields["meetingLink"] |
| - XCTAssertTrue(input.waitForExistence(timeout: 15)) |
| + XCTAssertTrue(app.buttons["signIn"].waitForExistence(timeout: 15)) |
| + XCTAssertTrue(app.buttons["joinAsGuest"].exists) |
| + XCTAssertFalse(app.textFields["meetingLink"].exists, "nothing to type to sign in") |
| + XCTAssertTrue( |
| + app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "calls.oficina.works")) |
| + .firstMatch.exists) |
| + app.buttons["settings"].tap() |
| + XCTAssertTrue(app.textFields["serverAddress"].waitForExistence(timeout: 5)) |
| + XCTAssertEqual(app.textFields["serverAddress"].value as? String, "https://calls.oficina.works") |
| + } |
| + |
| + @MainActor func testInvalidGuestLink() { |
| + let app = XCUIApplication() |
| + app.launchArguments += ["-CallsForgetSignIn", "YES"] |
| + app.launch() |
| + let guest = app.buttons["joinAsGuest"] |
| + XCTAssertTrue(guest.waitForExistence(timeout: 15)) |
| + guest.tap() |
| + let input = app.textFields["inviteLink"] |
| + XCTAssertTrue(input.waitForExistence(timeout: 5)) |
| input.tap() |
| input.typeText("not a meeting") |
| - app.buttons["Continue"].tap() |
| + app.buttons["guestJoin"].tap() |
| XCTAssertTrue( |
| app.staticTexts.containing( |
| - NSPredicate(format: "label CONTAINS[c] %@", "meeting or invitation link") |
| + NSPredicate(format: "label CONTAINS[c] %@", "Paste the whole invite link") |
| ).firstMatch.waitForExistence(timeout: 10)) |
| - XCTAssertTrue(app.textFields["meetingLink"].exists) |
| + XCTAssertTrue(app.textFields["inviteLink"].exists, "the sheet stays for another try") |
| } |
| @MainActor func testEmailBrowserHandoff() async throws { |
| let env = ProcessInfo.processInfo.environment |
| guard let meeting = env["CALLS_TEST_MEETING"], let control = env["CALLS_TEST_CONTROL"], |
| - let address = env["CALLS_TEST_EMAIL"] |
| + let address = env["CALLS_TEST_EMAIL"], let origin = env["CALLS_TEST_ORIGIN"] |
| else { |
| throw XCTSkip("Run the Go iPhone browser harness for a temporary server and captured email.") |
| } |
| + // A guest pastes the invite into the guest screen; a member signs in |
| + // to the server, which the harness sets for this run only. |
| + let guest = env["CALLS_TEST_GUEST"] == "1" |
| let app = XCUIApplication() |
| - app.launchArguments += ["-CallsForgetSignIn", "YES"] |
| + app.launchArguments += ["-CallsForgetSignIn", "YES", "-CallsServer", origin] |
| app.launch() |
| - let input = app.textFields["meetingLink"] |
| - XCTAssertTrue(input.waitForExistence(timeout: 15)) |
| - input.tap() |
| - input.typeText(meeting) |
| - app.buttons["Continue"].tap() |
| + if guest { |
| + let button = app.buttons["joinAsGuest"] |
| + XCTAssertTrue(button.waitForExistence(timeout: 15)) |
| + button.tap() |
| + let input = app.textFields["inviteLink"] |
| + XCTAssertTrue(input.waitForExistence(timeout: 5)) |
| + input.tap() |
| + input.typeText(meeting) |
| + app.buttons["guestJoin"].tap() |
| + } else { |
| + let button = app.buttons["signIn"] |
| + XCTAssertTrue(button.waitForExistence(timeout: 15)) |
| + button.tap() |
| + } |
| let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") |
| let email = safari.textFields["Email address"] |
| - if !email.waitForExistence(timeout: 10), safari.buttons["Continue"].exists { |
| - safari.buttons["Continue"].tap() |
| + // An invitation lands on the web's guest screen, whose "sign in" link |
| + // leads to the address field, as test/browser/native-auth.mjs follows it. |
| + if !email.waitForExistence(timeout: 10) { |
| + if safari.buttons["Continue"].exists { |
| + safari.buttons["Continue"].tap() |
| + } else if safari.links["sign in"].exists { |
| + safari.links["sign in"].tap() |
| + } |
| } |
| XCTAssertTrue(email.waitForExistence(timeout: 20)) |
| email.tap() |
| - email.typeText(address) |
| - safari.buttons["Send me a sign-in link"].tap() |
| + // Submitted with Return: Safari's autofill bubble can sit over the |
| + // button while the keyboard is up and take the tap instead. |
| + email.typeText(address + "\n") |
| var link: URL? |
| - for _ in 0..<20 { |
| + // Up to fifteen seconds: signing in to the server itself answers more |
| + // slowly than a meeting link (see TestNativeBrowserAuthentication/server). |
| + for _ in 0..<60 { |
| let (data, _) = try await URLSession.shared.data(from: URL(string: control)!) |
| if let response = try JSONSerialization.jsonObject(with: data) as? [String: String], |
| let text = response["link"], !text.isEmpty |
| @@ -68,8 +107,13 @@ final class CallsPhoneUITests: XCTestCase { |
| .exists) |
| approve.tap() |
| app.activate() |
| - XCTAssertTrue(app.buttons["Join meeting"].waitForExistence(timeout: 20)) |
| - XCTAssertTrue(app.staticTexts["Ready to join"].exists) |
| + if guest { |
| + XCTAssertTrue(app.buttons["Join meeting"].waitForExistence(timeout: 20)) |
| + XCTAssertTrue(app.staticTexts["Ready to join"].exists) |
| + } else { |
| + XCTAssertTrue(app.buttons["startMeeting"].waitForExistence(timeout: 20)) |
| + XCTAssertTrue(app.staticTexts["Ready when you are"].exists) |
| + } |
| let attachment = XCTAttachment(screenshot: app.screenshot()) |
| attachment.name = "iPhone signed in through Safari" |
| attachment.lifetime = .keepAlways |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 864800d..60fb255 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.2.0' |
| + MARKETING_VERSION: '0.3.1' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |