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

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.npm-cache/*
client/.angular/cache/*
.env
server/server/data/privatechat.sqlite
server/server/data/privatechat.sqlite-shm
server/server/data/privatechat.sqlite-wal
server/server/data/master.key
client/dist/*

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# PrivateChat
PrivateChat is a two-part application:
- An Angular 21 + Bootstrap frontend for authentication, peer discovery, and direct chat.
- A Fastify + WebSocket backend for JWT-based authentication, Redis-backed sessions, and initial WebRTC signaling.
Once two clients discover each other through the backend, text, JSON payloads, and file transfers move over a direct WebRTC data channel. Signaling is only allowed for clients with an active authenticated backend session.
## Features
- Register and log in with JWTs that are bound to active Redis sessions.
- Sign in with either a password or a registered WebAuthn access key.
- Persist users in a local SQLite database with encrypted credential storage.
- Persist the JWT signing secret in the SQLite database, encrypted at rest.
- Allow authenticated users to register multiple WebAuthn access keys.
- Discover online peers through a signaling WebSocket.
- Establish direct WebRTC peer connections.
- Exchange plain text messages, structured JSON payloads, and files.
- Keep signaling on the backend and user content on the peer-to-peer data channel.
## Run locally
Requirements:
- Node.js 20+ recommended.
- npm 10+.
- A local Redis instance reachable at `redis://127.0.0.1:6379/0` by default.
Install the root runner dependency:
```bash
npm install
```
Start both apps:
```bash
npm run dev
```
Configuration defaults now live in the repo root [`.env`](/var/approot/PrivateChat/.env). The backend loads that file with `dotenv` at startup, and the frontend generates `client/public/env.js` from the same file before `ng serve` and `ng build`.
To serve the prebuilt Angular app from Fastify and run only one server:
```bash
npm run build
npm run start --prefix server
```
The backend serves `client/dist/client/browser` on the same origin as the API and WebSocket endpoints.
Default endpoints:
- Frontend: `http://localhost:4200`
- Backend: `http://localhost:3000`
The backend automatically creates:
- `server/data/privatechat.sqlite` for users and encrypted secret material.
- `server/data/master.key` if `PRIVATECHAT_MASTER_KEY` is not supplied.
The frontend lets you override the backend URL from the login screen if you need a different host or port.
## Apple client
The repo also includes a multiplatform SwiftUI client in `apple-client/` for macOS and iOS/iPadOS.
- The native Settings UI manages backend URL, password auth, passkey auth, and access-key registration.
- The main chat surface embeds the Angular client bundle in a WKWebView, so the WebRTC chat workspace stays aligned with the web app.
- Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`.
- A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it.
## Backend environment
The backend accepts these environment variables:
- `PORT`: HTTP and WebSocket port. Default `3000`.
- `REDIS_URL`: Local Redis connection string. Default `redis://127.0.0.1:6379/0`.
- `SESSION_TTL_SECONDS`: Redis session TTL. Default `43200`.
- `SQLITE_PATH`: Optional SQLite database path.
- `PRIVATECHAT_DATA_DIR`: Base directory for generated local storage.
- `PRIVATECHAT_MASTER_KEY`: Optional master key for encrypting SQLite secret material and user credentials.
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key.
- `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`.
- `CORS_ORIGIN`: Optional allowed browser origin. If omitted, the server reflects request origins.
- `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`.
- `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.
- `WEBAUTHN_USER_VERIFICATION`: WebAuthn user-verification policy for access-key registration. Supported values: `discouraged`, `preferred`, `required`. Default `preferred`.
- `WEBAUTHN_CHALLENGE_TTL_SECONDS`: Pending access-key registration challenge TTL in Redis. Default `300`.
## Frontend environment
- `PRIVATECHAT_CLIENT_SERVER_URL`: Default backend URL preloaded into the Angular app before local storage overrides apply.
## Production note
This project uses the Fastify server only for auth and signaling. The chat payload itself stays peer-to-peer over WebRTC. For production deployment, replace the local master key strategy with your preferred secret-management system, keep Redis durable enough for your session policy, and configure TURN infrastructure if peers cannot connect with STUN alone.

View File

@@ -0,0 +1,381 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
0CF99BBF9D1A04B9E2F5DFC8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A07E2A8E4DEFCE88D5553B5B /* ContentView.swift */; };
1A645AC02B000D539C39368C /* PasskeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FF93F2A1FD25DB9E957AA6 /* PasskeyManager.swift */; };
3B90CADE868971A576AE57A7 /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E71E386D6B12B015DC0103 /* AppModels.swift */; };
3BEB977AA98055EF05F97989 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977F5A04FC063DC80BB9CE26 /* SettingsView.swift */; };
861A055BD65866A57B6BBC0E /* EmbeddedWebAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09649C45C63E1AD1704C0D78 /* EmbeddedWebAppView.swift */; };
D02DE21893F9BFC1519D511C /* BackendClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591916AA72EF25C5E6F3CCBB /* BackendClient.swift */; };
E854B030B8ACED01056B39FD /* PrivateChatAppleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF8901583248E9FADEEB357 /* PrivateChatAppleApp.swift */; };
FD0B1329E00BFC563CA92B34 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8826CE950551697F8A2238FD /* SettingsStore.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
08E8FBDE6277D997AAD09FFE /* PrivateChatApple.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = PrivateChatApple.app; sourceTree = BUILT_PRODUCTS_DIR; };
09649C45C63E1AD1704C0D78 /* EmbeddedWebAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedWebAppView.swift; sourceTree = "<group>"; };
16E71E386D6B12B015DC0103 /* AppModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModels.swift; sourceTree = "<group>"; };
38FF93F2A1FD25DB9E957AA6 /* PasskeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyManager.swift; sourceTree = "<group>"; };
591916AA72EF25C5E6F3CCBB /* BackendClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendClient.swift; sourceTree = "<group>"; };
8826CE950551697F8A2238FD /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
977F5A04FC063DC80BB9CE26 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A07E2A8E4DEFCE88D5553B5B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
FDF8901583248E9FADEEB357 /* PrivateChatAppleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatAppleApp.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
63815A93152EA7A84CECAE92 /* App */ = {
isa = PBXGroup;
children = (
16E71E386D6B12B015DC0103 /* AppModels.swift */,
591916AA72EF25C5E6F3CCBB /* BackendClient.swift */,
A07E2A8E4DEFCE88D5553B5B /* ContentView.swift */,
09649C45C63E1AD1704C0D78 /* EmbeddedWebAppView.swift */,
38FF93F2A1FD25DB9E957AA6 /* PasskeyManager.swift */,
FDF8901583248E9FADEEB357 /* PrivateChatAppleApp.swift */,
8826CE950551697F8A2238FD /* SettingsStore.swift */,
977F5A04FC063DC80BB9CE26 /* SettingsView.swift */,
);
name = App;
path = Sources/App;
sourceTree = "<group>";
};
66E649CB0A00080FE1C2DC25 /* Products */ = {
isa = PBXGroup;
children = (
08E8FBDE6277D997AAD09FFE /* PrivateChatApple.app */,
);
name = Products;
sourceTree = "<group>";
};
CBDB6BA6F0BD33470718D8EF = {
isa = PBXGroup;
children = (
63815A93152EA7A84CECAE92 /* App */,
66E649CB0A00080FE1C2DC25 /* Products */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3438777E81C60FEE7970CAF2 /* PrivateChatApple */ = {
isa = PBXNativeTarget;
buildConfigurationList = 03EBB318C449F01FB0639745 /* Build configuration list for PBXNativeTarget "PrivateChatApple" */;
buildPhases = (
473BDD4E02B992BD309F5028 /* Build Embedded Angular Client */,
0B1A447B1617CF6E1E4F4EA9 /* Sources */,
04166E69061744AF9B27795E /* Copy Embedded Angular Client */,
);
buildRules = (
);
dependencies = (
);
name = PrivateChatApple;
packageProductDependencies = (
);
productName = PrivateChatApple;
productReference = 08E8FBDE6277D997AAD09FFE /* PrivateChatApple.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
12B40BE574717A46063F45C8 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
3438777E81C60FEE7970CAF2 = {
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = A55C1DFA666ADF74C56D34DA /* Build configuration list for PBXProject "PrivateChatApple" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = CBDB6BA6F0BD33470718D8EF;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
3438777E81C60FEE7970CAF2 /* PrivateChatApple */,
);
};
/* End PBXProject section */
/* Begin PBXShellScriptBuildPhase section */
04166E69061744AF9B27795E /* Copy Embedded Angular Client */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Copy Embedded Angular Client";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -euo pipefail\nDESTINATION=\"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/WebApp\"\nrm -rf \"$DESTINATION\"\nmkdir -p \"$DESTINATION\"\ncp -R \"$SRCROOT/WebApp/.\" \"$DESTINATION\"\n";
};
473BDD4E02B992BD309F5028 /* Build Embedded Angular Client */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Embedded Angular Client";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -euo pipefail\ncd \"$SRCROOT/..\"\nmkdir -p \"$SRCROOT/WebApp\"\nnpm run build --prefix client -- --base-href ./ --output-path \"$SRCROOT/WebApp\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0B1A447B1617CF6E1E4F4EA9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3B90CADE868971A576AE57A7 /* AppModels.swift in Sources */,
D02DE21893F9BFC1519D511C /* BackendClient.swift in Sources */,
0CF99BBF9D1A04B9E2F5DFC8 /* ContentView.swift in Sources */,
861A055BD65866A57B6BBC0E /* EmbeddedWebAppView.swift in Sources */,
1A645AC02B000D539C39368C /* PasskeyManager.swift in Sources */,
E854B030B8ACED01056B39FD /* PrivateChatAppleApp.swift in Sources */,
FD0B1329E00BFC563CA92B34 /* SettingsStore.swift in Sources */,
3BEB977AA98055EF05F97989 /* SettingsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
0356A63804EF725F92924B9B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_KEY_CFBundleDisplayName = PrivateChat;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_NAME = PrivateChatApple;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
2E89E0E6371B30AE59C3646B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
MACOSX_DEPLOYMENT_TARGET = 14.4;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.privatechat.apple;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0;
};
name = Release;
};
8269FBF8D3B1C19B7CA65298 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
MACOSX_DEPLOYMENT_TARGET = 14.4;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.privatechat.apple;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
};
name = Debug;
};
9A5E4F1577926AC0869DBE31 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGNING_ALLOWED = NO;
CODE_SIGNING_REQUIRED = NO;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_KEY_CFBundleDisplayName = PrivateChat;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_NAME = PrivateChatApple;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
03EBB318C449F01FB0639745 /* Build configuration list for PBXNativeTarget "PrivateChatApple" */ = {
isa = XCConfigurationList;
buildConfigurations = (
9A5E4F1577926AC0869DBE31 /* Debug */,
0356A63804EF725F92924B9B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
A55C1DFA666ADF74C56D34DA /* Build configuration list for PBXProject "PrivateChatApple" */ = {
isa = XCConfigurationList;
buildConfigurations = (
8269FBF8D3B1C19B7CA65298 /* Debug */,
2E89E0E6371B30AE59C3646B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 12B40BE574717A46063F45C8 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>PrivateChatApple.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

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

View File

@@ -0,0 +1,355 @@
--------------------------------------------------------------------------------
Package: @angular/core
License: "MIT"
The MIT License
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: rxjs
License: "Apache-2.0"
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--------------------------------------------------------------------------------
Package: tslib
License: "0BSD"
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/common
License: "MIT"
The MIT License
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/platform-browser
License: "MIT"
The MIT License
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/router
License: "MIT"
The MIT License
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Package: @angular/forms
License: "MIT"
The MIT License
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
{
"routes": {}
}

44
apple-client/project.yml Normal file
View File

@@ -0,0 +1,44 @@
name: PrivateChatApple
options:
bundleIdPrefix: com.privatechat
settings:
base:
SWIFT_VERSION: 6.0
PRODUCT_BUNDLE_IDENTIFIER: com.privatechat.apple
MARKETING_VERSION: 1.0
CURRENT_PROJECT_VERSION: 1
GENERATE_INFOPLIST_FILE: YES
IPHONEOS_DEPLOYMENT_TARGET: 17.4
MACOSX_DEPLOYMENT_TARGET: 14.4
CODE_SIGN_STYLE: Automatic
targets:
PrivateChatApple:
type: application
supportedDestinations:
- iOS
- macOS
sources:
- path: Sources/App
settings:
base:
PRODUCT_NAME: PrivateChatApple
INFOPLIST_KEY_CFBundleDisplayName: PrivateChat
INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.social-networking
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
CODE_SIGNING_ALLOWED: NO
CODE_SIGNING_REQUIRED: NO
preBuildScripts:
- name: Build Embedded Angular Client
script: |
set -euo pipefail
cd "$SRCROOT/.."
mkdir -p "$SRCROOT/WebApp"
npm run build --prefix client -- --base-href ./ --output-path "$SRCROOT/WebApp"
postBuildScripts:
- name: Copy Embedded Angular Client
script: |
set -euo pipefail
DESTINATION="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/WebApp"
rm -rf "$DESTINATION"
mkdir -p "$DESTINATION"
cp -R "$SRCROOT/WebApp/." "$DESTINATION"

17
client/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

12
client/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
client/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
client/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
client/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

59
client/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Client
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

97
client/angular.json Normal file
View File

@@ -0,0 +1,97 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "700kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}

7784
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
client/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "client",
"version": "0.0.0",
"scripts": {
"prepare-env": "node scripts/write-env.js",
"ng": "node node_modules/@angular/cli/bin/ng.js",
"start": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js serve",
"build": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js build",
"watch": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js build --watch --configuration development",
"test": "node node_modules/@angular/cli/bin/ng.js test"
},
"private": true,
"packageManager": "npm@11.10.1",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.0",
"dotenv": "^17.3.1",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
}
}

3
client/public/env.js Normal file
View File

@@ -0,0 +1,3 @@
window.__PRIVATECHAT_ENV__ = {
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
};

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,17 @@
const fs = require('node:fs');
const path = require('node:path');
const dotenv = require('dotenv');
const rootEnvPath = path.resolve(__dirname, '../../.env');
const outputPath = path.resolve(__dirname, '../public/env.js');
dotenv.config({ path: rootEnvPath });
const runtimeEnv = {
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
};
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, fileContents, 'utf8');

View File

@@ -0,0 +1,13 @@
import { provideHttpClient, withFetch } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withFetch()),
provideRouter(routes)
]
};

