Initial commit

This commit is contained in:
2026-03-09 19:35:08 +01:00
commit f6b790a515
64 changed files with 18778 additions and 0 deletions

View 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)
}
}

View 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
}

View 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
}
}
}

View 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())
}
}

View 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
}
}

View 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
}
}

View 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
}
}
}

View 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
}
}