a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 80e6955a77

80e6955a770445546bca5619b32f99cfcc2b9a48

parent: aeca25dc10

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T04:47:00Z

auth: sign in by pasted token, Keychain storage, multiple instances

SessionStore validates a pasted token with whoami before anything is
stored, keeps tokens and the account list in the Keychain
(ThisDeviceOnly, never synced), and supports accounts on more than one
instance since gitbay is self-hosted. The active account survives
relaunch; only its id goes to UserDefaults, the token stays in the
Keychain.

A 401 on any later request is routed through handle(_:), which removes
the account cleanly and explains itself on the sign-in screen — an
expired token is a sentence, not a crash.

TokenStore is a protocol so the view-model tests run against an
in-memory store; no test touches the host Keychain or the network.

Verified on the simulator against gitbay.org: sign in, relaunch,
session restored.

Ref #11
gitbay/Auth/Account.swift added +20
@@ -0,0 +1,20 @@
1import Foundation
2
3/// One signed-in identity: an account on an instance. gitbay is
4/// self-hosted, so the pair is the unit the same person on two
5/// instances is two accounts.
6nonisolated struct Account: Codable, Hashable, Sendable, Identifiable {
7 let instance: GitbayInstance
8 let username: String
9
10 /// Stable across renames of nothing usernames are the identity on an
11 /// instance, and tokens are keyed by this.
12 var id: String {
13 "\(instance.baseURL.absoluteString)#\(username)"
14 }
15
16 /// "cmc on gitbay.org"
17 var label: String {
18 "\(username) on \(instance.baseURL.host() ?? instance.baseURL.absoluteString)"
19 }
20}
gitbay/Auth/SessionStore.swift added +135
@@ -0,0 +1,135 @@
1import Foundation
2import Observation
3
4/// Which accounts exist, which one is active, and the client for it.
5///
6/// Signing in validates the pasted token with `whoami` before anything is
7/// stored, so a stored account is always one that worked at least once. A
8/// revoked token later comes back as 401 on some request; `expire` turns
9/// that into a clean sign-out with a plain sentence on the sign-in screen.
10@Observable
11@MainActor
12final class SessionStore {
13
14 private(set) var accounts: [Account]
15 private(set) var current: Account?
16 private(set) var client: GitbayClient?
17
18 /// Why the user is looking at the sign-in screen, when there is a
19 /// reason beyond never having signed in.
20 private(set) var signedOutMessage: String?
21
22 private let store: any TokenStore
23 private let makeClient: @Sendable (GitbayInstance, String) -> GitbayClient
24
25 /// The active account survives relaunch in UserDefaults. An account id
26 /// is not a secret the token it points at stays in the Keychain.
27 private let defaults: UserDefaults
28 private var currentAccountID: String {
29 get { defaults.string(forKey: "currentAccountID") ?? "" }
30 set { defaults.set(newValue, forKey: "currentAccountID") }
31 }
32
33 init(
34 store: any TokenStore = KeychainTokenStore(),
35 defaults: UserDefaults = .standard,
36 makeClient: @escaping @Sendable (GitbayInstance, String) -> GitbayClient = {
37 GitbayClient(instance: $0, token: $1)
38 }
39 ) {
40 self.store = store
41 self.makeClient = makeClient
42 self.defaults = defaults
43 self.accounts = store.loadAccounts()
44 restore()
45 }
46
47 private func restore() {
48 let candidate = accounts.first { $0.id == currentAccountID } ?? accounts.first
49 guard let candidate, let token = store.token(for: candidate.id) else {
50 current = nil
51 client = nil
52 return
53 }
54 activate(candidate, token: token)
55 }
56
57 /// Validate a pasted token against an instance and store the account.
58 /// Throws `GitbayError` with a user-facing message on every failure.
59 func signIn(instanceURL: String, token: String) async throws {
60 let instance = try GitbayInstance(url: instanceURL)
61 let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
62 guard !trimmed.isEmpty else {
63 throw GitbayError.unauthorized("empty token")
64 }
65 let candidate = makeClient(instance, trimmed)
66 let who = try await candidate.read(["whoami"], as: WhoamiResponse.self)
67
68 let account = Account(instance: instance, username: who.username)
69 try store.saveToken(trimmed, for: account.id)
70 if !accounts.contains(account) {
71 accounts.append(account)
72 try store.saveAccounts(accounts)
73 }
74 current = account
75 client = candidate
76 currentAccountID = account.id
77 signedOutMessage = nil
78 }
79
80 /// Switch to another stored account. Returns false if its token is
81 /// gone from the Keychain, in which case the account is dropped too.
82 @discardableResult
83 func activate(_ account: Account) -> Bool {
84 guard let token = store.token(for: account.id) else {
85 remove(account)
86 return false
87 }
88 activate(account, token: token)
89 return true
90 }
91
92 private func activate(_ account: Account, token: String) {
93 current = account
94 client = makeClient(account.instance, token)
95 currentAccountID = account.id
96 }
97
98 /// Forget an account: token out of the Keychain, account out of the
99 /// list, and if it was active, over to the next one or signed out.
100 func remove(_ account: Account) {
101 store.deleteToken(for: account.id)
102 accounts.removeAll { $0.id == account.id }
103 try? store.saveAccounts(accounts)
104 if current?.id == account.id {
105 current = nil
106 client = nil
107 currentAccountID = ""
108 restore()
109 }
110 }
111
112 /// The server said 401: the token is expired or revoked. Sign the
113 /// account out cleanly and say why never crash, never loop.
114 func expire(_ account: Account) {
115 remove(account)
116 if current == nil {
117 signedOutMessage =
118 "The token for \(account.label) is no longer valid. Mint a new one and sign in again."
119 }
120 }
121
122 /// Route an error from any screen: a 401 expires the current account;
123 /// everything else is the caller's to show.
124 func handle(_ error: any Error) {
125 guard let error = error as? GitbayError, error.requiresReauthentication,
126 let current else {
127 return
128 }
129 expire(current)
130 }
131
132 nonisolated private struct WhoamiResponse: Decodable, Sendable {
133 let username: String
134 }
135}
gitbay/Auth/TokenStore.swift added +98
@@ -0,0 +1,98 @@
1import Foundation
2@preconcurrency import Security
3
4/// Persistence for accounts and their tokens. The Keychain implementation
5/// is the real one; tests use an in-memory stand-in so no test touches the
6/// host Keychain.
7nonisolated protocol TokenStore: Sendable {
8 func saveToken(_ token: String, for accountID: String) throws
9 func token(for accountID: String) -> String?
10 func deleteToken(for accountID: String)
11 func saveAccounts(_ accounts: [Account]) throws
12 func loadAccounts() -> [Account]
13}
14
15enum TokenStoreError: LocalizedError, Sendable {
16 case keychain(OSStatus)
17
18 var errorDescription: String? {
19 switch self {
20 case .keychain(let status):
21 "Could not store the token securely (\(status))."
22 }
23 }
24}
25
26/// Tokens and the account list live in the Keychain and nowhere else
27/// nothing git tracks, nothing in UserDefaults, no iCloud sync
28/// (ThisDeviceOnly): a token names this phone, not the account.
29nonisolated struct KeychainTokenStore: TokenStore {
30
31 private static let service = "org.gitbay.gitbay"
32 private static let accountsKey = "accounts"
33
34 func saveToken(_ token: String, for accountID: String) throws {
35 try save(Data(token.utf8), account: "token." + accountID)
36 }
37
38 func token(for accountID: String) -> String? {
39 load(account: "token." + accountID).flatMap { String(data: $0, encoding: .utf8) }
40 }
41
42 func deleteToken(for accountID: String) {
43 delete(account: "token." + accountID)
44 }
45
46 func saveAccounts(_ accounts: [Account]) throws {
47 try save(JSONEncoder().encode(accounts), account: Self.accountsKey)
48 }
49
50 func loadAccounts() -> [Account] {
51 guard let data = load(account: Self.accountsKey),
52 let accounts = try? JSONDecoder().decode([Account].self, from: data) else {
53 return []
54 }
55 return accounts
56 }
57
58 // MARK: - SecItem
59
60 private func save(_ data: Data, account: String) throws {
61 delete(account: account)
62 let query: [String: Any] = [
63 kSecClass as String: kSecClassGenericPassword,
64 kSecAttrService as String: Self.service,
65 kSecAttrAccount as String: account,
66 kSecValueData as String: data,
67 kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
68 ]
69 let status = SecItemAdd(query as CFDictionary, nil)
70 guard status == errSecSuccess else {
71 throw TokenStoreError.keychain(status)
72 }
73 }
74
75 private func load(account: String) -> Data? {
76 let query: [String: Any] = [
77 kSecClass as String: kSecClassGenericPassword,
78 kSecAttrService as String: Self.service,
79 kSecAttrAccount as String: account,
80 kSecReturnData as String: true,
81 kSecMatchLimit as String: kSecMatchLimitOne,
82 ]
83 var result: AnyObject?
84 guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
85 return nil
86 }
87 return result as? Data
88 }
89
90 private func delete(account: String) {
91 let query: [String: Any] = [
92 kSecClass as String: kSecClassGenericPassword,
93 kSecAttrService as String: Self.service,
94 kSecAttrAccount as String: account,
95 ]
96 SecItemDelete(query as CFDictionary)
97 }
98}
gitbay/ContentView.swift +47 −13
@@ -1,24 +1,58 @@
1//
2// ContentView.swift
3// gitbay
4//
5// Created by cmc on 2026-08-26.
6//
7
81 import SwiftUI
92
103 struct ContentView: View {
4
5 @Environment(SessionStore.self) private var session
6
7 var body: some View {
8 NavigationStack {
9 if let account = session.current {
10 SignedInPlaceholderView(account: account)
11 } else {
12 SignInView()
13 }
14 }
15 }
16}
17
18/// Stands in until the repo list lands; proves sign-in, account switching
19/// and sign-out end to end.
20private struct SignedInPlaceholderView: View {
21
22 @Environment(SessionStore.self) private var session
23 let account: Account
24
1125 var body: some View {
12 VStack {
13 Image(systemName: "globe")
14 .imageScale(.large)
15 .foregroundStyle(.tint)
16 Text("Hello, world!")
26 List {
27 Section("Signed in") {
28 LabeledContent("Account", value: account.username)
29 LabeledContent(
30 "Instance",
31 value: account.instance.baseURL.host() ?? account.instance.baseURL.absoluteString
32 )
33 }
34
35 if session.accounts.count > 1 {
36 Section("Switch account") {
37 ForEach(session.accounts.filter { $0.id != account.id }) { other in
38 Button(other.label) {
39 session.activate(other)
40 }
41 }
42 }
43 }
44
45 Section {
46 Button("Sign Out", role: .destructive) {
47 session.remove(account)
48 }
49 }
1750 }
18 .padding()
51 .navigationTitle("gitbay")
1952 }
2053 }
2154
2255 #Preview {
2356 ContentView()
57 .environment(SessionStore())
2458 }
gitbay/Views/SignInView.swift added +108
@@ -0,0 +1,108 @@
1import SwiftUI
2
3/// Paste a token, name the instance, done. There is no browser flow and no
4/// password anywhere in the system: tokens are minted over SSH on a
5/// machine that has it.
6struct SignInView: View {
7
8 @Environment(SessionStore.self) private var session
9
10 @State private var instanceURL = "gitbay.org"
11 @State private var token = ""
12 @State private var errorMessage: String?
13 @State private var signingIn = false
14 @FocusState private var tokenFieldFocused: Bool
15
16 var body: some View {
17 Form {
18 if let notice = session.signedOutMessage {
19 Section {
20 Label(notice, systemImage: "key.slash")
21 .foregroundStyle(.secondary)
22 }
23 }
24
25 Section("Instance") {
26 TextField("gitbay.org", text: $instanceURL)
27 .textContentType(.URL)
28 .keyboardType(.URL)
29 .autocorrectionDisabled()
30 .textInputAutocapitalization(.never)
31 }
32
33 Section {
34 SecureField("Paste a token", text: $token)
35 .autocorrectionDisabled()
36 .textInputAutocapitalization(.never)
37 .focused($tokenFieldFocused)
38 } header: {
39 Text("Token")
40 } footer: {
41 VStack(alignment: .leading, spacing: 8) {
42 Text("Mint one over SSH on a machine that has your key:")
43 Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d")
44 .font(.caption.monospaced())
45 .textSelection(.enabled)
46 Text("`--scope read` also works, but a read-only token cannot comment or merge.")
47 }
48 }
49
50 if let errorMessage {
51 Section {
52 Label(errorMessage, systemImage: "exclamationmark.triangle")
53 .foregroundStyle(.red)
54 }
55 }
56
57 Section {
58 Button {
59 signIn()
60 } label: {
61 if signingIn {
62 ProgressView()
63 .frame(maxWidth: .infinity)
64 } else {
65 Text("Sign In")
66 .frame(maxWidth: .infinity)
67 }
68 }
69 .disabled(signingIn || token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
70 }
71 }
72 .navigationTitle("Sign in to gitbay")
73 .onAppear { tokenFieldFocused = true }
74 }
75
76 private func signIn() {
77 signingIn = true
78 errorMessage = nil
79 Task {
80 defer { signingIn = false }
81 do {
82 try await session.signIn(instanceURL: instanceURL, token: token)
83 token = ""
84 } catch let error as GitbayError {
85 errorMessage = signInMessage(for: error)
86 } catch let error as GitbayInstance.InvalidURL {
87 errorMessage = error.errorDescription
88 } catch {
89 errorMessage = "Something went wrong. Please try again."
90 }
91 }
92 }
93
94 /// Sign-in is the one place a 401 means "that token", not "your session".
95 private func signInMessage(for error: GitbayError) -> String {
96 if error.requiresReauthentication {
97 return "That token was not accepted. Check it was pasted whole and has not expired."
98 }
99 return error.userFacingMessage
100 }
101}
102
103#Preview {
104 NavigationStack {
105 SignInView()
106 }
107 .environment(SessionStore())
108}
gitbay/gitbayApp.swift +4 −7
@@ -1,17 +1,14 @@
1//
2// gitbayApp.swift
3// gitbay
4//
5// Created by cmc on 2026-08-26.
6//
7
81 import SwiftUI
92
103 @main
114 struct gitbayApp: App {
5
6 @State private var session = SessionStore()
7
128 var body: some Scene {
139 WindowGroup {
1410 ContentView()
11 .environment(session)
1512 }
1613 }
1714 }
gitbayTests/SessionStoreTests.swift added +197
@@ -0,0 +1,197 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// In-memory TokenStore so no test touches the host Keychain.
6nonisolated final class MemoryTokenStore: TokenStore, @unchecked Sendable {
7
8 private let state = Mutex<(tokens: [String: String], accounts: [Account])>((tokens: [:], accounts: []))
9
10 func saveToken(_ token: String, for accountID: String) throws {
11 state.withLock { $0.tokens[accountID] = token }
12 }
13
14 func token(for accountID: String) -> String? {
15 state.withLock { $0.tokens[accountID] }
16 }
17
18 func deleteToken(for accountID: String) {
19 state.withLock { _ = $0.tokens.removeValue(forKey: accountID) }
20 }
21
22 func saveAccounts(_ accounts: [Account]) throws {
23 state.withLock { $0.accounts = accounts }
24 }
25
26 func loadAccounts() -> [Account] {
27 state.withLock { $0.accounts }
28 }
29
30 var tokenCount: Int {
31 state.withLock { $0.tokens.count }
32 }
33}
34
35private let whoamiCMC = """
36 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
37 """
38private let badToken = """
39 {"protocol_version":1,"error":"invalid or expired token"}
40 """
41
42@MainActor
43private func makeSession(
44 store: MemoryTokenStore = MemoryTokenStore()
45) -> (SessionStore, MemoryTokenStore, StubProtocol.Box) {
46 let box = StubProtocol.box()
47 let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")!
48 let session = SessionStore(store: store, defaults: defaults) { instance, token in
49 GitbayClient(instance: instance, token: token, session: box.session())
50 }
51 return (session, store, box)
52}
53
54@MainActor
55struct SessionStoreTests {
56
57 @Test func signInValidatesWithWhoamiThenStores() async throws {
58 let (session, store, stub) = makeSession()
59 stub.enqueue(.init(status: 200, json: whoamiCMC))
60
61 try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
62
63 #expect(session.current?.username == "cmc")
64 #expect(session.client != nil)
65 #expect(store.loadAccounts().count == 1)
66 let account = try #require(session.current)
67 #expect(store.token(for: account.id) == "gb_secret")
68 let seen = try #require(stub.seen.first)
69 #expect(seen.url.query() == "argv=whoami")
70 }
71
72 @Test func rejectedTokenStoresNothing() async throws {
73 let (session, store, stub) = makeSession()
74 stub.enqueue(.init(status: 401, json: badToken))
75
76 await #expect(throws: GitbayError.self) {
77 try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong")
78 }
79
80 #expect(session.current == nil)
81 #expect(store.loadAccounts().isEmpty)
82 #expect(store.tokenCount == 0)
83 }
84
85 @Test func whitespaceAroundThePastedTokenIsTrimmed() async throws {
86 let (session, store, stub) = makeSession()
87 stub.enqueue(.init(status: 200, json: whoamiCMC))
88
89 try await session.signIn(instanceURL: "gitbay.org", token: " gb_secret\n")
90
91 let account = try #require(session.current)
92 #expect(store.token(for: account.id) == "gb_secret")
93 let seen = try #require(stub.seen.first)
94 #expect(seen.headers["Authorization"] == "Bearer gb_secret")
95 }
96
97 @Test func signingInTwiceOnTheSameAccountDoesNotDuplicateIt() async throws {
98 let (session, store, stub) = makeSession()
99 stub.enqueue(.init(status: 200, json: whoamiCMC))
100 stub.enqueue(.init(status: 200, json: whoamiCMC))
101
102 try await session.signIn(instanceURL: "gitbay.org", token: "gb_old")
103 try await session.signIn(instanceURL: "gitbay.org", token: "gb_new")
104
105 #expect(store.loadAccounts().count == 1)
106 let account = try #require(session.current)
107 #expect(store.token(for: account.id) == "gb_new")
108 }
109
110 @Test func accountsOnTwoInstancesCoexist() async throws {
111 let (session, store, stub) = makeSession()
112 stub.enqueue(.init(status: 200, json: whoamiCMC))
113 stub.enqueue(.init(status: 200, json: whoamiCMC))
114
115 try await session.signIn(instanceURL: "gitbay.org", token: "gb_one")
116 try await session.signIn(instanceURL: "https://forge.example", token: "gb_two")
117
118 #expect(session.accounts.count == 2)
119 #expect(session.current?.instance.baseURL.host() == "forge.example")
120 let first = try #require(session.accounts.first)
121 #expect(session.activate(first))
122 #expect(session.current?.instance.baseURL.host() == "gitbay.org")
123 }
124
125 @Test func removingTheActiveAccountFallsBackToAnother() async throws {
126 let (session, _, stub) = makeSession()
127 stub.enqueue(.init(status: 200, json: whoamiCMC))
128 stub.enqueue(.init(status: 200, json: whoamiCMC))
129
130 try await session.signIn(instanceURL: "gitbay.org", token: "gb_one")
131 try await session.signIn(instanceURL: "https://forge.example", token: "gb_two")
132 let active = try #require(session.current)
133
134 session.remove(active)
135
136 #expect(session.current?.instance.baseURL.host() == "gitbay.org")
137 #expect(session.accounts.count == 1)
138 }
139
140 @Test func sessionRestoresFromTheStoreAcrossLaunches() async throws {
141 let store = MemoryTokenStore()
142 let (first, _, stub) = makeSession(store: store)
143 stub.enqueue(.init(status: 200, json: whoamiCMC))
144 try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret")
145
146 // A second SessionStore over the same TokenStore is a relaunch.
147 let (second, _, _) = makeSession(store: store)
148
149 #expect(second.current?.username == "cmc")
150 #expect(second.client != nil)
151 }
152
153 @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws {
154 let (session, store, stub) = makeSession()
155 stub.enqueue(.init(status: 200, json: whoamiCMC))
156 try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
157 let account = try #require(session.current)
158
159 // Some later request comes back 401.
160 session.handle(GitbayError.unauthorized("invalid or expired token"))
161
162 #expect(session.current == nil)
163 #expect(session.client == nil)
164 #expect(store.tokenCount == 0)
165 #expect(store.loadAccounts().isEmpty)
166 let message = try #require(session.signedOutMessage)
167 #expect(message.contains(account.username))
168 #expect(message.contains("no longer valid"))
169 }
170
171 @Test func nonAuthErrorsDoNotSignAnyoneOut() async throws {
172 let (session, _, stub) = makeSession()
173 stub.enqueue(.init(status: 200, json: whoamiCMC))
174 try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
175
176 session.handle(GitbayError.notFound("no such repo"))
177 session.handle(GitbayError.failure("boom"))
178 session.handle(URLError(.timedOut))
179
180 #expect(session.current != nil)
181 #expect(session.signedOutMessage == nil)
182 }
183
184 @Test func restoringWithAMissingTokenDropsTheAccount() async throws {
185 let store = MemoryTokenStore()
186 let (first, _, stub) = makeSession(store: store)
187 stub.enqueue(.init(status: 200, json: whoamiCMC))
188 try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret")
189 let account = try #require(first.current)
190 store.deleteToken(for: account.id)
191
192 let (second, _, _) = makeSession(store: store)
193
194 #expect(second.current == nil)
195 #expect(second.client == nil)
196 }
197}