1
client/src/app/app.html Normal file
View File

@@ -0,0 +1 @@
<router-outlet />

View File

@@ -0,0 +1,24 @@
import { Routes } from '@angular/router';
import { ApprovalPageComponent } from './approval-page.component';
import { ChatPageComponent } from './chat-page.component';
import { HomePageComponent } from './home-page.component';
export const routes: Routes = [
{
path: '',
component: HomePageComponent,
},
{
path: 'chat/:peerId',
component: ChatPageComponent,
},
{
path: 'approvals',
component: ApprovalPageComponent,
},
{
path: '**',
redirectTo: '',
},
];

4
client/src/app/app.scss Normal file
View File

@@ -0,0 +1,4 @@
:host {
display: block;
min-height: 100dvh;
}

14
client/src/app/app.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
constructor(_: ThemeService) {}
}

View File

@@ -0,0 +1,45 @@
<main class="approval-shell py-4">
<div class="container-lg">
<section class="panel p-4 p-lg-5">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start gap-3 mb-4">
<div>
<a class="back-link" routerLink="/">← Back to dashboard</a>
<h1 class="h3 mt-2 mb-1">Account approvals</h1>
<p class="text-secondary mb-0">Only <code>ladparis</code> can activate newly registered accounts.</p>
</div>
<span class="badge rounded-pill text-bg-dark">{{ pendingUsers().length }} pending</span>
</div>
@if (errorMessage()) {
<div class="alert alert-danger mb-4">{{ errorMessage() }}</div>
}
@if (loading()) {
<div class="text-secondary">Loading pending accounts...</div>
} @else if (pendingUsers().length === 0) {
<div class="empty-state p-4 text-center text-secondary">No accounts are waiting for approval.</div>
} @else {
<div class="d-grid gap-3">
@for (user of pendingUsers(); track user.id) {
<article class="approval-card d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 p-3">
<div>
<div class="fw-semibold">{{ user.displayName }}</div>
<div class="text-secondary">{{ user.username }}</div>
<div class="small text-secondary">Registered {{ user.createdAt | date: 'medium' }}</div>
</div>
<button
class="btn btn-accent"
type="button"
[disabled]="approvingUserId() === user.id"
(click)="approve(user.id)"
>
{{ approvingUserId() === user.id ? 'Approving...' : 'Approve account' }}
</button>
</article>
}
</div>
}
</section>
</div>
</main>

