oficina / meet Public

Default the native apps to calls.oficina.works, with Sign in and guest joining

Signed out, the home screen names the Calls server (calls.oficina.works by
default) with one prominent "Sign in" and a secondary "Join a call as a
guest"; there is no address field until the person is signed in. The guest
screen takes a pasted invite link (Paste control only, inline validation)
and runs that meeting's own browser sign-in on whatever server it names.

The server is a setting: "Calls server" after the mini-window settings in
the Mac's Settings scene, and a gear sheet on iPhone. https only (http on
loopback, as callscore), host only, trimmed; Reset to calls.oficina.works.
Changing it while signed in asks, then signs out of the old server as Sign
out does. Kept in UserDefaults; -CallsServer overrides it for one run.

A link never changes the setting. oficina-calls:// links off the signed-in
server are offered with the server named rather than started. Guest and
other-server sign-ins are visits: never kept, start nothing, and leaving
drops them (revoked) and comes back to the server's own home.

iPhone marketing version 0.3.1. The iPhone Safari harness now signs in to
the server via -CallsServer and joins guests through the guest screen,
which also fixes its guest case, already failing on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 41fd0c59e5e52763e90134e0451d18d405da8793 parent f91de82
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