Initial commit
This commit is contained in:
133
apple-client/Sources/App/AppModels.swift
Normal file
133
apple-client/Sources/App/AppModels.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
struct UserProfile: Codable, Equatable {
|
||||
let id: String
|
||||
let username: String
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
struct AuthResponse: Codable {
|
||||
let token: String
|
||||
let user: UserProfile
|
||||
}
|
||||
|
||||
struct AccessKeySummary: Codable, Equatable, Identifiable {
|
||||
let id: String
|
||||
let credentialId: String
|
||||
let label: String
|
||||
let transports: [String]
|
||||
let deviceType: String
|
||||
let backedUp: Bool
|
||||
let aaguid: String
|
||||
let createdAt: String
|
||||
}
|
||||
|
||||
struct AccessKeyListResponse: Codable {
|
||||
let credentials: [AccessKeySummary]
|
||||
}
|
||||
|
||||
struct SessionResponse: Codable {
|
||||
let user: UserProfile
|
||||
}
|
||||
|
||||
struct RegistrationOptionsResponse: Codable {
|
||||
struct RelyingParty: Codable {
|
||||
let name: String
|
||||
let id: String
|
||||
}
|
||||
|
||||
struct UserEntity: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
let expectedOrigin: String?
|
||||
let rp: RelyingParty
|
||||
let user: UserEntity
|
||||
let challenge: String
|
||||
}
|
||||
|
||||
struct AuthenticationOptionsResponse: Codable {
|
||||
let attemptId: String
|
||||
let expectedOrigin: String?
|
||||
let challenge: String
|
||||
let rpId: String?
|
||||
}
|
||||
|
||||
struct APIErrorResponse: Codable, Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct PasskeyRegistrationPayload: Encodable {
|
||||
struct Response: Encodable {
|
||||
let clientDataJSON: String
|
||||
let attestationObject: String
|
||||
let transports: [String]?
|
||||
}
|
||||
|
||||
let id: String
|
||||
let rawId: String
|
||||
let response: Response
|
||||
let clientExtensionResults: [String: String]
|
||||
let type: String
|
||||
}
|
||||
|
||||
struct PasskeyAuthenticationPayload: Encodable {
|
||||
struct Response: Encodable {
|
||||
let clientDataJSON: String
|
||||
let authenticatorData: String
|
||||
let signature: String
|
||||
let userHandle: String?
|
||||
}
|
||||
|
||||
let id: String
|
||||
let rawId: String
|
||||
let response: Response
|
||||
let clientExtensionResults: [String: String]
|
||||
let type: String
|
||||
}
|
||||
|
||||
extension Data {
|
||||
init?(base64URLEncoded value: String) {
|
||||
var normalized = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let padding = (4 - normalized.count % 4) % 4
|
||||
normalized.append(String(repeating: "=", count: padding))
|
||||
self.init(base64Encoded: normalized)
|
||||
}
|
||||
|
||||
func base64URLEncodedString() -> String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
extension Encodable {
|
||||
func jsonString() -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
|
||||
guard let data = try? encoder.encode(AnyEncodable(self)),
|
||||
let string = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return "null"
|
||||
}
|
||||
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnyEncodable: Encodable {
|
||||
private let encodeValue: (Encoder) throws -> Void
|
||||
|
||||
init(_ value: some Encodable) {
|
||||
encodeValue = value.encode
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
try encodeValue(encoder)
|
||||
}
|
||||
}
|
||||
|
||||
150
apple-client/Sources/App/BackendClient.swift
Normal file
150
apple-client/Sources/App/BackendClient.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
|
||||
struct BackendClient {
|
||||
let baseURL: URL
|
||||
|
||||
func register(username: String, password: String, displayName: String) async throws -> AuthResponse {
|
||||
try await post(
|
||||
path: "/api/auth/register",
|
||||
body: RegisterRequest(
|
||||
username: username,
|
||||
password: password,
|
||||
displayName: displayName.isEmpty ? nil : displayName
|
||||
),
|
||||
token: nil
|
||||
)
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> AuthResponse {
|
||||
try await post(
|
||||
path: "/api/auth/login",
|
||||
body: LoginRequest(username: username, password: password),
|
||||
token: nil
|
||||
)
|
||||
}
|
||||
|
||||
func restoreSession(token: String) async throws -> SessionResponse {
|
||||
try await get(path: "/api/auth/session", token: token)
|
||||
}
|
||||
|
||||
func logout(token: String) async throws {
|
||||
let _: EmptyResponse = try await post(path: "/api/auth/logout", body: EmptyBody(), token: token)
|
||||
}
|
||||
|
||||
func listAccessKeys(token: String) async throws -> [AccessKeySummary] {
|
||||
let response: AccessKeyListResponse = try await get(path: "/api/webauthn/credentials", token: token)
|
||||
return response.credentials
|
||||
}
|
||||
|
||||
func startAccessKeyRegistration(label: String?, token: String) async throws -> RegistrationOptionsResponse {
|
||||
try await post(
|
||||
path: "/api/webauthn/register/options",
|
||||
body: RegisterAccessKeyRequest(label: label?.isEmpty == false ? label : nil),
|
||||
token: token
|
||||
)
|
||||
}
|
||||
|
||||
func finishAccessKeyRegistration(payload: PasskeyRegistrationPayload, token: String) async throws {
|
||||
let _: EmptyResponse = try await post(
|
||||
path: "/api/webauthn/register/verify",
|
||||
body: VerifyRegistrationRequest(credential: payload),
|
||||
token: token
|
||||
)
|
||||
}
|
||||
|
||||
func startAccessKeyAuthentication() async throws -> AuthenticationOptionsResponse {
|
||||
try await post(path: "/api/webauthn/authenticate/options", body: EmptyBody(), token: nil)
|
||||
}
|
||||
|
||||
func finishAccessKeyAuthentication(
|
||||
attemptId: String,
|
||||
payload: PasskeyAuthenticationPayload
|
||||
) async throws -> AuthResponse {
|
||||
try await post(
|
||||
path: "/api/webauthn/authenticate/verify",
|
||||
body: VerifyAuthenticationRequest(attemptId: attemptId, credential: payload),
|
||||
token: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func get<Response: Decodable>(path: String, token: String?) async throws -> Response {
|
||||
var request = URLRequest(url: baseURL.appending(path: path))
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
if let token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func post<Body: Encodable, Response: Decodable>(
|
||||
path: String,
|
||||
body: Body,
|
||||
token: String?
|
||||
) async throws -> Response {
|
||||
var request = URLRequest(url: baseURL.appending(path: path))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func perform<Response: Decodable>(_ request: URLRequest) async throws -> Response {
|
||||
let (data, rawResponse) = try await URLSession.shared.data(for: request)
|
||||
let response = rawResponse as? HTTPURLResponse
|
||||
|
||||
guard let response else {
|
||||
throw APIErrorResponse(message: "The backend did not return a valid HTTP response.")
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
if (200 ..< 300).contains(response.statusCode) {
|
||||
if Response.self == EmptyResponse.self {
|
||||
return EmptyResponse() as! Response
|
||||
}
|
||||
|
||||
return try decoder.decode(Response.self, from: data)
|
||||
}
|
||||
|
||||
if let apiError = try? decoder.decode(APIErrorResponse.self, from: data) {
|
||||
throw apiError
|
||||
}
|
||||
|
||||
throw APIErrorResponse(message: "The backend returned HTTP \(response.statusCode).")
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
private struct EmptyResponse: Decodable {}
|
||||
private struct RegisterRequest: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
let displayName: String?
|
||||
}
|
||||
|
||||
private struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
private struct RegisterAccessKeyRequest: Encodable {
|
||||
let label: String?
|
||||
}
|
||||
|
||||
private struct VerifyRegistrationRequest: Encodable {
|
||||
let credential: PasskeyRegistrationPayload
|
||||
}
|
||||
|
||||
private struct VerifyAuthenticationRequest: Encodable {
|
||||
let attemptId: String
|
||||
let credential: PasskeyAuthenticationPayload
|
||||
}
|
||||
57
apple-client/Sources/App/ContentView.swift
Normal file
57
apple-client/Sources/App/ContentView.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Bindable var settings: SettingsStore
|
||||
|
||||
#if !os(macOS)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
EmbeddedWebAppView(settings: settings)
|
||||
.toolbar {
|
||||
#if os(macOS)
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
SettingsLink {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
}
|
||||
#else
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let user = settings.currentUser {
|
||||
Text("Signed in as \(user.displayName)")
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
#if !os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView(settings: settings)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
showSettings = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
apple-client/Sources/App/EmbeddedWebAppView.swift
Normal file
112
apple-client/Sources/App/EmbeddedWebAppView.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct EmbeddedWebAppView: View {
|
||||
@Bindable var settings: SettingsStore
|
||||
|
||||
var body: some View {
|
||||
PlatformWebView(script: settings.injectionScript, reloadToken: settings.webStateVersion)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
struct PlatformWebView: NSViewRepresentable {
|
||||
let script: String
|
||||
let reloadToken: UUID
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
context.coordinator.makeWebView(script: script)
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.update(webView: webView, script: script, reloadToken: reloadToken)
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct PlatformWebView: UIViewRepresentable {
|
||||
let script: String
|
||||
let reloadToken: UUID
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
context.coordinator.makeWebView(script: script)
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.update(webView: webView, script: script, reloadToken: reloadToken)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
private var lastReloadToken: UUID?
|
||||
private var pendingRefreshTask: Task<Void, Never>?
|
||||
|
||||
func makeWebView(script: String) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
let contentController = WKUserContentController()
|
||||
contentController.addUserScript(
|
||||
WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||
)
|
||||
configuration.userContentController = contentController
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = self
|
||||
#if os(macOS)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
#endif
|
||||
loadIndex(into: webView)
|
||||
return webView
|
||||
}
|
||||
|
||||
func update(webView: WKWebView, script: String, reloadToken: UUID) {
|
||||
guard lastReloadToken != reloadToken else {
|
||||
return
|
||||
}
|
||||
|
||||
lastReloadToken = reloadToken
|
||||
pendingRefreshTask?.cancel()
|
||||
pendingRefreshTask = Task { @MainActor [weak webView] in
|
||||
await Task.yield()
|
||||
|
||||
guard !Task.isCancelled, let webView else {
|
||||
return
|
||||
}
|
||||
|
||||
webView.evaluateJavaScript(script) { _, _ in
|
||||
DispatchQueue.main.async {
|
||||
webView.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadIndex(into webView: WKWebView) {
|
||||
let indexURL =
|
||||
Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "WebApp/browser")
|
||||
?? Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "WebApp")
|
||||
|
||||
guard let indexURL else {
|
||||
let fallbackHTML = """
|
||||
<html>
|
||||
<body style="font-family:-apple-system;padding:32px;background:#08111d;color:#eff3ff">
|
||||
<h2>Embedded chat bundle not found</h2>
|
||||
<p>The Apple app expects the Angular client to be built into the bundled <code>WebApp</code> folder.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
webView.loadHTMLString(fallbackHTML, baseURL: nil)
|
||||
return
|
||||
}
|
||||
|
||||
webView.loadFileURL(indexURL, allowingReadAccessTo: indexURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
126
apple-client/Sources/App/PasskeyManager.swift
Normal file
126
apple-client/Sources/App/PasskeyManager.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
import AuthenticationServices
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#else
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class PasskeyManager: NSObject {
|
||||
private var continuation: CheckedContinuation<ASAuthorization, Error>?
|
||||
|
||||
func register(options: RegistrationOptionsResponse) async throws -> PasskeyRegistrationPayload {
|
||||
guard let challengeData = Data(base64URLEncoded: options.challenge),
|
||||
let userData = Data(base64URLEncoded: options.user.id)
|
||||
else {
|
||||
throw APIErrorResponse(message: "The backend returned malformed access key registration options.")
|
||||
}
|
||||
|
||||
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id)
|
||||
let clientData = ASPublicKeyCredentialClientData(
|
||||
challenge: challengeData,
|
||||
origin: options.expectedOrigin ?? "http://localhost:4200"
|
||||
)
|
||||
let request = provider.createCredentialRegistrationRequest(
|
||||
clientData: clientData,
|
||||
name: options.user.name,
|
||||
userID: userData
|
||||
)
|
||||
|
||||
let authorization = try await perform(requests: [request])
|
||||
|
||||
guard let registration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else {
|
||||
throw APIErrorResponse(message: "The platform did not return a valid access key registration.")
|
||||
}
|
||||
|
||||
return PasskeyRegistrationPayload(
|
||||
id: registration.credentialID.base64URLEncodedString(),
|
||||
rawId: registration.credentialID.base64URLEncodedString(),
|
||||
response: .init(
|
||||
clientDataJSON: registration.rawClientDataJSON.base64URLEncodedString(),
|
||||
attestationObject: registration.rawAttestationObject?.base64URLEncodedString() ?? "",
|
||||
transports: nil
|
||||
),
|
||||
clientExtensionResults: [:],
|
||||
type: "public-key"
|
||||
)
|
||||
}
|
||||
|
||||
func authenticate(options: AuthenticationOptionsResponse) async throws -> PasskeyAuthenticationPayload {
|
||||
guard let challengeData = Data(base64URLEncoded: options.challenge) else {
|
||||
throw APIErrorResponse(message: "The backend returned malformed access key sign-in options.")
|
||||
}
|
||||
|
||||
let rpId = options.rpId
|
||||
?? URL(string: options.expectedOrigin ?? "")?.host
|
||||
?? "localhost"
|
||||
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
|
||||
let clientData = ASPublicKeyCredentialClientData(
|
||||
challenge: challengeData,
|
||||
origin: options.expectedOrigin ?? "http://localhost:4200"
|
||||
)
|
||||
let request = provider.createCredentialAssertionRequest(clientData: clientData)
|
||||
let authorization = try await perform(requests: [request])
|
||||
|
||||
guard let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {
|
||||
throw APIErrorResponse(message: "The platform did not return a valid access key assertion.")
|
||||
}
|
||||
|
||||
return PasskeyAuthenticationPayload(
|
||||
id: assertion.credentialID.base64URLEncodedString(),
|
||||
rawId: assertion.credentialID.base64URLEncodedString(),
|
||||
response: .init(
|
||||
clientDataJSON: assertion.rawClientDataJSON.base64URLEncodedString(),
|
||||
authenticatorData: assertion.rawAuthenticatorData.base64URLEncodedString(),
|
||||
signature: assertion.signature.base64URLEncodedString(),
|
||||
userHandle: assertion.userID.base64URLEncodedString()
|
||||
),
|
||||
clientExtensionResults: [:],
|
||||
type: "public-key"
|
||||
)
|
||||
}
|
||||
|
||||
private func perform(requests: [ASAuthorizationRequest]) async throws -> ASAuthorization {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
let controller = ASAuthorizationController(authorizationRequests: requests)
|
||||
controller.delegate = self
|
||||
controller.presentationContextProvider = self
|
||||
controller.performRequests()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PasskeyManager: ASAuthorizationControllerDelegate {
|
||||
func authorizationController(
|
||||
controller _: ASAuthorizationController,
|
||||
didCompleteWithAuthorization authorization: ASAuthorization
|
||||
) {
|
||||
continuation?.resume(returning: authorization)
|
||||
continuation = nil
|
||||
}
|
||||
|
||||
func authorizationController(
|
||||
controller _: ASAuthorizationController,
|
||||
didCompleteWithError error: Error
|
||||
) {
|
||||
continuation?.resume(throwing: error)
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension PasskeyManager: ASAuthorizationControllerPresentationContextProviding {
|
||||
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
||||
#if os(macOS)
|
||||
return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor()
|
||||
#else
|
||||
return UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow) ?? ASPresentationAnchor()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
18
apple-client/Sources/App/PrivateChatAppleApp.swift
Normal file
18
apple-client/Sources/App/PrivateChatAppleApp.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PrivateChatAppleApp: App {
|
||||
@State private var settings = SettingsStore()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(settings: settings)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
SettingsView(settings: settings)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
227
apple-client/Sources/App/SettingsStore.swift
Normal file
227
apple-client/Sources/App/SettingsStore.swift
Normal file
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SettingsStore {
|
||||
enum AuthMode: String, CaseIterable, Identifiable {
|
||||
case login
|
||||
case register
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var backendURLString: String
|
||||
var currentUser: UserProfile?
|
||||
var accessKeys: [AccessKeySummary]
|
||||
var authMode: AuthMode = .login
|
||||
var username = ""
|
||||
var password = ""
|
||||
var displayName = ""
|
||||
var accessKeyLabel = ""
|
||||
var infoMessage: String?
|
||||
var errorMessage: String?
|
||||
var isBusy = false
|
||||
private(set) var webStateVersion = UUID()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let passkeyManager = PasskeyManager()
|
||||
|
||||
private enum Keys {
|
||||
static let backendURL = "privatechat.apple.backendURL"
|
||||
static let token = "privatechat.apple.token"
|
||||
static let user = "privatechat.apple.user"
|
||||
}
|
||||
|
||||
init() {
|
||||
backendURLString = defaults.string(forKey: Keys.backendURL) ?? "http://localhost:3000"
|
||||
accessKeys = []
|
||||
|
||||
if let data = defaults.data(forKey: Keys.user),
|
||||
let user = try? JSONDecoder().decode(UserProfile.self, from: data)
|
||||
{
|
||||
currentUser = user
|
||||
}
|
||||
|
||||
Task {
|
||||
await restoreSessionIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
var token: String? {
|
||||
defaults.string(forKey: Keys.token)
|
||||
}
|
||||
|
||||
var isAuthenticated: Bool {
|
||||
token != nil && currentUser != nil
|
||||
}
|
||||
|
||||
var injectionScript: String {
|
||||
let serverValue = backendURLString.jsonString()
|
||||
let tokenValue = (token ?? "").jsonString()
|
||||
let userValue: String
|
||||
|
||||
if let currentUser {
|
||||
userValue = currentUser.jsonString()
|
||||
} else {
|
||||
userValue = "null"
|
||||
}
|
||||
|
||||
return """
|
||||
(function() {
|
||||
try {
|
||||
localStorage.setItem('privatechat.embeddedMode', '1');
|
||||
localStorage.setItem('privatechat.serverUrl', \(serverValue));
|
||||
if (\(tokenValue) && \(tokenValue) !== '""') {
|
||||
localStorage.setItem('privatechat.token', \(tokenValue));
|
||||
} else {
|
||||
localStorage.removeItem('privatechat.token');
|
||||
}
|
||||
if (\(userValue) !== null) {
|
||||
localStorage.setItem('privatechat.user', JSON.stringify(\(userValue)));
|
||||
} else {
|
||||
localStorage.removeItem('privatechat.user');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync native settings into localStorage', error);
|
||||
}
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
func saveBackendURL() {
|
||||
defaults.set(backendURLString, forKey: Keys.backendURL)
|
||||
invalidateWebState(info: "Backend URL updated.")
|
||||
}
|
||||
|
||||
func authenticate() async {
|
||||
await runTask { [self] in
|
||||
switch authMode {
|
||||
case .login:
|
||||
let response = try await self.apiClient().login(username: self.username, password: self.password)
|
||||
try await self.applyAuthResponse(response, success: "Signed in as \(response.user.displayName).")
|
||||
case .register:
|
||||
let response = try await self.apiClient().register(
|
||||
username: self.username,
|
||||
password: self.password,
|
||||
displayName: self.displayName
|
||||
)
|
||||
try await self.applyAuthResponse(response, success: "Account created for \(response.user.displayName).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signInWithAccessKey() async {
|
||||
await runTask { [self] in
|
||||
let options = try await self.apiClient().startAccessKeyAuthentication()
|
||||
let credential = try await self.passkeyManager.authenticate(options: options)
|
||||
let response = try await self.apiClient().finishAccessKeyAuthentication(
|
||||
attemptId: options.attemptId,
|
||||
payload: credential
|
||||
)
|
||||
try await self.applyAuthResponse(response, success: "Signed in as \(response.user.displayName).")
|
||||
}
|
||||
}
|
||||
|
||||
func registerAccessKey() async {
|
||||
guard let token else {
|
||||
errorMessage = "Sign in before registering an access key."
|
||||
return
|
||||
}
|
||||
|
||||
await runTask { [self] in
|
||||
let options = try await self.apiClient().startAccessKeyRegistration(
|
||||
label: self.accessKeyLabel.isEmpty ? nil : self.accessKeyLabel,
|
||||
token: token
|
||||
)
|
||||
let payload = try await self.passkeyManager.register(options: options)
|
||||
try await self.apiClient().finishAccessKeyRegistration(payload: payload, token: token)
|
||||
self.accessKeyLabel = ""
|
||||
self.accessKeys = try await self.apiClient().listAccessKeys(token: token)
|
||||
self.infoMessage = "Access key registered."
|
||||
}
|
||||
}
|
||||
|
||||
func signOut() async {
|
||||
guard let token else {
|
||||
clearAuthState(info: "Signed out.")
|
||||
return
|
||||
}
|
||||
|
||||
await runTask { [self] in
|
||||
try? await self.apiClient().logout(token: token)
|
||||
self.clearAuthState(info: "Signed out.")
|
||||
self.authMode = .login
|
||||
}
|
||||
}
|
||||
|
||||
func restoreSessionIfPossible() async {
|
||||
guard let token else {
|
||||
return
|
||||
}
|
||||
|
||||
await runTask(clearMessages: false) { [self] in
|
||||
let response = try await self.apiClient().restoreSession(token: token)
|
||||
self.currentUser = response.user
|
||||
self.accessKeys = try await self.apiClient().listAccessKeys(token: token)
|
||||
self.infoMessage = "Restored session for \(response.user.displayName)."
|
||||
self.invalidateWebState()
|
||||
}
|
||||
}
|
||||
|
||||
private func apiClient() throws -> BackendClient {
|
||||
guard let url = URL(string: backendURLString.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
url.scheme?.hasPrefix("http") == true
|
||||
else {
|
||||
throw APIErrorResponse(message: "Enter a valid backend URL before continuing.")
|
||||
}
|
||||
|
||||
return BackendClient(baseURL: url)
|
||||
}
|
||||
|
||||
private func applyAuthResponse(_ response: AuthResponse, success: String) async throws {
|
||||
currentUser = response.user
|
||||
defaults.set(response.token, forKey: Keys.token)
|
||||
defaults.set(try JSONEncoder().encode(response.user), forKey: Keys.user)
|
||||
accessKeys = try await apiClient().listAccessKeys(token: response.token)
|
||||
password = ""
|
||||
infoMessage = success
|
||||
invalidateWebState()
|
||||
}
|
||||
|
||||
private func clearAuthState(info: String) {
|
||||
currentUser = nil
|
||||
accessKeys = []
|
||||
defaults.removeObject(forKey: Keys.token)
|
||||
defaults.removeObject(forKey: Keys.user)
|
||||
infoMessage = info
|
||||
errorMessage = nil
|
||||
invalidateWebState()
|
||||
}
|
||||
|
||||
private func invalidateWebState(info: String? = nil) {
|
||||
if let info {
|
||||
infoMessage = info
|
||||
}
|
||||
|
||||
webStateVersion = UUID()
|
||||
}
|
||||
|
||||
private func runTask(clearMessages: Bool = true, operation: @escaping () async throws -> Void) async {
|
||||
if clearMessages {
|
||||
errorMessage = nil
|
||||
infoMessage = nil
|
||||
}
|
||||
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
|
||||
do {
|
||||
try await operation()
|
||||
} catch let apiError as APIErrorResponse {
|
||||
errorMessage = apiError.message
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
127
apple-client/Sources/App/SettingsView.swift
Normal file
127
apple-client/Sources/App/SettingsView.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var settings: SettingsStore
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Backend") {
|
||||
TextField("Backend URL", text: $settings.backendURLString)
|
||||
.onSubmit {
|
||||
settings.saveBackendURL()
|
||||
}
|
||||
.platformCredentialField()
|
||||
|
||||
Button("Apply Backend URL") {
|
||||
settings.saveBackendURL()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Session") {
|
||||
if let user = settings.currentUser {
|
||||
LabeledContent("Signed in as", value: user.displayName)
|
||||
LabeledContent("Username", value: user.username)
|
||||
|
||||
Button("Sign out", role: .destructive) {
|
||||
Task {
|
||||
await settings.signOut()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Mode", selection: $settings.authMode) {
|
||||
ForEach(SettingsStore.AuthMode.allCases) { mode in
|
||||
Text(mode == .login ? "Log in" : "Register").tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if settings.authMode == .register {
|
||||
TextField("Display name", text: $settings.displayName)
|
||||
}
|
||||
|
||||
TextField("Username", text: $settings.username)
|
||||
.platformCredentialField()
|
||||
|
||||
SecureField("Password", text: $settings.password)
|
||||
|
||||
Button(settings.authMode == .login ? "Authenticate" : "Create account") {
|
||||
Task {
|
||||
await settings.authenticate()
|
||||
}
|
||||
}
|
||||
.disabled(settings.isBusy)
|
||||
|
||||
Button("Use access key") {
|
||||
Task {
|
||||
await settings.signInWithAccessKey()
|
||||
}
|
||||
}
|
||||
.disabled(settings.isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Access Keys") {
|
||||
if settings.currentUser == nil {
|
||||
Text("Sign in before registering or listing access keys.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
TextField("New access key label", text: $settings.accessKeyLabel)
|
||||
Button("Register access key") {
|
||||
Task {
|
||||
await settings.registerAccessKey()
|
||||
}
|
||||
}
|
||||
.disabled(settings.isBusy)
|
||||
|
||||
if settings.accessKeys.isEmpty {
|
||||
Text("No access keys registered yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(settings.accessKeys) { key in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(key.label)
|
||||
.font(.headline)
|
||||
Text("Device: \(key.deviceType)\(key.backedUp ? " / backed up" : "")")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Transports: \(key.transports.isEmpty ? "unspecified" : key.transports.joined(separator: ", "))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let infoMessage = settings.infoMessage {
|
||||
Section("Status") {
|
||||
Text(infoMessage)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage = settings.errorMessage {
|
||||
Section("Error") {
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func platformCredentialField() -> some View {
|
||||
#if os(iOS)
|
||||
textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user