View File

@@ -0,0 +1,5 @@
.approval-card {
border: 1px solid var(--surface-border-soft);
border-radius: 1rem;
background: var(--panel-soft-background);
}

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import type { PendingApprovalUser } from './models';
@Component({
selector: 'app-approval-page',
imports: [CommonModule, RouterLink],
templateUrl: './approval-page.component.html',
styleUrl: './approval-page.component.scss',
})
export class ApprovalPageComponent {
private readonly router = inject(Router);
readonly pendingUsers = signal<PendingApprovalUser[]>([]);
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly approvingUserId = signal<string | null>(null);
constructor(readonly session: ChatSessionService) {
if (!this.session.isApprovalAdmin()) {
void this.router.navigateByUrl('/');
return;
}
void this.loadPendingUsers();
}
async approve(userId: string): Promise<void> {
this.errorMessage.set(null);
this.approvingUserId.set(userId);
try {
await this.session.approvePendingUser(userId);
this.pendingUsers.update((users) => users.filter((user) => user.id !== userId));
} catch (error) {
this.errorMessage.set(
error instanceof Error ? error.message : 'Could not approve that account.',
);
} finally {
this.approvingUserId.set(null);
}
}
private async loadPendingUsers(): Promise<void> {
this.loading.set(true);
this.errorMessage.set(null);
try {
this.pendingUsers.set(await this.session.loadPendingApprovalUsers());
} catch (error) {
this.errorMessage.set(
error instanceof Error ? error.message : 'Could not load pending account approvals.',
);
} finally {
this.loading.set(false);
}
}
}

View File

