Replace the abandoned RN iOS path with a SwiftUI app (iOS 17+) that reuses the shared TipTap editor bundle, GRDB local mirror/outbox, and Android-aligned shell chrome for list, create, detail, and Me. Package the editor HTML in the app resources so Markdown renders, present edit from the workspace root with a UIKit FAB, and keep caret stable while typing.
87 lines
3.2 KiB
Swift
87 lines
3.2 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class SessionStore {
|
|
private(set) var isLoading = true
|
|
private(set) var session: MobileSession?
|
|
private(set) var lastError: String?
|
|
|
|
let client: APIClient
|
|
private let keychainSessionKey = KeychainStore.Key.session
|
|
|
|
init(client: APIClient = APIClient(baseURL: URL(string: "https://localhost")!)) {
|
|
self.client = client
|
|
}
|
|
|
|
var isSignedIn: Bool { session != nil }
|
|
|
|
var dataScope: String? {
|
|
guard let session else { return nil }
|
|
guard let url = try? EdgeEverURLNormalizer.normalizeInstanceURL(session.baseUrl) else { return nil }
|
|
return SyncProtocol.createDataScope(baseURL: url, userId: session.user?.id)
|
|
}
|
|
|
|
func bootstrap() async {
|
|
defer { isLoading = false }
|
|
do {
|
|
guard let raw = try KeychainStore.string(forKey: keychainSessionKey),
|
|
let data = raw.data(using: .utf8)
|
|
else { return }
|
|
let stored = try EdgeEverJSON.decoder.decode(MobileSession.self, from: data)
|
|
let baseURL = try EdgeEverURLNormalizer.normalizeInstanceURL(stored.baseUrl)
|
|
await client.update(baseURL: baseURL, token: stored.token, onUnauthorized: { [weak self] in
|
|
Task { @MainActor in self?.clearLocalSession() }
|
|
})
|
|
session = stored
|
|
} catch {
|
|
lastError = error.localizedDescription
|
|
clearLocalSession()
|
|
}
|
|
}
|
|
|
|
func signIn(baseUrl: String, username: String, password: String) async throws {
|
|
lastError = nil
|
|
let url = try EdgeEverURLNormalizer.normalizeInstanceURL(baseUrl)
|
|
if url.scheme?.lowercased() == "http" {
|
|
// Allowed with local networking; UI should already warn.
|
|
}
|
|
let deviceId = try DeviceId.getOrCreate()
|
|
await client.update(baseURL: url, token: nil)
|
|
let auth = try await client.login(LoginInput(username: username, password: password, deviceId: deviceId))
|
|
guard auth.authenticated, let token = auth.sessionToken else {
|
|
throw APIError(
|
|
status: 400,
|
|
code: nil,
|
|
message: "登录成功但服务端没有返回移动端会话。请确认服务端已更新到支持 App 登录的版本。"
|
|
)
|
|
}
|
|
let next = MobileSession(baseUrl: url.absoluteString, token: token, user: auth.user)
|
|
try persist(next)
|
|
await client.update(baseURL: url, token: token, onUnauthorized: { [weak self] in
|
|
Task { @MainActor in self?.clearLocalSession() }
|
|
})
|
|
session = next
|
|
}
|
|
|
|
func signOut() async {
|
|
if session != nil {
|
|
try? await client.logout()
|
|
}
|
|
clearLocalSession()
|
|
}
|
|
|
|
private func persist(_ session: MobileSession) throws {
|
|
let data = try EdgeEverJSON.encoder.encode(session)
|
|
guard let raw = String(data: data, encoding: .utf8) else { return }
|
|
try KeychainStore.setString(raw, forKey: keychainSessionKey)
|
|
}
|
|
|
|
func clearLocalSession() {
|
|
try? KeychainStore.delete(forKey: keychainSessionKey)
|
|
session = nil
|
|
Task { await client.update(baseURL: URL(string: "https://localhost")!, token: nil) }
|
|
}
|
|
}
|