oficina / meet Public

Merge branch 'feat/default-server' into main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e99c8da3604682240d3982bba2024b5bbd9938cf parent f91de82parent 41fd0c5
22 files changed, +1252 −180
  • internal/callstest/iphone_browser_test.go +8 −1
  • internal/callstest/native_auth_browser_test.go +10 −7
  • native/DESIGN.md +16 −3
  • native/README.md +56 −11
  • native/VERIFICATION.md +20 −0
  • native/apple/Sources/CallsInterop/LobbySnapshots.swift +76 −24
  • native/apple/Sources/CallsInterop/SettingsSnapshots.swift +5 −23
  • native/apple/Sources/CallsKit/CallsLobby.swift +84 −24
  • native/apple/Sources/CallsKit/CallsModel.swift +214 −49
  • native/apple/Sources/CallsKit/CallsServer.swift +129 −0
  • native/apple/Sources/CallsKit/CallsSettingsView.swift +5 −3
  • native/apple/Sources/CallsKit/GuestJoinView.swift +114 −0
  • native/apple/Sources/CallsKit/ServerSettings.swift +122 −0
  • native/apple/Sources/CallsMac/CallsMac.swift +1 −1
  • native/apple/Tests/CallsKitTests/DefaultServerTests.swift +288 −0
  • native/apple/Tests/CallsKitTests/KeptSignInTests.swift +3 −2
  • native/apple/Tests/CallsKitTests/MeetingLinkTests.swift +5 −4
  • native/apple/Tests/CallsKitTests/StartMeetingTests.swift +12 −5
  • native/ios/README.md +3 −1
  • native/ios/Sources/CallsPhone.swift +16 −1
  • native/ios/UITests/CallsPhoneUITests.swift +64 −20
  • native/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