@@ -0,0 +1,199 @@
<main class="chat-shell py-4">
<div class="container-lg">
<section class="chat-page panel p-3 p-lg-4">
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
<div>
<a class="back-link" routerLink="/">← Back to dashboard</a>
@if (currentUser(); as connectedUser) {
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
<div class="status-indicators mt-2">
<div class="status-indicator">
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
<span>Signaling</span>
</div>
<div class="status-indicator">
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
<span>WebRTC</span>
</div>
</div>
} @else {
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
}
</div>
@if (peer(); as selectedPeer) {
<button
class="btn btn-outline-light"
type="button"
[disabled]="selectedPeer.channelState === 'open'"
(click)="ensureConnection()"
>
{{ selectedPeer.channelState === 'open' ? 'Connected' : 'Open channel' }}
</button>
}
</div>
<div class="chat-layout">
<aside class="peer-sidebar">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Connected peers</h2>
<p class="small text-secondary mb-0">Switch between active direct chats.</p>
</div>
<span class="peer-count">{{ session.peers().length }}</span>
</div>
<div class="peer-list">
@if (session.peers().length === 0) {
<div class="empty-chat empty-peers">
No peers are currently connected.
</div>
}
@for (connectedPeer of session.peers(); track connectedPeer.id) {
<button
class="peer-tile text-start"
type="button"
[class.peer-tile-active]="connectedPeer.id === peerId()"
(click)="switchPeer(connectedPeer.id)"
>
<div class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
@if (isPeerTyping(connectedPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing">
<span></span>
<span></span>
<span></span>
</span>
}
</span>
<span
class="status-led peer-tile-status"
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
[attr.aria-label]="
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
></span>
</div>
</button>
}
</div>
</aside>
<div class="chat-main">
<div class="conversation">
@if (conversation().length === 0) {
<div class="empty-chat">
No text messages yet. The chat page is ready as soon as the peer channel opens.
</div>
}
@for (entry of conversation(); track entry.id) {
<article
class="bubble"
[class.bubble-incoming]="entry.direction === 'incoming'"
[class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-system]="entry.direction === 'system'"
>
<button
class="bubble-delete"
type="button"
(click)="deleteMessage(entry)"
title="Delete message"
aria-label="Delete message"
>
×
</button>
<div class="bubble-meta">
<span>{{ entry.authorLabel }}</span>
<time>{{ entry.createdAt | date: 'shortTime' }}</time>
</div>
@switch (entry.kind) {
@case ('text') {
<p class="mb-0">{{ entry.text }}</p>
}
@case ('json') {
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
}
@case ('file') {
<div class="d-grid gap-3">
@if (isImageEntry(entry)) {
<img
class="bubble-image"
[src]="entry.downloadUrl"
[alt]="entry.fileName || 'Shared image'"
/>
}
<div>
<div class="fw-semibold">{{ entry.fileName }}</div>
@if (entry.fileSize) {
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
}
</div>
@if (entry.downloadUrl) {
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
}
</div>
}
@default {
<p class="mb-0">{{ entry.text }}</p>
}
}
</article>
}
</div>
<div class="composer">
@if (peer(); as selectedPeer) {
<input
#fileInput
class="composer-file-input"
type="file"
[disabled]="selectedPeer.channelState !== 'open'"
(change)="sendFile(selectedPeer.id, fileInput)"
/>
<button
class="composer-plus"
type="button"
[disabled]="selectedPeer.channelState !== 'open'"
(click)="fileInput.click()"
title="Send file"
aria-label="Send file"
>
+
</button>
}
<textarea
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
></textarea>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</div>
</div>
</div>
</section>
</div>
</main>

View File

@@ -0,0 +1,316 @@
:host {
display: block;
min-height: 100dvh;
color: var(--page-text);
}
.chat-shell {
min-height: 100dvh;
}
.panel {
border: 1px solid var(--surface-border);
border-radius: 1.75rem;
background: var(--panel-background);
backdrop-filter: blur(18px);
box-shadow: 0 20px 60px var(--shadow-color);
}
.back-link {
color: var(--link-color);
text-decoration: none;
}
.status-indicators {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
color: var(--page-text-soft);
}
.status-led {
width: 0.8rem;
height: 0.8rem;
border-radius: 999px;
box-shadow: 0 0 0 1px var(--input-border);
}
.status-led-ok {
background: #59d66f;
}
.status-led-connecting {
background: #f3ad3d;
}
.status-led-offline {
background: #eb5d64;
}
.chat-layout {
display: grid;
grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr);
gap: 1.25rem;
}
.peer-sidebar {
padding: 1rem;
border-radius: 1.3rem;
border: 1px solid var(--surface-border-soft);
background: var(--panel-soft-background);
}
.peer-count {
display: inline-flex;
min-width: 2rem;
justify-content: center;
padding: 0.35rem 0.65rem;
border-radius: 999px;
font-size: 0.85rem;
background: var(--badge-background);
}
.peer-list {
display: grid;
gap: 0.75rem;
max-height: calc(100dvh - 17rem);
overflow: auto;
}
.peer-tile {
width: 100%;
padding: 0.95rem 1rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
color: inherit;
background: var(--surface-background);
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.peer-tile:hover,
.peer-tile:focus-visible,
.peer-tile-active {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
background: var(--surface-hover-background);
}
.peer-tile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.peer-tile-title {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.peer-typing-dots {
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-height: 0.9rem;
}
.peer-typing-dots span {
width: 0.38rem;
height: 0.38rem;
border-radius: 999px;
background: var(--page-text);
opacity: 0.28;
animation: peer-typing-pulse 900ms infinite ease-in-out;
}
.peer-typing-dots span:nth-child(2) {
animation-delay: 120ms;
}
.peer-typing-dots span:nth-child(3) {
animation-delay: 240ms;
}
.peer-tile-status {
flex: 0 0 auto;
}
.chat-main {
min-width: 0;
}
.conversation {
display: grid;
gap: 0.85rem;
min-height: 24rem;
max-height: calc(100dvh - 20rem);
overflow: auto;
padding: 0.5rem 0;
}
.bubble {
position: relative;
max-width: min(75%, 34rem);
padding: 0.9rem 1rem;
border-radius: 1.2rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
}
.bubble-delete {
position: absolute;
top: 0.45rem;
right: 0.55rem;
width: 1.5rem;
height: 1.5rem;
border: 0;
border-radius: 999px;
color: #fff;
background: var(--danger-background);
line-height: 1;
font-size: 1rem;
}
.bubble-incoming {
justify-self: start;
color: var(--incoming-bubble-text);
background: var(--incoming-bubble-background);
}
.bubble-outgoing {
justify-self: end;
color: var(--outgoing-bubble-text);
background: var(--outgoing-bubble-background);
}
.bubble-system {
justify-self: center;
max-width: 90%;
color: var(--page-text-soft);
background: var(--badge-background);
}
.bubble-meta {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.35rem;
font-size: 0.78rem;
opacity: 0.7;
}
.composer {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.9rem;
align-items: end;
padding-top: 1rem;
margin-top: 1rem;
border-top: 1px solid var(--surface-border-soft);
}
.composer-file-input {
display: none;
}
.composer-plus,
.send-emoji {
width: 3.25rem;
height: 3.25rem;
border: 0;
border-radius: 999px;
font-size: 1.35rem;
}
.composer-textarea,
.composer-textarea:focus {
color: var(--page-text);
background-color: var(--input-background);
border-color: var(--input-border);
box-shadow: none;
}
.composer-textarea::placeholder {
color: var(--placeholder-color);
}
.composer-plus {
color: var(--page-text);
background: var(--badge-background);
}
.send-emoji {
background: linear-gradient(135deg, #def7dd, #9bd5ff);
}
.bubble-image {
width: 200px;
max-width: 100%;
height: auto;
border-radius: 1rem;
display: block;
}
.bubble-download {
color: inherit;
font-weight: 600;
}
.bubble-json {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
.empty-chat {
padding: 1.25rem;
border: 1px dashed var(--input-border);
border-radius: 1rem;
color: var(--page-text-muted);
text-align: center;
}
.empty-peers {
min-height: 10rem;
}
.h3,
.small {
color: var(--page-text);
}
@keyframes peer-typing-pulse {
0%,
80%,
100% {
opacity: 0.28;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-1px);
}
}
@media (max-width: 767.98px) {
.chat-layout {
grid-template-columns: 1fr;
}
.peer-list {
max-height: 16rem;
}
.bubble {
max-width: 88%;
}
}

View File

@@ -0,0 +1,151 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import type { ChatEntry, ConnectionState } from './models';
@Component({
selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss',
})
export class ChatPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly routeParamMap = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
messageText = '';
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser());
readonly conversation = computed(() =>
this.session
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
readonly webRtcState = computed<ConnectionState>(() => {
const selectedPeer = this.peer();
if (!selectedPeer) {
return 'disconnected';
}
if (selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected') {
return 'connected';
}
if (selectedPeer.channelState === 'connecting' || selectedPeer.connectionState === 'connecting') {
return 'connecting';
}
return 'disconnected';
});
constructor(readonly session: ChatSessionService) {
if (!this.session.currentUser()) {
void this.router.navigateByUrl('/');
}
effect(() => {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.selectPeer(peerId);
void this.session.connectToPeer(peerId);
});
}
async ensureConnection(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.selectPeer(peerId);
await this.session.connectToPeer(peerId);
}
async sendMessage(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.sendText(peerId, this.messageText);
this.messageText = '';
}
handleComposerEnter(event: Event): void {
if (!(event instanceof KeyboardEvent) || event.shiftKey) {
return;
}
event.preventDefault();
void this.sendMessage();
}
handleMessageTextChange(text: string): void {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.notifyTypingActivity(peerId, text);
}
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0);
if (!file) {
return;
}
await this.session.sendFile(peerId, file);
input.value = '';
}
async deleteMessage(entry: ChatEntry): Promise<void> {
await this.session.deleteMessage(entry);
}
isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
}
isPeerTyping(peerId: string): boolean {
return this.session.typingPeerIds().includes(peerId);
}
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') {
return 'ok';
}
if (state === 'connecting') {
return 'connecting';
}
return 'offline';
}
async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) {
return;
}
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
<main class="shell py-4 py-lg-5">
<div class="container-xl">
@if (!embeddedMode) {
<section class="hero-panel mb-4 mb-lg-5 p-4 p-lg-5">
<div class="d-flex flex-column flex-lg-row align-items-start align-items-lg-center justify-content-between gap-4">
<div class="hero-copy">
<div class="d-flex align-items-center justify-content-between gap-3">
<span class="eyebrow mb-0">WebRTC Private Chat</span>
<button
class="theme-toggle"
type="button"
(click)="cycleTheme()"
[attr.aria-label]="'Theme mode: ' + theme.mode() + '. Switch to ' + theme.nextMode()"
[title]="'Theme mode: ' + theme.mode() + '. Click for ' + theme.nextMode()"
>
<span class="theme-toggle-icon" aria-hidden="true">{{ theme.emoji() }}</span>
<span class="theme-toggle-label">{{ theme.mode() }}</span>
</button>
</div>
</div>
@if (session.currentUser(); as user) {
<div class="session-card p-3 p-lg-4">
<div class="text-uppercase small text-secondary mb-2">Signed in</div>
<div class="h4 mb-1">{{ user.displayName }}</div>
<div class="text-secondary mb-3">{{ user.username }}</div>
<div class="small status-pill mb-3">{{ session.status() }}</div>
<button class="btn btn-accent w-100 mb-2" type="button" [disabled]="!canOpenChatUi()" (click)="openChatUi()">
Open chat UI
</button>
@if (session.isApprovalAdmin()) {
<a class="btn btn-outline-light w-100 mb-2" routerLink="/approvals">Approve accounts</a>
}
<button class="btn btn-outline-light w-100" type="button" (click)="logout()">Log out</button>
</div>
}
</div>
</section>
}
@if (!session.currentUser()) {
@if (embeddedMode) {
<section class="panel p-4 p-lg-5 text-center">
<h2 class="h3 mb-3">Open Settings to sign in</h2>
<p class="text-secondary mb-0">
This embedded client expects authentication and backend configuration from the native app settings.
</p>
</section>
} @else {
<section class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="panel p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
<p class="text-secondary mb-0">Use the Fastify server for authentication and peer discovery.</p>
</div>
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
</div>
<div class="mb-3">
<label class="form-label" for="serverUrl">Backend URL</label>
<input
id="serverUrl"
name="serverUrl"
class="form-control form-control-lg"
[(ngModel)]="serverUrl"
placeholder="http://localhost:3000"
/>
</div>
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
<button
class="btn"
[class.btn-primary]="authMode === 'login'"
[class.btn-outline-primary]="authMode !== 'login'"
type="button"
(click)="authMode = 'login'"
>
Log in
</button>
<button
class="btn"
[class.btn-primary]="authMode === 'register'"
[class.btn-outline-primary]="authMode !== 'register'"
type="button"
(click)="authMode = 'register'"
>
Register
</button>
</div>
<form class="d-grid gap-3" (ngSubmit)="submitAuth()">
@if (authMode === 'register') {
<div>
<label class="form-label" for="displayName">Display name</label>
<input
id="displayName"
name="displayName"
class="form-control form-control-lg"
[(ngModel)]="displayName"
placeholder="Operator One"
/>
</div>
}
<div>
<label class="form-label" for="username">Username</label>
<input
id="username"
name="username"
class="form-control form-control-lg"
[(ngModel)]="username"
placeholder="alice"
[attr.autocomplete]="authMode === 'login' ? 'username webauthn' : 'username'"
/>
</div>
<div>
<label class="form-label" for="password">Password</label>
<input
id="password"
name="password"
type="password"
class="form-control form-control-lg"
[(ngModel)]="password"
placeholder="At least 8 characters"
autocomplete="current-password"
/>
</div>
<button class="btn btn-accent btn-lg mt-2" type="submit">
{{ authMode === 'login' ? 'Enter chat' : 'Create account' }}
</button>
@if (authMode === 'login' && session.webAuthnSupported()) {
<div class="text-center small text-secondary mt-1">or</div>
<button class="btn btn-outline-light btn-lg" type="button" (click)="loginWithAccessKey()">
🔑 Use access key
</button>
<div class="small text-secondary">
Leave the username blank to choose from discoverable passkeys, or enter it to target one account.
</div>
}
</form>
@if (session.error()) {
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
}
@if (session.notice()) {
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
}
</div>
</div>
<div class="col-lg-6">
<div class="panel panel-muted p-4 h-100">
<h2 class="h3 mb-3">Transport model</h2>
<div class="info-rail d-grid gap-3">
<article>
<div class="small text-uppercase text-secondary mb-2">1. Authenticate</div>
<p class="mb-0">Register or log in against the Fastify API to receive a JWT.</p>
</article>
<article>
<div class="small text-uppercase text-secondary mb-2">2. Discover peers</div>
<p class="mb-0">Open the WebSocket signaling session and receive the online peer list.</p>
</article>
<article>
<div class="small text-uppercase text-secondary mb-2">3. Exchange data directly</div>
<p class="mb-0">Create a WebRTC data channel and send text, JSON, or files peer-to-peer.</p>
</article>
</div>
</div>
</div>
</section>
}
} @else {
<section class="row g-4 align-items-stretch">
<div class="col-lg-5">
<div class="panel p-4 h-100">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h3 mb-1">Connection settings</h2>
<p class="text-secondary mb-0">Manage the backend endpoint used for auth and signaling.</p>
</div>
@if (session.isApprovalAdmin()) {
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
}
</div>
@if (!embeddedMode) {
<label class="form-label" for="connectedServerUrl">Backend URL</label>
<div class="input-group mb-3">
<input
id="connectedServerUrl"
class="form-control"
[(ngModel)]="serverUrl"
(blur)="applyServerUrl()"
/>
<button class="btn btn-outline-secondary" type="button" (click)="applyServerUrl()">Apply</button>
</div>
} @else {
<div class="empty-state p-4 text-center text-secondary">
Backend settings are managed by the native app in embedded mode.
</div>
}
<div class="small status-pill mt-3">{{ session.status() }}</div>
@if (session.error()) {
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
}
@if (session.notice()) {
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
}
</div>
</div>
<div class="col-lg-7">
<div class="panel p-4 h-100">
<section class="access-key-panel">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Access keys</h3>
<p class="small text-secondary mb-0">Register one or more WebAuthn credentials for this account.</p>
</div>
<span class="badge rounded-pill text-bg-dark">{{ session.accessKeys().length }}</span>
</div>
@if (!embeddedMode && session.webAuthnSupported()) {
<div class="input-group mb-3">
<input
class="form-control"
[(ngModel)]="accessKeyLabel"
placeholder="Laptop passkey"
/>
<button class="btn btn-outline-light" type="button" (click)="registerAccessKey()">Add key</button>
</div>
} @else if (!embeddedMode) {
<div class="alert alert-warning mb-3">This browser does not expose WebAuthn registration APIs.</div>
} @else {
<div class="empty-state p-3 text-center text-secondary mb-3">
Access keys are managed through the native app in embedded mode.
</div>
}
<div class="d-grid gap-2">
@if (session.accessKeys().length === 0) {
<div class="empty-state p-3 text-center text-secondary">No access keys registered yet.</div>
}
@for (key of session.accessKeys(); track key.id) {
<article class="access-key-card p-3">
<div class="fw-semibold">{{ key.label }}</div>
<div class="small text-secondary">Device: {{ key.deviceType }}{{ key.backedUp ? ' / backed up' : '' }}</div>
<div class="small text-secondary">Transports: {{ key.transports.length > 0 ? key.transports.join(', ') : 'unspecified' }}</div>
<div class="small text-secondary">Added: {{ key.createdAt | date: 'medium' }}</div>
</article>
}
</div>
</section>
</div>
</div>
</section>
}
</div>
</main>

View File

@@ -0,0 +1,156 @@
:host {
display: block;
min-height: 100dvh;
color: var(--page-text);
}
.shell {
min-height: 100dvh;
}
.hero-panel,
.panel,
.session-card,
.empty-state {
border: 1px solid var(--surface-border);
background: var(--panel-background);
backdrop-filter: blur(18px);
box-shadow: 0 20px 60px var(--shadow-color);
}
.hero-panel {
border-radius: 2rem;
}
.panel {
border-radius: 1.5rem;
}
.panel-muted {
background: var(--panel-alt-background);
}
.hero-copy {
max-width: 52rem;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-radius: 999px;
margin-bottom: 1rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 0.72rem;
font-weight: 700;
color: var(--accent-color);
background: var(--accent-color-soft);
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-width: 7.5rem;
height: 3rem;
padding: 0 0.95rem;
border: 1px solid var(--surface-border);
border-radius: 999px;
color: var(--page-text);
background: var(--panel-soft-background);
font-size: 0.95rem;
font-weight: 700;
text-transform: capitalize;
line-height: 1;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}
.theme-toggle-icon {
font-size: 1.25rem;
}
.theme-toggle-label {
letter-spacing: 0.03em;
}
.theme-toggle:hover,
.theme-toggle:focus-visible {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
background: var(--surface-hover-background);
}
.session-card {
min-width: min(100%, 18rem);
border-radius: 1.5rem;
}
.status-pill {
display: inline-flex;
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: var(--badge-background);
}
.btn-accent {
color: #06111d;
border: 0;
background: var(--accent-gradient);
}
.btn-accent:hover,
.btn-accent:focus-visible {
color: #06111d;
background: var(--accent-gradient-hover);
}
.access-key-panel {
padding: 1rem;
border-radius: 1rem;
background: var(--panel-soft-background);
}
.access-key-card {
border-radius: 0.9rem;
border: 1px solid var(--surface-border-soft);
background: var(--surface-background);
}
.empty-state {
border-radius: 1.25rem;
}
.info-rail article {
padding: 1rem 1.1rem;
border-radius: 1rem;
background: var(--panel-soft-background);
}
.form-control,
.form-control:focus {
color: var(--page-text);
background-color: var(--input-background);
border-color: var(--input-border);
box-shadow: none;
}
.form-control::placeholder {
color: var(--placeholder-color);
}
.form-label,
.h3,
.h4,
.display-5,
.fw-semibold,
.fw-bold {
color: var(--page-text);
}
.text-secondary,
.lead,
.small {
color: var(--page-text-muted) !important;
}

View File

@@ -0,0 +1,102 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-home-page',
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './home-page.component.html',
styleUrl: './home-page.component.scss',
})
export class HomePageComponent {
private readonly router = inject(Router);
readonly theme = inject(ThemeService);
authMode: 'login' | 'register' = 'login';
readonly embeddedMode =
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
serverUrl = '';
displayName = '';
username = '';
password = '';
accessKeyLabel = '';
constructor(readonly session: ChatSessionService) {
this.serverUrl = session.serverUrl();
if (this.embeddedMode) {
effect(() => {
const currentUser = this.session.currentUser();
const activePeerId = this.session.activePeerId();
if (!currentUser || !activePeerId) {
return;
}
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
});
}
}
async submitAuth(): Promise<void> {
this.applyServerUrl();
if (this.authMode === 'register') {
const authenticated = await this.session.register(this.username, this.password, this.displayName);
this.password = '';
if (!authenticated) {
this.authMode = 'login';
}
return;
}
await this.session.login(this.username, this.password);
}
applyServerUrl(): void {
this.session.setServerUrl(this.serverUrl);
}
async logout(): Promise<void> {
await this.session.logout();
this.authMode = 'login';
this.displayName = '';
this.password = '';
}
async loginWithAccessKey(): Promise<void> {
this.applyServerUrl();
await this.session.loginWithAccessKey(this.username);
this.password = '';
}
async registerAccessKey(): Promise<void> {
await this.session.registerAccessKey(this.accessKeyLabel);
this.accessKeyLabel = '';
}
canOpenChatUi(): boolean {
return this.session.peers().length > 0;
}
async openChatUi(): Promise<void> {
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
if (!peerId) {
this.session.error.set('No connected peers are available yet.');
return;
}
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
cycleTheme(): void {
this.theme.cycleMode();
}
}

144
client/src/app/models.ts Normal file
View File

@@ -0,0 +1,144 @@
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'failed';
export type ChannelState = 'closed' | 'connecting' | 'open';
export interface UserProfile {
id: string;
username: string;
displayName: string;
}
export interface PeerSummary extends UserProfile {
connectionState: ConnectionState;
channelState: ChannelState;
}
export interface AuthResponse {
token: string;
user: UserProfile;
messageEncryptionKey: string;
}
export interface SessionResponse {
user: UserProfile;
messageEncryptionKey: string;
}
export interface PendingApprovalResponse {
pendingApproval: true;
message: string;
}
export interface PendingApprovalUser {
id: string;
username: string;
displayName: string;
createdAt: string;
}
export interface AccessKeySummary {
id: string;
credentialId: string;
label: string;
transports: string[];
deviceType: string;
backedUp: boolean;
aaguid: string;
createdAt: string;
}
export interface RegistrationOptionsResponse {
rp: PublicKeyCredentialRpEntity;
user: {
id: string;
name: string;
displayName: string;
};
challenge: string;
pubKeyCredParams: PublicKeyCredentialParameters[];
timeout?: number;
excludeCredentials?: Array<{
id: string;
type: PublicKeyCredentialType;
transports?: string[];
}>;
authenticatorSelection?: AuthenticatorSelectionCriteria;
attestation?: AttestationConveyancePreference;
extensions?: AuthenticationExtensionsClientInputs;
}
export interface AuthenticationOptionsResponse {
attemptId: string;
challenge: string;
timeout?: number;
rpId?: string;
allowCredentials?: Array<{
id: string;
type: PublicKeyCredentialType;
transports?: string[];
}>;
userVerification?: UserVerificationRequirement;
hints?: string[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface ChatEntry {
id: string;
peerId: string;
direction: 'incoming' | 'outgoing' | 'system';
kind: 'text' | 'json' | 'file' | 'system';
createdAt: number;
authorLabel: string;
text?: string;
payload?: unknown;
fileName?: string;
fileSize?: number;
fileMimeType?: string;
downloadUrl?: string;
}
export type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
export type ServerEvent =
| { type: 'presence'; self: UserProfile; peers: UserProfile[] }
| { type: 'peer-joined'; peer: UserProfile }
| { type: 'peer-left'; peerId: string }
| { type: 'signal'; from: string; signal: SignalPayload }
| { type: 'error'; message: string };
export type DataEnvelope =
| {
type: 'text';
id: string;
body: string;
authorId: string;
authorName: string;
sentAt: number;
}
| {
type: 'json';
id: string;
body: unknown;
authorId: string;
authorName: string;
sentAt: number;
}
| {
type: 'file-meta';
id: string;
name: string;
mimeType: string;
size: number;
authorId: string;
authorName: string;
sentAt: number;
}
| {
type: 'file-complete';
id: string;
}
| {
type: 'typing';
active: boolean;
};

View File

@@ -0,0 +1,110 @@
import { Injectable, computed, signal } from '@angular/core';
export type ThemeMode = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly storageKey = 'privatechat.themeMode';
private readonly mediaQuery =
typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null;
readonly mode = signal<ThemeMode>(this.readStoredMode());
readonly resolvedTheme = computed<ResolvedTheme>(() => {
const mode = this.mode();
if (mode === 'system') {
return this.mediaQuery?.matches ? 'dark' : 'light';
}
return mode === 'dark' ? 'dark' : 'light';
});
readonly emoji = computed(() => {
switch (this.mode()) {
case 'light':
return '🌞';
case 'dark':
return '🌙';
default:
return '🖥️';
}
});
readonly nextMode = computed<ThemeMode>(() => {
switch (this.mode()) {
case 'light':
return 'dark';
case 'dark':
return 'system';
default:
return 'light';
}
});
constructor() {
this.applyTheme();
const mediaQuery = this.mediaQuery as
| (MediaQueryList & {
addListener?: (listener: () => void) => void;
})
| null;
if (mediaQuery) {
try {
mediaQuery.addEventListener('change', this.handleSystemThemeChange);
} catch {
mediaQuery.addListener?.(this.handleSystemThemeChange);
}
}
}
cycleMode(): void {
this.setMode(this.nextMode());
}
setMode(mode: ThemeMode): void {
this.mode.set(mode);
this.writeStoredMode(mode);
this.applyTheme();
}
private readonly handleSystemThemeChange = (): void => {
if (this.mode() === 'system') {
this.applyTheme();
}
};
private applyTheme(): void {
if (typeof document === 'undefined') {
return;
}
document.documentElement.dataset['themeMode'] = this.mode();
document.documentElement.dataset['theme'] = this.resolvedTheme();
document.documentElement.dataset['bsTheme'] = this.resolvedTheme();
document.documentElement.style.colorScheme = this.resolvedTheme();
}
private readStoredMode(): ThemeMode {
try {
const value = localStorage.getItem(this.storageKey);
if (value === 'light' || value === 'dark' || value === 'system') {
return value;
}
} catch {
// Ignore storage access issues.
}
return 'system';
}
private writeStoredMode(mode: ThemeMode): void {
try {
localStorage.setItem(this.storageKey, mode);
} catch {
// Ignore storage access issues.
}
}
}

17
client/src/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PrivateChat</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src="env.js"></script>
</head>
<body>
<app-root></app-root>
</body>
</html>

6
client/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

179
client/src/styles.scss Normal file
View File

@@ -0,0 +1,179 @@
@use 'bootstrap/scss/bootstrap';
:root {
--page-text: #142236;
--page-text-muted: rgba(39, 63, 91, 0.72);
--page-text-soft: rgba(39, 63, 91, 0.82);
--page-background:
radial-gradient(circle at top left, rgba(81, 168, 255, 0.2), transparent 30%),
radial-gradient(circle at top right, rgba(129, 244, 215, 0.22), transparent 24%),
linear-gradient(180deg, #f6fbff 0%, #e8f1fb 100%);
--panel-background: rgba(255, 255, 255, 0.82);
--panel-alt-background: rgba(241, 247, 255, 0.9);
--panel-soft-background: rgba(20, 34, 54, 0.04);
--surface-background: rgba(255, 255, 255, 0.82);
--surface-hover-background: rgba(235, 244, 255, 0.98);
--surface-border: rgba(33, 62, 94, 0.12);
--surface-border-soft: rgba(33, 62, 94, 0.08);
--input-background: rgba(255, 255, 255, 0.92);
--input-border: rgba(77, 114, 154, 0.26);
--placeholder-color: rgba(55, 83, 118, 0.52);
--accent-color: #138a7b;
--accent-color-soft: rgba(19, 138, 123, 0.1);
--accent-gradient: linear-gradient(135deg, #8df0df, #6cb6ff);
--accent-gradient-hover: linear-gradient(135deg, #a6f5e8, #86c4ff);
--link-color: #2f7cd6;
--badge-background: rgba(20, 34, 54, 0.08);
--incoming-bubble-background: #d9ebff;
--incoming-bubble-text: #183759;
--outgoing-bubble-background: #d9f5df;
--outgoing-bubble-text: #1e4d2f;
--danger-background: #d94b53;
--shadow-color: rgba(41, 73, 110, 0.14);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--page-text: #eff3ff;
--page-text-muted: rgba(231, 238, 249, 0.72);
--page-text-soft: rgba(231, 238, 249, 0.84);
--page-background:
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
linear-gradient(180deg, #08111d 0%, #101d31 100%);
--panel-background: rgba(9, 16, 28, 0.78);
--panel-alt-background: rgba(15, 27, 44, 0.78);
--panel-soft-background: rgba(255, 255, 255, 0.04);
--surface-background: rgba(8, 14, 23, 0.7);
--surface-hover-background: rgba(16, 30, 49, 0.92);
--surface-border: rgba(255, 255, 255, 0.12);
--surface-border-soft: rgba(255, 255, 255, 0.08);
--input-background: rgba(255, 255, 255, 0.06);
--input-border: rgba(255, 255, 255, 0.16);
--placeholder-color: rgba(239, 243, 255, 0.5);
--accent-color: #81f4d7;
--accent-color-soft: rgba(129, 244, 215, 0.1);
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
--link-color: #9bd5ff;
--badge-background: rgba(255, 255, 255, 0.08);
--incoming-bubble-background: #dcefff;
--incoming-bubble-text: #0f2540;
--outgoing-bubble-background: #def7dd;
--outgoing-bubble-text: #153420;
--danger-background: #d94b53;
--shadow-color: rgba(0, 0, 0, 0.28);
color-scheme: dark;
}
}
:root[data-theme='dark'] {
--page-text: #eff3ff;
--page-text-muted: rgba(231, 238, 249, 0.72);
--page-text-soft: rgba(231, 238, 249, 0.84);
--page-background:
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
linear-gradient(180deg, #08111d 0%, #101d31 100%);
--panel-background: rgba(9, 16, 28, 0.78);
--panel-alt-background: rgba(15, 27, 44, 0.78);
--panel-soft-background: rgba(255, 255, 255, 0.04);
--surface-background: rgba(8, 14, 23, 0.7);
--surface-hover-background: rgba(16, 30, 49, 0.92);
--surface-border: rgba(255, 255, 255, 0.12);
--surface-border-soft: rgba(255, 255, 255, 0.08);
--input-background: rgba(255, 255, 255, 0.06);
--input-border: rgba(255, 255, 255, 0.16);
--placeholder-color: rgba(239, 243, 255, 0.5);
--accent-color: #81f4d7;
--accent-color-soft: rgba(129, 244, 215, 0.1);
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
--link-color: #9bd5ff;
--badge-background: rgba(255, 255, 255, 0.08);
--incoming-bubble-background: #dcefff;
--incoming-bubble-text: #0f2540;
--outgoing-bubble-background: #def7dd;
--outgoing-bubble-text: #153420;
--danger-background: #d94b53;
--shadow-color: rgba(0, 0, 0, 0.28);
color-scheme: dark;
}
:root[data-theme='light'] {
color-scheme: light;
}
html,
body {
min-height: 100dvh;
}
body {
margin: 0;
color: var(--page-text);
font-family: 'Space Grotesk', system-ui, sans-serif;
background: var(--page-background);
background-attachment: fixed;
transition:
background 180ms ease,
color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
button,
input,
textarea {
font: inherit;
}
.text-secondary {
color: var(--page-text-muted) !important;
}
.text-bg-dark {
color: var(--page-text) !important;
background: var(--badge-background) !important;
}
.btn-outline-light {
color: var(--page-text);
border-color: var(--surface-border);
}
.btn-outline-light:hover,
.btn-outline-light:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background);
}
.btn-outline-secondary {
color: var(--page-text-muted);
border-color: var(--surface-border);
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background);
}
.btn-outline-primary {
color: var(--link-color);
border-color: color-mix(in srgb, var(--link-color) 32%, transparent);
}
.btn-primary {
border-color: transparent;
background: var(--accent-gradient);
}
.alert-danger,
.alert-success,
.alert-warning {
border: 1px solid var(--surface-border);
}

15
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

30
client/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}

15
client/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}

329
package-lock.json generated Normal file
View File

@@ -0,0 +1,329 @@
{
"name": "private-chat",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "private-chat",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^9.2.1"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "private-chat",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "node node_modules/concurrently/dist/bin/concurrently.js -k -n server,client -c green,blue \"npm run dev --prefix server\" \"npm run start --prefix client\"",
"dev:server": "npm run dev --prefix server",
"dev:client": "npm run start --prefix client",
"build": "npm run build --prefix server && npm run build --prefix client",
"start": "npm run build && npm run start --prefix server"
},
"devDependencies": {
"concurrently": "^9.2.1"
}
}

1026
server/dist/index.js vendored Normal file

File diff suppressed because it is too large Load Diff

2166
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "private-chat-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node node_modules/tsx/dist/cli.mjs watch src/index.ts",
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/static": "^9.0.0",
"@fastify/websocket": "^11.2.0",
"@simplewebauthn/server": "^13.2.3",
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"ioredis": "^5.10.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.3.5",
"@types/ws": "^8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

1540
server/src/index.ts Normal file

File diff suppressed because it is too large Load Diff

15
server/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}