122 Commits

Author SHA1 Message Date
2916d17868 Summary Update. 2026-06-06 23:38:50 -04:00
dd75ca4b06 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	RelayClient/MainPage.xaml.cs
#	RelayClient/Services/RelaySocketClient.cs
#	RelayServer/Services/Chat/ChatSocketBehavior.cs
#	RelayServer/Services/Chat/ConnectedClientService.cs
#	RelayShared/Services/WsControlMessage.cs
2026-06-03 13:20:15 -04:00
f819d7284e Update: Text Channel Stuff
Bugs: Files don't work
Bugs: Video In-Line don't work

Added: idk, everything?
2026-06-03 13:19:21 -04:00
b62ceb1949 Updated, the update... should be working now hopefully... 2026-05-30 21:11:33 -04:00
cd2d809322 Update: Seems everything is working now? 2026-05-25 01:06:19 -04:00
1ed3efcc68 License Gen and Verification added to core 2026-05-17 04:06:28 -04:00
9fbe795660 fixed IPs back to local 2026-05-14 23:18:11 -04:00
63d3806936 working on server authenticate users 2026-05-13 23:47:33 -04:00
a9d2fd64de Signin and Register now give a token added ps1 script to start servers 2026-05-04 21:46:48 -04:00
f8b595f609 need to solve DB read issue 2026-05-04 20:51:46 -04:00
885db41ba9 Merge branch 'CoreAuth' 2026-05-03 18:04:55 -04:00
3460ce6b04 need server and core webapp to work at same time for testing purposes 2026-05-03 18:04:40 -04:00
4974663128 removed test delay 2026-05-02 16:13:19 -04:00
ec6a8c446a Auth setup continued 2026-05-02 16:06:08 -04:00
3901542141 AUDIO FUCKING WORKS - Test Camera. 2026-05-02 16:04:23 -04:00
33eee17c43 Beginnings of Core Auth 2026-04-30 19:08:37 -04:00
dd1aa45f6e Fixed connections 2026-04-29 14:39:28 -04:00
38662f6655 Fix attempts for RTC calls 2026-04-29 13:52:35 -04:00
777328caed Merge branch 'RTC-Rewrite' 2026-04-29 09:13:58 -04:00
87ade75f1d Merge remote-tracking branch 'origin/RTC-Rewrite' into RTC-Rewrite
# Conflicts:
#	RelayClient/MainPage.xaml.cs
2026-04-29 09:11:58 -04:00
798652cb4d Set the target of RTC to _rtc and removed all Pass Through Methods. 2026-04-29 09:10:49 -04:00
6a650a282b fixed spacing and added todo 2026-04-27 14:32:40 -04:00
5b10afcec2 Rewrote all of MainPage.xaml.cs 2026-04-27 10:01:59 -04:00
1220654656 Setup new services required for change 2026-04-27 10:01:02 -04:00
be797c55c2 Merge remote-tracking branch 'origin/RTC-Rewrite' into RTC-Rewrite 2026-04-27 06:54:21 -04:00
20e99fefec Fixed missing merge stuff. 2026-04-27 06:54:12 -04:00
f77a5eb823 Merge remote-tracking branch 'origin/RTC-Rewrite' into RTC-Rewrite 2026-04-27 06:49:55 -04:00
d6ecb63b5f MainPage cleanup 2026-04-27 06:49:44 -04:00
687387b105 Reviewed and Updated models for Shared project 2026-04-26 02:33:40 -04:00
5486795f4d Added Peer stuff to RelayRTC
I've confirmed any and all issues past this point is C# related, and client is done being rewritten.
2026-04-26 00:18:52 -04:00
b90144d335 More logging on the RTC Signal 2026-04-26 00:18:14 -04:00
9a3e435dd4 Fixed cases of no Media Content, and swapping Media failing. 2026-04-26 00:14:47 -04:00
c4dfb07627 Fixed Ice OBJECT not returning/sending correctly. 2026-04-26 00:09:39 -04:00
92c432cd49 Client Code Done - Needs Bug Fixing 2026-04-26 00:06:49 -04:00
a52ae2f4a4 Encryption Sent, Encrpytion Decoded, Offer Sent, Offer Recieved, JS -> C# / C# -> JS Broke (some disconnect here) SendRtcSignalToJsAsync 2026-04-24 05:18:50 -04:00
0c9ff3b5d9 Setting stage for channel types and groups 2026-04-22 21:35:21 -04:00
3b75c2b785 Merge branch 'LeaveCall' 2026-04-21 01:15:13 -04:00
4f6bbcf6e2 A step in the right direction 2026-04-21 01:14:44 -04:00
a2608ffab9 This isn't FULLY functional, but it's what I've made thus far... I'm still working on a Ice Disconnect somewhere at least for me - welcome to test. 2026-04-18 18:05:22 -04:00
b70189c619 Verify RTS Push to JS (fixes to application) 2026-04-17 16:47:30 -04:00
88c5d597d3 Removed useless button 2026-04-14 21:59:31 -04:00
4a8170c448 Removed File 2026-04-14 21:59:18 -04:00
a56e246095 Fixed Messaging Bug (inversed a if statement) 2026-04-13 22:57:39 -04:00
fba86881ec Renames complete. 2026-04-13 17:06:10 -04:00
28be2ae6c3 Added TODOs and fixed some typings 2026-04-13 17:02:31 -04:00
627d67be39 Shutdown ("Exit", "Stop") now exist 2026-04-13 16:27:12 -04:00
085507519a updated to shared lib 2026-04-11 18:42:29 -04:00
a67f94b08e Merge pull request 'Shared-Files' (#2) from Shared-Files into main
Reviewed-on: #2
2026-04-10 18:15:17 +00:00
c3b8dc5061 Shared System completed. Test for bugs. 2026-04-10 14:14:35 -04:00
e855948ca9 Merge branch 'refs/heads/main' into Shared-Files 2026-04-10 14:13:01 -04:00
63a12b8d17 review for cleanup 2026-04-10 00:55:15 -04:00
dc37933fb8 cleanup prep and leave call prep 2026-04-09 16:53:29 -04:00
9ad1d898ff Starting work on Shared Files 2026-04-09 10:51:04 -04:00
dad5de3d7f CALL WORKS, NEEDS TO HAVE LEAVE CALL SETUP AND HOTSWAP FIXED 2026-04-08 22:29:29 -04:00
c03e5102fb Merge remote-tracking branch 'origin/main' 2026-04-08 18:56:36 -04:00
9e587ad7b5 fixed missing audio/video devices 2026-04-08 18:56:30 -04:00
8fb9126072 Merge remote-tracking branch 'origin/main' 2026-04-08 18:53:18 -04:00
e18e61710e Added Hotswapping to Settings. 2026-04-08 18:53:07 -04:00
cec2d7593f making ice candidates write to DB properly 2026-04-08 18:48:15 -04:00
dff05dd596 ice candidates gathering working, needs to get public ips 2026-04-07 17:30:34 -04:00
31646a315a cleaned up extra debug messages 2026-04-06 20:59:19 -04:00
7af9cd0df8 Auto stash before merge of "main" and "origin/main" 2026-04-06 20:48:57 -04:00
68a905a292 Update 2026-04-06 20:46:55 -04:00
df438e265b Revert "It's still broken, but I made a bit of progress."
This reverts commit 9b666ee109.
2026-04-06 20:43:03 -04:00
98a837cc8b Dark Mode 2026-04-06 19:40:09 -04:00
9b666ee109 It's still broken, but I made a bit of progress. 2026-04-06 18:57:46 -04:00
3c1a4c7a2d brokededed again 2026-04-06 17:42:14 -04:00
7d8755ca71 Fixed all underlying issues with the "Answer" call. 2026-04-06 16:08:37 -04:00
aa7f6597c4 Merge broketh my code 2026-04-06 15:31:39 -04:00
9f4d4eaa15 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	RelayServer/Services/Chat/ChatSocketBehavior.cs
2026-04-06 15:27:23 -04:00
e5a1166bec Changed thread / added helper for UI based messages on MAIN thread. 2026-04-06 15:26:23 -04:00
e7994f00b1 debug lines added 2026-04-06 14:44:33 -04:00
7af1295754 Updatedededed...? Still needs testing. 2026-04-04 17:06:35 -04:00
3aec6e23b2 fixeded the hardcoded values AGAIN 2026-04-04 16:35:23 -04:00
3f27c94032 Update: Needs Testing - Not Tested 2026-04-04 16:17:57 -04:00
c89a0cf88b setup client to make calls to servers api for webrtc data management 2026-04-03 19:45:06 -04:00
63e427a4a1 added logo files to app icon 2026-04-03 19:44:38 -04:00
b7af055845 added websocket todos 2026-04-03 19:44:22 -04:00
941dcc16d9 fixed endpoint data 2026-04-03 19:44:07 -04:00
ebda006010 Is bool again. 2026-04-03 17:03:26 -04:00
cf70b82024 updated RtcOffer to reflect proper communication needs and added todo 2026-04-03 16:24:13 -04:00
f24a255d12 Update: Edited to change JSON formatting. 2026-04-03 15:22:53 -04:00
776889932e Update: Combined Endpoints to do correct jobs. 2026-04-03 09:35:07 -04:00
701e30c31b more TODOs 2026-04-03 09:02:57 -04:00
9a6fcfb6de added offers list to api 2026-04-02 19:32:17 -04:00
5a69ea627e Update: API Call Fix 2026-04-02 18:20:48 -04:00
fe2473be21 Finished. Have at it. 2026-04-02 17:16:05 -04:00
e4e7a70b2c should probs push the removal of that file. 2026-04-02 16:25:52 -04:00
bdaf793094 Updated GitIgnore 2026-04-02 15:44:31 -04:00
65eb163482 fixed params in function call 2026-04-02 13:59:06 -04:00
6d9c3c9a40 additional TODOs 2026-04-02 13:58:51 -04:00
92db27edc4 presetup client for server api calls for webrtc calls 2026-04-02 13:51:26 -04:00
cb59cc4409 added todos 2026-04-02 13:51:04 -04:00
c193061fe3 Updateded 2026-04-02 13:45:58 -04:00
a4f0175ca4 Some orgnization, and cleanup to come. 2026-04-02 13:36:54 -04:00
6287f4d19b AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 2026-04-01 14:32:23 -04:00
bb34b7b0fa Added basic code as channelCallJoin in index.js
included more TODOs in function
2026-03-31 18:59:25 -04:00
dc3d954757 Moved JS to own file and added TODOs 2026-03-31 17:11:12 -04:00
2269866539 added ui design doc outline 2026-03-31 09:47:57 -04:00
e9f96b7389 Update: Channel Based VC (Same Issues) 2026-03-31 09:47:15 -04:00
6fedad92b1 update: Device Selection + No Cam Required 2026-03-31 04:58:00 -04:00
8c6724038a Added WebRTC stuff - Needs Testing. 2026-03-29 15:18:57 -04:00
0bb3aa28b1 updated and connected webview to C# 2026-03-28 05:18:05 -04:00
a5772d7579 swapping to webview webrtc setup as temp solution
will build a custom C# webrtc implementation later
2026-03-26 03:32:58 -04:00
3d5c35fb15 Init: Debugging 2026-03-22 05:36:10 -04:00
619f1add51 it broke 2026-03-22 04:43:43 -04:00
caf020c393 Update: Mutli Channel Support 2026-03-22 01:54:52 -04:00
69a4951579 Added new channel for testing 2026-03-22 00:10:59 -04:00
d0839308ef Started API, will wait til later 2026-03-22 00:10:42 -04:00
8a771220e4 Update: Full E2EE + Scripts 2026-03-21 04:45:49 -04:00
cc31c4024a Merge branch 'socketServer' 2026-03-21 00:00:25 -04:00
2b2b16271b bare basic WS setup 2026-03-20 23:59:08 -04:00
4961ced384 Merge remote-tracking branch 'origin/main' into socketServer 2026-03-20 22:48:50 -04:00
187c8de6d3 start of socket 2026-03-20 22:48:32 -04:00
2dfc898e8a Multi Client Support - Needs Update Post Socket to support Multiple "Clients" not in a single Bus. 2026-03-20 22:48:04 -04:00
44fa9a8bb2 Fix: Removed BOM from files. 2026-03-19 16:51:50 -04:00
aac69ea770 Update: Added RelayServer Logic 2026-03-19 16:48:56 -04:00
46ba326150 Updated to AnonPro 2026-03-16 00:05:59 -04:00
9bc41d9a6d Updated Structure, and fixed paths.
- Project does not work still in this version as communication is still not happening between Client/Server/Core. Fake UI is still somewhat needed to progress
2026-03-15 23:51:51 -04:00
cc921f4b70 Added correct fonts to client program 2026-03-15 23:12:50 -04:00
916801e524 Added folders and files for RelayCore 2026-03-15 23:12:37 -04:00
d3d52c3553 remade into 1 project
remade into 1 project
2026-03-14 18:41:39 -04:00
124 changed files with 10410 additions and 1951 deletions

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

184
.gitignore vendored
View File

@@ -1,93 +1,93 @@
############################################ ############################################
# .NET Build # .NET Build
############################################ ############################################
bin/ bin/
obj/ obj/
out/ out/
publish/ publish/
############################################ ############################################
# Visual Studio # Visual Studio
############################################ ############################################
.vs/ .vs/
*.user *.user
*.suo *.suo
*.userprefs *.userprefs
*.csproj.user *.csproj.user
*.dbmdl *.dbmdl
*.cache *.cache
*.pdb *.pdb
*.opendb *.opendb
############################################ ############################################
# Rider / JetBrains # Rider / JetBrains
############################################ ############################################
.idea/ .idea/
*.sln.iml *.sln.iml
############################################ ############################################
# VSCode # VSCode
############################################ ############################################
.vscode/ .vscode/
############################################ ############################################
# NuGet # NuGet
############################################ ############################################
*.nupkg *.nupkg
*.snupkg *.snupkg
packages/ packages/
.nuget/ .nuget/
.nuget/packages/ .nuget/packages/
############################################ ############################################
# Logs # Logs
############################################ ############################################
*.log *.log
logs/ logs/
############################################ ############################################
# OS files # OS files
############################################ ############################################
.DS_Store .DS_Store
Thumbs.db Thumbs.db
############################################ ############################################
# Local secrets / environment # Local secrets / environment
############################################ ############################################
.env .env
.env.* .env.*
secrets.json secrets.json
appsettings.Development.json appsettings.Development.json
############################################ ############################################
# E2EE private keys # E2EE private keys
############################################ ############################################
keys/* keys/*
!keys/.gitkeep !keys/.gitkeep
############################################ ############################################
# Local test databases / data folders # Local test databases / data folders
############################################ ############################################
data/ data/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
############################################ ############################################
# Temporary files # Temporary files
############################################ ############################################
*.tmp *.tmp
*.temp *.temp
*.bak *.bak
*.swp *.swp

138
Relay.sln
View File

@@ -1,62 +1,76 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayCore", "RelayCore\RelayCore.csproj", "{346BE501-DE74-4E88-9787-4722FBC8BD0D}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayCore", "RelayCore\RelayCore.csproj", "{346BE501-DE74-4E88-9787-4722FBC8BD0D}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayClient", "RelayClient\RelayClient.csproj", "{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayClient", "RelayClient\RelayClient.csproj", "{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayServer", "RelayServer\RelayServer.csproj", "{38995780-E9AA-44D6-B62D-07CCA45E4E4C}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayServer", "RelayServer\RelayServer.csproj", "{38995780-E9AA-44D6-B62D-07CCA45E4E4C}"
EndProject EndProject
Global Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayShared", "RelayShared\RelayShared.csproj", "{60B17B0B-9910-426A-9B48-AD9377AC89F7}"
GlobalSection(SolutionConfigurationPlatforms) = preSolution EndProject
Debug|Any CPU = Debug|Any CPU Global
Debug|x64 = Debug|x64 GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x86 = Debug|x86 Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU Debug|x64 = Debug|x64
Release|x64 = Release|x64 Debug|x86 = Debug|x86
Release|x86 = Release|x86 Release|Any CPU = Release|Any CPU
EndGlobalSection Release|x64 = Release|x64
GlobalSection(ProjectConfigurationPlatforms) = postSolution Release|x86 = Release|x86
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU EndGlobalSection
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|Any CPU.Build.0 = Debug|Any CPU GlobalSection(ProjectConfigurationPlatforms) = postSolution
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x64.ActiveCfg = Debug|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x64.Build.0 = Debug|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x86.ActiveCfg = Debug|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x64.ActiveCfg = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x86.Build.0 = Debug|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x64.Build.0 = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x86.ActiveCfg = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|Any CPU.Build.0 = Release|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x86.Build.0 = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x64.ActiveCfg = Release|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x64.Build.0 = Release|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|Any CPU.Build.0 = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x86.ActiveCfg = Release|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x64.ActiveCfg = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x86.Build.0 = Release|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x64.Build.0 = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x86.ActiveCfg = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|Any CPU.Build.0 = Debug|Any CPU {346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x86.Build.0 = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x64.ActiveCfg = Debug|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x64.Build.0 = Debug|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x86.ActiveCfg = Debug|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x64.ActiveCfg = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x86.Build.0 = Debug|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x64.Build.0 = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x86.ActiveCfg = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|Any CPU.Build.0 = Release|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x86.Build.0 = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x64.ActiveCfg = Release|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x64.Build.0 = Release|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|Any CPU.Build.0 = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x86.ActiveCfg = Release|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x64.ActiveCfg = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x86.Build.0 = Release|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x64.Build.0 = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x86.ActiveCfg = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU {AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x86.Build.0 = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x64.ActiveCfg = Debug|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x64.Build.0 = Debug|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x86.ActiveCfg = Debug|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x64.ActiveCfg = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x86.Build.0 = Debug|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x64.Build.0 = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x86.ActiveCfg = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|Any CPU.Build.0 = Release|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x86.Build.0 = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x64.ActiveCfg = Release|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x64.Build.0 = Release|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|Any CPU.Build.0 = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x86.ActiveCfg = Release|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x64.ActiveCfg = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x86.Build.0 = Release|Any CPU {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x86.ActiveCfg = Release|Any CPU
GlobalSection(SolutionProperties) = preSolution {38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x86.Build.0 = Release|Any CPU
HideSolutionNode = FALSE {60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
EndGlobalSection {60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
EndGlobal {60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|x64.ActiveCfg = Debug|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|x64.Build.0 = Debug|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|x86.ActiveCfg = Debug|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|x86.Build.0 = Debug|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Release|Any CPU.Build.0 = Release|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Release|x64.ActiveCfg = Release|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Release|x64.Build.0 = Release|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Release|x86.ActiveCfg = Release|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

184
RelayClient/.gitignore vendored
View File

@@ -1,93 +1,93 @@
############################################ ############################################
# .NET Build # .NET Build
############################################ ############################################
bin/ bin/
obj/ obj/
out/ out/
publish/ publish/
############################################ ############################################
# Visual Studio # Visual Studio
############################################ ############################################
.vs/ .vs/
*.user *.user
*.suo *.suo
*.userprefs *.userprefs
*.csproj.user *.csproj.user
*.dbmdl *.dbmdl
*.cache *.cache
*.pdb *.pdb
*.opendb *.opendb
############################################ ############################################
# Rider / JetBrains # Rider / JetBrains
############################################ ############################################
.idea/ .idea/
*.sln.iml *.sln.iml
############################################ ############################################
# VSCode # VSCode
############################################ ############################################
.vscode/ .vscode/
############################################ ############################################
# NuGet # NuGet
############################################ ############################################
*.nupkg *.nupkg
*.snupkg *.snupkg
packages/ packages/
.nuget/ .nuget/
.nuget/packages/ .nuget/packages/
############################################ ############################################
# Logs # Logs
############################################ ############################################
*.log *.log
logs/ logs/
############################################ ############################################
# OS files # OS files
############################################ ############################################
.DS_Store .DS_Store
Thumbs.db Thumbs.db
############################################ ############################################
# Local secrets / environment # Local secrets / environment
############################################ ############################################
.env .env
.env.* .env.*
secrets.json secrets.json
appsettings.Development.json appsettings.Development.json
############################################ ############################################
# E2EE private keys # E2EE private keys
############################################ ############################################
keys/* keys/*
!keys/.gitkeep !keys/.gitkeep
############################################ ############################################
# Local test databases / data folders # Local test databases / data folders
############################################ ############################################
data/ data/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
############################################ ############################################
# Temporary files # Temporary files
############################################ ############################################
*.tmp *.tmp
*.temp *.temp
*.bak *.bak
*.swp *.swp

View File

@@ -1,14 +1,14 @@
<?xml version = "1.0" encoding = "UTF-8" ?> <?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:RelayClient" xmlns:local="clr-namespace:RelayClient"
x:Class="RelayClient.App"> x:Class="RelayClient.App">
<Application.Resources> <Application.Resources>
<ResourceDictionary> <ResourceDictionary>
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" /> <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" /> <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>
</Application> </Application>

View File

@@ -1,16 +1,32 @@
using Microsoft.Extensions.DependencyInjection; namespace RelayClient;
namespace RelayClient; public partial class App : Application
{
public partial class App : Application public App()
{ {
public App() InitializeComponent();
{
InitializeComponent(); var username = Environment.GetCommandLineArgs()
} .Skip(1)
.Chunk(2)
protected override Window CreateWindow(IActivationState? activationState) .Where(x => x.Length == 2 && x[0] == "--user")
{ .Select(x => x[1])
return new Window(new AppShell()); .FirstOrDefault();
}
if (string.IsNullOrWhiteSpace(username))
{
username = "Test";
// throw new Exception("Missing required --user argument. Example: --user Keeper317");
}
ClientSession.Username = username;
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage(ClientSession.Username))
{
Title = $"Relay Client - {ClientSession.Username}"
};
}
} }

View File

@@ -1,14 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<Shell <Shell
x:Class="RelayClient.AppShell" x:Class="RelayClient.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:RelayClient" xmlns:local="clr-namespace:RelayClient"
Title="RelayClient"> Title="RelayClient"
FlyoutBehavior="Flyout">
<ShellContent
Title="Home" <ShellContent
ContentTemplate="{DataTemplate local:MainPage}" Title="Home"
Route="MainPage" /> ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
</Shell>
</Shell>

View File

@@ -1,9 +1,9 @@
namespace RelayClient; namespace RelayClient;
public partial class AppShell : Shell public partial class AppShell : Shell
{ {
public AppShell() public AppShell()
{ {
InitializeComponent(); InitializeComponent();
} }
} }

View File

@@ -0,0 +1,8 @@
namespace RelayClient;
public sealed class ChatMessage
{
public required string SenderUsername { get; set; }
public required string Text { get; set; }
public required DateTime Timestamp { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace RelayClient;
public static class ClientSession
{
public static string Username { get; set; } = "Unknown";
}

View File

@@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayClient.Crypto;
/// <summary>
/// Client-side mirror of RelayServer.Services.Crypto.E2EeHelper. Identical algorithms +
/// key formats so blobs round-trip cleanly between server and client.
/// See the server class for full algorithm details.
/// </summary>
public static class E2EeHelper
{
/// <summary>Generates a fresh RSA-2048 keypair. Called once per user on first launch and persisted via KeyStorage.</summary>
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
{
using var rsa = RSA.Create(2048);
return (
Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()),
Convert.ToBase64String(rsa.ExportPkcs8PrivateKey())
);
}
/// <summary>
/// Hybrid encrypts a plaintext string for a specific recipient: fresh AES-256 key encrypts
/// the payload (AES-GCM), then RSA-OAEP-SHA256 wraps the AES key with the recipient's
/// public key. Returns base64-encoded fields ready to ship in a SocketEncryptedMessage.
/// </summary>
public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64)
{
byte[] aesKey = RandomNumberGenerator.GetBytes(32);
byte[] nonce = RandomNumberGenerator.GetBytes(12);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = new byte[plainBytes.Length];
byte[] tag = new byte[16];
using (var aes = new AesGcm(aesKey, 16))
{
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
}
byte[] encryptedKey;
using (var rsa = RSA.Create())
{
rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(recipientPublicKeyBase64), out _);
encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256);
}
return new EncryptedPayload
{
CipherText = Convert.ToBase64String(cipherBytes),
Nonce = Convert.ToBase64String(nonce),
Tag = Convert.ToBase64String(tag),
EncryptedKey = Convert.ToBase64String(encryptedKey)
};
}
/// <summary>
/// Reverse of EncryptForRecipient: RSA-decrypt the AES key with the recipient's private
/// key, then AES-GCM-decrypt the ciphertext. Throws on tampered/corrupt payloads
/// (auth tag mismatch). Returns the original UTF-8 plaintext string.
/// </summary>
public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64)
{
byte[] aesKey;
using (var rsa = RSA.Create())
{
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(recipientPrivateKeyBase64), out _);
aesKey = rsa.Decrypt(Convert.FromBase64String(payload.EncryptedKey), RSAEncryptionPadding.OaepSHA256);
}
byte[] plainBytes = new byte[Convert.FromBase64String(payload.CipherText).Length];
using (var aes = new AesGcm(aesKey, 16))
{
aes.Decrypt(
Convert.FromBase64String(payload.Nonce),
Convert.FromBase64String(payload.CipherText),
Convert.FromBase64String(payload.Tag),
plainBytes
);
}
return Encoding.UTF8.GetString(plainBytes);
}
}
/// <summary>The 4-tuple ciphertext bundle. Same shape on both client and server; matches SocketEncryptedMessage's encrypted fields.</summary>
public class EncryptedPayload
{
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required string EncryptedKey { get; set; }
}

View File

@@ -0,0 +1,52 @@
namespace RelayClient.Crypto;
/// <summary>
/// Per-user RSA keypair persistence. Keys live as base64-encoded files in
/// {AppData}/keys/{username}.{public|private}.key
///
/// Plaintext on disk. For now this is fine because the only attack model is "someone else
/// has access to your filesystem" — at which point everything is compromised. A future
/// enhancement could encrypt the private key with a passphrase derived from the user's
/// password, similar to how SSH/PGP do it.
/// </summary>
public static class KeyStorage
{
/// <summary>Returns (and creates if needed) the per-app keys directory.</summary>
private static string GetKeyFolder()
{
var folder = Path.Combine(FileSystem.AppDataDirectory, "keys");
Directory.CreateDirectory(folder);
return folder;
}
/// <summary>Writes the base64 RSA private key to disk. Used at first-launch after GenerateRsaKeyPair.</summary>
public static void SavePrivateKey(string username, string privateKey)
{
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey);
}
/// <summary>Writes the base64 RSA public key to disk. Sent to the server via WsAction.RegisterKey.</summary>
public static void SavePublicKey(string username, string publicKey)
{
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"), publicKey);
}
/// <summary>Reads the user's RSA private key. Used by TryDecryptAndParseContent on every inbound message.</summary>
public static string LoadPrivateKey(string username)
{
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"));
}
/// <summary>Reads the user's RSA public key. Used during the boot handshake to send to the server.</summary>
public static string LoadPublicKey(string username)
{
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"));
}
/// <summary>True if BOTH halves of the user's keypair already exist on disk. False means we need to generate.</summary>
public static bool HasKeys(string username)
{
return File.Exists(Path.Combine(GetKeyFolder(), $"{username}.private.key")) &&
File.Exists(Path.Combine(GetKeyFolder(), $"{username}.public.key"));
}
}

View File

@@ -0,0 +1,498 @@
using System.Net.Http;
using System.Text.RegularExpressions;
namespace RelayClient.Helpers;
/// <summary>
/// Detects URLs in message text and builds embed views:
/// • Direct image URLs → inline Image (loaded lazily from URI or base64).
/// • relay:// jump links → tappable "Jump to message" card.
/// • Everything else → a link card with an async OG-tag preview loaded in the background.
/// </summary>
public static class EmbedHelper
{
private static readonly Regex UrlPattern = new(
@"https?://[^\s<>""]+",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex RelayJumpPattern = new(
@"relay://jump/([^/]+)/(.+)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly HashSet<string> ImageExtensions =
[".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"];
/// <summary>Extracts every distinct http/https URL from message text. De-duped so multiple occurrences don't double-embed.</summary>
public static List<string> DetectUrls(string text)
{
if (string.IsNullOrWhiteSpace(text)) return [];
return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList();
}
/// <summary>
/// Dispatcher: classifies each URL and delegates to the appropriate Build* method.
/// Order matters — jump links and YouTube/Vimeo IDs are checked before the generic
/// image-extension and link-card paths so the more specific embed wins.
/// </summary>
public static List<View> BuildEmbeds(string text)
{
var views = new List<View>();
foreach (var url in DetectUrls(text))
{
try
{
if (RelayJumpPattern.IsMatch(url))
views.Add(BuildJumpCard(url));
else if (TryGetYouTubeId(url, out var ytId))
views.Add(BuildYouTubeCard(url, ytId));
else if (TryGetVimeoId(url, out var vimeoId))
views.Add(BuildVimeoCard(url, vimeoId));
else if (IsImageUrl(url))
views.Add(BuildImageEmbed(url));
else
views.Add(BuildLinkCard(url));
}
catch { /* never crash the UI */ }
}
return views;
}
/// <summary>
/// Decodes a base64 attachment to bytes and renders it as an inline Image. Used by
/// MainPage.BuildBubbleContent when a message has an image attachment.
/// </summary>
public static View BuildBase64ImageEmbed(string base64, string fileName)
{
try
{
var bytes = Convert.FromBase64String(base64);
var source = ImageSource.FromStream(() => new MemoryStream(bytes));
var image = new Image
{
Source = source,
Aspect = Aspect.AspectFit,
WidthRequest = 400,
MaximumHeightRequest = 300,
HorizontalOptions = LayoutOptions.Start
};
return new Border
{
StrokeThickness = 1,
Padding = new Thickness(4),
Margin = new Thickness(0, 4, 0, 0),
Content = image
};
}
catch
{
return new Label
{
Text = $"⚠ Could not render image: {fileName}",
FontSize = 12,
TextColor = Colors.Gray
};
}
}
/// <summary>
/// Renders a non-image attachment as a tappable card. Tap → writes the bytes to a temp
/// file and hands off to the system handler via Launcher.OpenAsync.
/// </summary>
public static View BuildFileCard(string base64, string fileName, string mimeType)
{
var label = new Label
{
Text = $"📎 {fileName}",
FontSize = 13,
TextColor = Color.FromArgb("#5DA8FF"),
TextDecorations = TextDecorations.Underline
};
var tap = new TapGestureRecognizer();
tap.Tapped += async (_, _) =>
{
try
{
var bytes = Convert.FromBase64String(base64);
var tempPath = Path.Combine(Path.GetTempPath(), fileName);
await File.WriteAllBytesAsync(tempPath, bytes);
await Launcher.OpenAsync(new OpenFileRequest
{
File = new ReadOnlyFile(tempPath)
});
}
catch { /* ignore launch errors */ }
};
label.GestureRecognizers.Add(tap);
return new Border
{
StrokeThickness = 1,
Padding = new Thickness(8, 6),
Margin = new Thickness(0, 4, 0, 0),
Content = label
};
}
/// <summary>Direct image URL → inline Image (loaded async by MAUI from the URI). Tap opens in browser.</summary>
private static View BuildImageEmbed(string url)
{
var image = new Image
{
Source = ImageSource.FromUri(new Uri(url)),
Aspect = Aspect.AspectFit,
WidthRequest = 400,
MaximumHeightRequest = 300,
HorizontalOptions = LayoutOptions.Start
};
var tap = new TapGestureRecognizer();
tap.Tapped += (_, _) => _ = Launcher.OpenAsync(new Uri(url));
image.GestureRecognizers.Add(tap);
return new Border
{
StrokeThickness = 1,
Padding = new Thickness(4),
Margin = new Thickness(0, 4, 0, 0),
Content = image
};
}
/// <summary>
/// Builds the "💬 Jump to linked message" card for relay://jump URLs. The actual tap
/// handler is wired by MainPage.WireJumpLinks because it needs access to the message
/// bubble dictionary that EmbedHelper doesn't know about.
/// </summary>
private static View BuildJumpCard(string relayUrl)
{
var label = new Label
{
Text = "💬 Jump to linked message",
FontSize = 12,
TextColor = Color.FromArgb("#9ECEFF"),
TextDecorations = TextDecorations.Underline
};
label.SetValue(JumpUrlProperty, relayUrl);
return new Border
{
StrokeThickness = 1,
Padding = new Thickness(8, 4),
Margin = new Thickness(0, 4, 0, 0),
Content = label
};
}
/// <summary>Attached property that stores the relay:// URL on the jump label so MainPage.WireJumpLinks can find it.</summary>
public static readonly BindableProperty JumpUrlProperty =
BindableProperty.CreateAttached("JumpUrl", typeof(string), typeof(EmbedHelper), null);
/// <summary>
/// Generic URL card. Starts with just the URL itself; spawns a background task to fetch
/// OG meta tags from the page and append a title/description/preview-image when the
/// response arrives. The whole card is tappable to open the URL in the browser.
/// </summary>
private static View BuildLinkCard(string url)
{
var displayUrl = url.Length > 55 ? url[..52] + "…" : url;
var card = new VerticalStackLayout { Spacing = 4 };
var urlLabel = new Label
{
Text = "🔗 " + displayUrl,
FontSize = 12,
TextColor = Color.FromArgb("#5DA8FF"),
TextDecorations = TextDecorations.Underline,
LineBreakMode = LineBreakMode.TailTruncation
};
var tapUrl = new TapGestureRecognizer();
tapUrl.Tapped += (_, _) => _ = Launcher.OpenAsync(new Uri(url));
urlLabel.GestureRecognizers.Add(tapUrl);
card.Children.Add(urlLabel);
_ = Task.Run(async () =>
{
var og = await FetchOgTagsAsync(url);
if (og is null) return;
MainThread.BeginInvokeOnMainThread(() =>
{
if (!string.IsNullOrWhiteSpace(og.Title))
{
card.Children.Add(new Label
{
Text = og.Title,
FontSize = 13,
FontAttributes = FontAttributes.Bold,
MaxLines = 2,
LineBreakMode = LineBreakMode.TailTruncation
});
}
if (!string.IsNullOrWhiteSpace(og.Description))
{
card.Children.Add(new Label
{
Text = og.Description,
FontSize = 11,
TextColor = Colors.LightGray,
MaxLines = 3,
LineBreakMode = LineBreakMode.TailTruncation
});
}
if (!string.IsNullOrWhiteSpace(og.ImageUrl) && IsImageUrl(og.ImageUrl))
{
card.Children.Add(new Image
{
Source = ImageSource.FromUri(new Uri(og.ImageUrl)),
Aspect = Aspect.AspectFit,
WidthRequest = 360,
MaximumHeightRequest = 200,
HorizontalOptions = LayoutOptions.Start
});
}
});
});
return new Border
{
StrokeThickness = 1,
Padding = new Thickness(8, 6),
Margin = new Thickness(0, 4, 0, 0),
Content = card
};
}
private sealed record OgData(string? Title, string? Description, string? ImageUrl);
/// <summary>
/// 4-second-budget HTTP GET + regex extract of og:title, og:description, og:image meta
/// tags from a page's HTML. Returns null on any failure (so the link card just stays bare).
/// </summary>
private static async Task<OgData?> FetchOgTagsAsync(string url)
{
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(4) };
client.DefaultRequestHeaders.Add("User-Agent", "Relay/1.0 (link preview)");
var html = await client.GetStringAsync(url);
var title = GetMetaContent(html, "og:title")
?? GetTitleTag(html);
var description = GetMetaContent(html, "og:description");
var image = GetMetaContent(html, "og:image");
if (title is null && description is null && image is null) return null;
return new OgData(title, description, image);
}
catch { return null; }
}
private static string? GetMetaContent(string html, string property)
{
var pattern = $"""<meta[^>]+property=["']{Regex.Escape(property)}["'][^>]+content=["']([^"']+)["']""";
var m = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
if (m.Success) return System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim());
var pattern2 = $"""<meta[^>]+content=["']([^"']+)["'][^>]+property=["']{Regex.Escape(property)}["']""";
m = Regex.Match(html, pattern2, RegexOptions.IgnoreCase);
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null;
}
private static string? GetTitleTag(string html)
{
var m = Regex.Match(html, @"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase);
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null;
}
/// <summary>True if the URL's path ends with a known image extension. Used to choose between BuildImageEmbed and BuildLinkCard.</summary>
private static bool IsImageUrl(string url)
{
try
{
var path = new Uri(url).AbsolutePath;
var ext = Path.GetExtension(path).ToLowerInvariant();
return ImageExtensions.Contains(ext);
}
catch { return false; }
}
private static readonly Regex YouTubePattern = new(
@"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{6,})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>Extracts the 11-char video ID from any YouTube URL form (watch, youtu.be, embed, shorts, /v/).</summary>
private static bool TryGetYouTubeId(string url, out string id)
{
var match = YouTubePattern.Match(url);
if (match.Success)
{
id = match.Groups[1].Value;
return true;
}
id = string.Empty;
return false;
}
private static readonly Regex VimeoPattern = new(
@"vimeo\.com/(?:video/|channels/[^/]+/|groups/[^/]+/videos/)?(\d{6,})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>Extracts the numeric video ID from Vimeo URLs. Handles vimeo.com/{id}, /video/{id}, channels/x/{id}, groups/x/videos/{id}.</summary>
private static bool TryGetVimeoId(string url, out string id)
{
var match = VimeoPattern.Match(url);
if (match.Success)
{
id = match.Groups[1].Value;
return true;
}
id = string.Empty;
return false;
}
/// <summary>YouTube embed card. Thumbnail comes from img.youtube.com; player swaps to the youtube.com/embed/ URL on tap.</summary>
private static View BuildYouTubeCard(string url, string videoId) =>
BuildVideoCardWithEmbed(
providerLabel: "🎬 YouTube",
providerColor: Color.FromArgb("#FF4444"),
externalUrl: url,
thumbnailUrl: $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg",
embedUrl: $"https://www.youtube.com/embed/{videoId}?autoplay=1&rel=0");
/// <summary>Vimeo embed card. No thumbnail (Vimeo's API requires OAuth); placeholder stays black with a play badge until tap.</summary>
private static View BuildVimeoCard(string url, string videoId) =>
BuildVideoCardWithEmbed(
providerLabel: "🎬 Vimeo",
providerColor: Color.FromArgb("#1AB7EA"),
externalUrl: url,
thumbnailUrl: null, // Vimeo thumbs require an API call; skip and show a black placeholder
embedUrl: $"https://player.vimeo.com/video/{videoId}?autoplay=1");
/// <summary>
/// The lazy-swap player. Default content is BuildThumbnailPlaceholder (cheap — no WebView
/// spawned). On tap, the ContentView's content swaps to a WebView pointing at embedUrl.
/// Means 50 videos in scrollback = 50 thumbnails, not 50 WebViews.
/// </summary>
private static View BuildVideoCardWithEmbed(
string providerLabel,
Color providerColor,
string externalUrl,
string? thumbnailUrl,
string embedUrl)
{
var card = new VerticalStackLayout { Spacing = 4 };
var headerRow = new HorizontalStackLayout { Spacing = 10 };
headerRow.Children.Add(new Label
{
Text = providerLabel,
FontSize = 11,
FontAttributes = FontAttributes.Bold,
TextColor = providerColor
});
var openExternal = new Label
{
Text = "↗ Open in browser",
FontSize = 10,
TextColor = Color.FromArgb("#8E8E93"),
TextDecorations = TextDecorations.Underline
};
var openTap = new TapGestureRecognizer();
openTap.Tapped += (_, _) => _ = Launcher.OpenAsync(new Uri(externalUrl));
openExternal.GestureRecognizers.Add(openTap);
headerRow.Children.Add(openExternal);
card.Children.Add(headerRow);
var playerHost = new ContentView
{
HorizontalOptions = LayoutOptions.Start,
Content = BuildThumbnailPlaceholder(thumbnailUrl, () =>
{
// On tap → swap the placeholder for a real player.
})
};
playerHost.Content = BuildThumbnailPlaceholder(thumbnailUrl, () =>
{
playerHost.Content = BuildEmbeddedPlayer(embedUrl);
});
card.Children.Add(playerHost);
return new Border
{
StrokeThickness = 1,
Padding = new Thickness(8, 6),
Margin = new Thickness(0, 4, 0, 0),
Content = card
};
}
/// <summary>
/// 16:9 thumbnail (or solid black if no thumb URL) with a translucent black play-badge
/// overlay. Calling onPlay swaps the parent ContentView's content to the real WebView.
/// </summary>
private static View BuildThumbnailPlaceholder(string? thumbnailUrl, Action onPlay)
{
var grid = new Grid
{
WidthRequest = 400,
HeightRequest = 225,
BackgroundColor = Colors.Black,
HorizontalOptions = LayoutOptions.Start
};
if (!string.IsNullOrWhiteSpace(thumbnailUrl))
{
grid.Children.Add(new Image
{
Source = ImageSource.FromUri(new Uri(thumbnailUrl)),
Aspect = Aspect.AspectFill
});
}
var playBadge = new Label
{
Text = "▶",
FontSize = 36,
TextColor = Colors.White,
BackgroundColor = Color.FromArgb("#CC000000"),
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
WidthRequest = 64,
HeightRequest = 64,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center
};
grid.Children.Add(playBadge);
var tap = new TapGestureRecognizer();
tap.Tapped += (_, _) => onPlay();
grid.GestureRecognizers.Add(tap);
return grid;
}
/// <summary>The actual in-client video player. WebView2 (Windows) and WebKit (mobile) both handle YouTube/Vimeo embed pages.</summary>
private static View BuildEmbeddedPlayer(string embedUrl)
{
return new WebView
{
Source = embedUrl,
WidthRequest = 480,
HeightRequest = 270,
HorizontalOptions = LayoutOptions.Start
};
}
}

View File

@@ -0,0 +1,411 @@
using System.Text;
using System.Text.RegularExpressions;
namespace RelayClient.Helpers;
public static class MarkdownHelper
{
private static readonly Regex FencedCode =
new(@"```([A-Za-z0-9_+#-]*)\r?\n?(.*?)```", RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Color MentionText = Color.FromArgb("#9EA8FF");
private static readonly Color MentionBg = Color.FromArgb("#2D2F5C");
private static readonly Color SpoilerBg = Color.FromArgb("#1F1F23");
/// <summary>
/// The entry point. Returns either a single Label (simple inline text) or a
/// VerticalStackLayout (anything with paragraphs, code blocks, or headers).
/// First pass extracts fenced code blocks (verbatim, can span multiple lines), then
/// AppendTextSegment handles per-line headers and the inline parser.
/// </summary>
public static View Render(string markdown, double fontSize = 14)
{
if (string.IsNullOrEmpty(markdown))
return new Label { Text = string.Empty, FontSize = fontSize };
var stack = new VerticalStackLayout { Spacing = 2 };
var matches = FencedCode.Matches(markdown);
int cursor = 0;
foreach (Match m in matches)
{
if (m.Index > cursor)
AppendTextSegment(stack, markdown[cursor..m.Index], fontSize);
stack.Children.Add(CreateCodeBlock(m.Groups[1].Value.Trim(), m.Groups[2].Value.TrimEnd()));
cursor = m.Index + m.Length;
}
if (cursor < markdown.Length)
AppendTextSegment(stack, markdown[cursor..], fontSize);
return stack.Children.Count == 1 ? (View)stack.Children[0] : stack;
}
/// <summary>
/// Splits a non-code segment by newline and emits the right view per line. Headers/subtext
/// get their own labels; consecutive normal lines accumulate into a paragraph buffer so
/// they wrap naturally as one paragraph.
/// </summary>
private static void AppendTextSegment(VerticalStackLayout stack, string segment, double fontSize)
{
var paragraphBuffer = new StringBuilder();
void FlushParagraph()
{
if (paragraphBuffer.Length == 0) return;
stack.Children.Add(CreateInlineLabel(paragraphBuffer.ToString(), fontSize));
paragraphBuffer.Clear();
}
foreach (var rawLine in segment.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line))
{
FlushParagraph();
continue;
}
if (line.StartsWith("### "))
{
FlushParagraph();
stack.Children.Add(CreateHeaderLabel(line[4..], fontSize + 3));
continue;
}
if (line.StartsWith("## "))
{
FlushParagraph();
stack.Children.Add(CreateHeaderLabel(line[3..], fontSize + 6));
continue;
}
if (line.StartsWith("# "))
{
FlushParagraph();
stack.Children.Add(CreateHeaderLabel(line[2..], fontSize + 10));
continue;
}
if (line.StartsWith("-# "))
{
FlushParagraph();
stack.Children.Add(CreateSubtextLabel(line[3..], fontSize - 3));
continue;
}
if (paragraphBuffer.Length > 0)
paragraphBuffer.Append('\n');
paragraphBuffer.Append(line);
}
FlushParagraph();
}
/// <summary>
/// Builds the dark-pane code block. If a language is specified, delegates token coloring
/// to SyntaxHighlighter and prepends a small green language label (Discord-style).
/// </summary>
private static View CreateCodeBlock(string language, string code)
{
var label = new Label
{
FontFamily = "AnonymousProRegular",
FontSize = 12,
TextColor = Color.FromArgb("#D4D4D4"),
LineBreakMode = LineBreakMode.WordWrap
};
var spans = SyntaxHighlighter.Highlight(code, language, 12);
if (spans.Count > 0)
{
var fs = new FormattedString();
foreach (var s in spans) fs.Spans.Add(s);
label.FormattedText = fs;
}
else
{
label.Text = code;
}
var stack = new VerticalStackLayout { Spacing = 4 };
if (!string.IsNullOrWhiteSpace(language))
{
stack.Children.Add(new Label
{
Text = language.ToLowerInvariant(),
FontFamily = "AnonymousProRegular",
FontSize = 10,
TextColor = Color.FromArgb("#6A9955"),
FontAttributes = FontAttributes.Bold
});
}
stack.Children.Add(label);
return new Border
{
BackgroundColor = Color.FromArgb("#1E1E1E"),
StrokeThickness = 0,
Padding = new Thickness(10, 6),
Content = stack
};
}
/// <summary>Bold, larger Label for # / ## / ### lines. Inline markdown still works inside (e.g. `# Hello **world**`).</summary>
private static Label CreateHeaderLabel(string text, double size)
{
var label = new Label
{
FontSize = size,
FontAttributes = FontAttributes.Bold,
LineBreakMode = LineBreakMode.WordWrap,
Margin = new Thickness(0, 4, 0, 2)
};
var fs = new FormattedString();
var spoilerSpans = new List<Span>();
ParseInline(text, fs.Spans, size, spoilerSpans);
if (fs.Spans.Count > 0) label.FormattedText = fs;
else label.Text = text;
WireSpoilerTap(label, spoilerSpans);
return label;
}
/// <summary>Smaller, grey Label for "-#" lines (Discord calls it subtext). Inherits inline markdown.</summary>
private static Label CreateSubtextLabel(string text, double size)
{
var label = new Label
{
FontSize = size,
TextColor = Color.FromArgb("#8E8E93"),
LineBreakMode = LineBreakMode.WordWrap
};
var fs = new FormattedString();
var spoilerSpans = new List<Span>();
ParseInline(text, fs.Spans, size, spoilerSpans);
if (fs.Spans.Count > 0)
{
foreach (var s in fs.Spans)
s.TextColor ??= Color.FromArgb("#8E8E93");
label.FormattedText = fs;
}
else
{
label.Text = text;
}
WireSpoilerTap(label, spoilerSpans);
return label;
}
/// <summary>Standard paragraph Label. Runs the inline parser to build a FormattedString of spans.</summary>
private static Label CreateInlineLabel(string text, double fontSize)
{
var label = new Label { FontSize = fontSize, LineBreakMode = LineBreakMode.WordWrap };
var fs = new FormattedString();
var spoilerSpans = new List<Span>();
ParseInline(text, fs.Spans, fontSize, spoilerSpans);
if (fs.Spans.Count > 0) label.FormattedText = fs;
else label.Text = text;
WireSpoilerTap(label, spoilerSpans);
return label;
}
/// <summary>
/// Attaches a TapGestureRecognizer that reveals every spoiler span in the label when
/// tapped once. MAUI Spans can't fire their own gesture events, so per-spoiler reveal
/// would require splitting the line into separate labels — this is the pragmatic compromise.
/// </summary>
private static void WireSpoilerTap(Label label, List<Span> spoilerSpans)
{
if (spoilerSpans.Count == 0) return;
var tap = new TapGestureRecognizer();
tap.Tapped += (_, _) =>
{
foreach (var s in spoilerSpans)
{
s.BackgroundColor = Colors.Transparent;
s.TextColor = null; // fall back to default label color
}
};
label.GestureRecognizers.Add(tap);
}
/// <summary>
/// Single-pass character walk. For each markdown sigil (||, @, ~~, __, **, *, `), tries
/// to find a matching closer; if found, emits a styled Span and skips past. Otherwise the
/// char accumulates into a "plain" buffer that's flushed as a plain Span when the next
/// sigil hits or the string ends. Spoiler spans are registered in spoilerSpans for reveal.
/// </summary>
private static void ParseInline(string text, IList<Span> spans, double fontSize, List<Span> spoilerSpans)
{
var plain = new StringBuilder();
int i = 0;
void Flush()
{
if (plain.Length == 0) return;
spans.Add(new Span { Text = plain.ToString(), FontSize = fontSize });
plain.Clear();
}
while (i < text.Length)
{
char c = text[i];
if (c == '|' && Peek(text, i + 1) == '|')
{
int end = text.IndexOf("||", i + 2, StringComparison.Ordinal);
if (end > i + 2)
{
Flush();
var span = new Span
{
Text = text[(i + 2)..end],
FontSize = fontSize,
BackgroundColor = SpoilerBg,
TextColor = SpoilerBg // text invisible until revealed
};
spans.Add(span);
spoilerSpans.Add(span);
i = end + 2;
continue;
}
}
if (c == '@' && i + 1 < text.Length &&
(char.IsLetter(text[i + 1]) || text[i + 1] == '_'))
{
int end = i + 1;
while (end < text.Length && (char.IsLetterOrDigit(text[end]) || text[end] == '_'))
end++;
Flush();
spans.Add(new Span
{
Text = text[i..end],
TextColor = MentionText,
BackgroundColor = MentionBg,
FontAttributes = FontAttributes.Bold,
FontSize = fontSize
});
i = end;
continue;
}
if (c == '~' && Peek(text, i + 1) == '~')
{
int end = text.IndexOf("~~", i + 2, StringComparison.Ordinal);
if (end > i + 2)
{
Flush();
spans.Add(new Span
{
Text = text[(i + 2)..end],
FontSize = fontSize,
TextDecorations = TextDecorations.Strikethrough
});
i = end + 2; continue;
}
}
if (c == '_' && Peek(text, i + 1) == '_')
{
int end = text.IndexOf("__", i + 2, StringComparison.Ordinal);
if (end > i + 2)
{
Flush();
spans.Add(new Span
{
Text = text[(i + 2)..end],
FontSize = fontSize,
TextDecorations = TextDecorations.Underline
});
i = end + 2; continue;
}
}
if (c == '*' && Peek(text, i + 1) == '*')
{
int end = text.IndexOf("**", i + 2, StringComparison.Ordinal);
if (end > i + 2)
{
Flush();
spans.Add(new Span
{
Text = text[(i + 2)..end],
FontSize = fontSize,
FontAttributes = FontAttributes.Bold
});
i = end + 2; continue;
}
}
if (c == '*' && Peek(text, i + 1) != '*')
{
int end = FindClosingSingle(text, '*', i + 1);
if (end > i + 1)
{
Flush();
spans.Add(new Span
{
Text = text[(i + 1)..end],
FontSize = fontSize,
FontAttributes = FontAttributes.Italic
});
i = end + 1; continue;
}
}
if (c == '`')
{
int end = text.IndexOf('`', i + 1);
if (end > i + 1)
{
Flush();
spans.Add(new Span
{
Text = text[(i + 1)..end],
FontFamily = "AnonymousProRegular",
FontSize = fontSize - 1,
BackgroundColor = Color.FromArgb("#2D2D2D"),
TextColor = Color.FromArgb("#CE9178")
});
i = end + 1; continue;
}
}
plain.Append(c);
i++;
}
Flush();
}
/// <summary>Safe one-character lookahead. Returns '\0' past end-of-string.</summary>
private static char Peek(string text, int index) => index < text.Length ? text[index] : '\0';
/// <summary>
/// Finds the next single occurrence of marker that is NOT immediately followed by
/// another marker. Used to disambiguate "*italic*" from "**bold**".
/// </summary>
private static int FindClosingSingle(string text, char marker, int start)
{
for (int i = start; i < text.Length; i++)
if (text[i] == marker && Peek(text, i + 1) != marker)
return i;
return -1;
}
}

View File

@@ -0,0 +1,361 @@
using System.Text.RegularExpressions;
namespace RelayClient.Helpers;
/// <summary>
/// Discord-style syntax highlighting for ```lang...``` fenced code blocks. Builds a list of
/// MAUI Spans (with colors from the VS Code Dark+ palette) that the caller drops into a
/// FormattedString.
///
/// How it works:
/// - The opening fence captures an optional language tag (e.g. ```cs, ```python).
/// - Aliases resolves "cs" → "csharp", "js" → "javascript", etc.
/// - Tokenizers[lang] is a compiled regex with named groups (comment/string/number/word/…).
/// - For each match, SpanForMatch picks a colour based on which group matched + whether
/// a "word" hit a language keyword set.
///
/// Adding a new language: register an alias (if needed), a Keywords set, and a tokenizer regex.
/// </summary>
public static class SyntaxHighlighter
{
/// <summary>Fallback identifier color (light grey). Used for any token we don't recognise.</summary>
private static readonly Color DefaultColor = Color.FromArgb("#D4D4D4");
/// <summary>Language keywords (if, for, return, etc.) — VS Code's "control flow" blue.</summary>
private static readonly Color KeywordColor = Color.FromArgb("#569CD6");
/// <summary>String literals — orange/salmon.</summary>
private static readonly Color StringColor = Color.FromArgb("#CE9178");
/// <summary>Numeric literals — soft green.</summary>
private static readonly Color NumberColor = Color.FromArgb("#B5CEA8");
/// <summary>Comments — green, rendered italic.</summary>
private static readonly Color CommentColor = Color.FromArgb("#6A9955");
/// <summary>Type names (heuristic: uppercase-start words in C#/JS/TS) — teal.</summary>
private static readonly Color TypeColor = Color.FromArgb("#4EC9B0");
/// <summary>Function names — yellow. Currently unused (we don't disambiguate function calls).</summary>
private static readonly Color FunctionColor = Color.FromArgb("#DCDCAA");
/// <summary>Operators — same as default. Reserved for future use.</summary>
private static readonly Color OperatorColor = Color.FromArgb("#D4D4D4");
/// <summary>HTML tag names (&lt;div&gt;, &lt;/p&gt;) — blue.</summary>
private static readonly Color TagColor = Color.FromArgb("#569CD6");
/// <summary>HTML/CSS attribute names, YAML keys, bash variables — light blue.</summary>
private static readonly Color AttrColor = Color.FromArgb("#9CDCFE");
/// <summary>Monospace font registered in MauiProgram. Used for all code-block spans.</summary>
private const string FontFamily = "AnonymousProRegular";
/// <summary>
/// Short language tags → canonical names. So users can write ```cs (instead of ```csharp),
/// ```py instead of ```python, etc. Case-insensitive.
/// </summary>
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["cs"] = "csharp",
["c#"] = "csharp",
["js"] = "javascript",
["jsx"] = "javascript",
["ts"] = "typescript",
["tsx"] = "typescript",
["py"] = "python",
["sh"] = "bash",
["shell"] = "bash",
["zsh"] = "bash",
["htm"] = "html",
["xml"] = "html",
["yml"] = "yaml"
};
/// <summary>
/// Per-language keyword sets. A token in a "word" match-group that hits one of these
/// gets rendered with KeywordColor. Case-sensitivity matches the language — Ordinal
/// for most languages, OrdinalIgnoreCase for SQL and CSS.
/// </summary>
private static readonly Dictionary<string, HashSet<string>> Keywords = new(StringComparer.OrdinalIgnoreCase)
{
["csharp"] = new(StringComparer.Ordinal)
{
"abstract","as","async","await","base","bool","break","byte","case","catch","char","checked",
"class","const","continue","decimal","default","delegate","do","double","else","enum","event",
"explicit","extern","false","finally","fixed","float","for","foreach","get","goto","if",
"implicit","in","int","interface","internal","is","lock","long","namespace","new","null",
"object","operator","out","override","params","partial","private","protected","public",
"readonly","record","ref","return","sbyte","sealed","set","short","sizeof","stackalloc",
"static","string","struct","switch","this","throw","true","try","typeof","uint","ulong",
"unchecked","unsafe","ushort","using","var","virtual","void","volatile","while","yield",
"nameof","when","where","global","init","required","file","scoped","with"
},
["javascript"] = new(StringComparer.Ordinal)
{
"async","await","break","case","catch","class","const","continue","debugger","default",
"delete","do","else","enum","export","extends","false","finally","for","from","function",
"get","if","implements","import","in","instanceof","let","new","null","of","package",
"private","protected","public","return","set","static","super","switch","this","throw",
"true","try","typeof","undefined","var","void","while","with","yield"
},
["typescript"] = new(StringComparer.Ordinal)
{
"any","as","async","await","boolean","break","case","catch","class","const","continue",
"debugger","declare","default","delete","do","else","enum","export","extends","false",
"finally","for","from","function","get","if","implements","import","in","instanceof",
"interface","is","keyof","let","namespace","never","new","null","number","of","package",
"private","protected","public","readonly","return","set","static","string","super",
"switch","this","throw","true","try","type","typeof","undefined","unknown","var","void",
"while","with","yield"
},
["python"] = new(StringComparer.Ordinal)
{
"and","as","assert","async","await","break","class","continue","def","del","elif","else",
"except","False","finally","for","from","global","if","import","in","is","lambda","None",
"nonlocal","not","or","pass","raise","return","True","try","while","with","yield","self",
"cls","match","case"
},
["sql"] = new(StringComparer.OrdinalIgnoreCase)
{
"select","from","where","insert","update","delete","create","alter","drop","table","index",
"view","join","inner","outer","left","right","full","cross","on","as","group","by","order",
"having","distinct","union","all","into","values","set","null","not","and","or","in","like",
"between","is","true","false","primary","key","foreign","references","default","limit",
"offset","with","case","when","then","else","end","exists","cast","begin","commit","rollback"
},
["bash"] = new(StringComparer.Ordinal)
{
"if","then","else","elif","fi","for","in","do","done","while","until","case","esac",
"function","return","break","continue","exit","echo","printf","export","local","readonly",
"source","alias","unset","trap","set","eval","exec","shift","let","declare","typeset"
},
["json"] = new(StringComparer.Ordinal) { "true","false","null" },
["yaml"] = new(StringComparer.Ordinal) { "true","false","null","yes","no","on","off" },
["css"] = new(StringComparer.OrdinalIgnoreCase)
{
"important","inherit","initial","unset","auto","none","normal","bold","italic","center",
"left","right","top","bottom","flex","grid","block","inline","absolute","relative","fixed",
"sticky","static"
}
};
/// <summary>
/// Per-language compiled token regex. Each pattern uses named groups (comment/string/
/// number/word/tag/attr/…) which SpanForMatch dispatches on. Initialised lazily in the
/// static constructor so the heavy regex compilation is paid once at startup.
/// </summary>
private static readonly Dictionary<string, Regex> Tokenizers = new(StringComparer.Ordinal);
static SyntaxHighlighter()
{
const RegexOptions opts = RegexOptions.Compiled | RegexOptions.Singleline;
Tokenizers["csharp"] = new Regex(
@"(?<comment>//[^\n]*|/\*.*?\*/)" +
@"|(?<string>@""(?:""""|[^""])*""|\$""(?:\\.|[^""\\])*""|""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*')" +
@"|(?<number>\b\d+(?:\.\d+)?[fFdDmMuUlL]*\b)" +
@"|(?<word>[A-Za-z_]\w*)",
opts);
Tokenizers["javascript"] = new Regex(
@"(?<comment>//[^\n]*|/\*.*?\*/)" +
@"|(?<string>""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)" +
@"|(?<number>\b\d+(?:\.\d+)?\b)" +
@"|(?<word>[A-Za-z_$][\w$]*)",
opts);
Tokenizers["typescript"] = Tokenizers["javascript"];
Tokenizers["python"] = new Regex(
@"(?<comment>\#[^\n]*)" +
@"|(?<string>""""""[\s\S]*?""""""|'''[\s\S]*?'''|""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*')" +
@"|(?<number>\b\d+(?:\.\d+)?\b)" +
@"|(?<word>[A-Za-z_]\w*)",
opts);
Tokenizers["sql"] = new Regex(
@"(?<comment>--[^\n]*|/\*.*?\*/)" +
@"|(?<string>'(?:''|[^'])*')" +
@"|(?<number>\b\d+(?:\.\d+)?\b)" +
@"|(?<word>[A-Za-z_]\w*)",
opts);
Tokenizers["bash"] = new Regex(
@"(?<comment>\#[^\n]*)" +
@"|(?<string>""(?:\\.|[^""\\])*""|'[^']*')" +
@"|(?<number>\b\d+\b)" +
@"|(?<variable>\$\{?[A-Za-z_]\w*\}?)" +
@"|(?<word>[A-Za-z_][\w-]*)",
opts);
Tokenizers["json"] = new Regex(
@"(?<string>""(?:\\.|[^""\\])*"")" +
@"|(?<number>-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)" +
@"|(?<word>true|false|null)",
opts);
Tokenizers["yaml"] = new Regex(
@"(?<comment>\#[^\n]*)" +
@"|(?<string>""(?:\\.|[^""\\])*""|'[^']*')" +
@"|(?<key>^[ \t]*[A-Za-z_][\w-]*(?=\s*:))" +
@"|(?<number>\b\d+(?:\.\d+)?\b)" +
@"|(?<word>[A-Za-z_][\w-]*)",
opts | RegexOptions.Multiline);
Tokenizers["html"] = new Regex(
@"(?<comment><!--.*?-->)" +
@"|(?<string>""[^""]*""|'[^']*')" +
@"|(?<tag></?[A-Za-z][A-Za-z0-9-]*)" +
@"|(?<attr>\b[A-Za-z_][\w-]*(?==))",
opts);
Tokenizers["css"] = new Regex(
@"(?<comment>/\*.*?\*/)" +
@"|(?<string>""[^""]*""|'[^']*')" +
@"|(?<number>-?\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?\b)" +
@"|(?<selector>[.#]?[A-Za-z_][\w-]*(?=\s*[{,]))" +
@"|(?<prop>[A-Za-z-]+(?=\s*:))" +
@"|(?<word>[A-Za-z_][\w-]*)",
opts);
Tokenizers["diff"] = new Regex(
@"(?<add>^\+[^\n]*)" +
@"|(?<del>^-[^\n]*)" +
@"|(?<hunk>^@@[^\n]*)",
opts | RegexOptions.Multiline);
Tokenizers["markdown"] = new Regex(
@"(?<header>^#{1,6}[^\n]*)" +
@"|(?<bold>\*\*[^*\n]+\*\*|__[^_\n]+__)" +
@"|(?<italic>\*[^*\n]+\*|_[^_\n]+_)" +
@"|(?<code>`[^`\n]+`)" +
@"|(?<link>\[[^\]]+\]\([^)]+\))",
opts | RegexOptions.Multiline);
}
/// <summary>
/// Entry point. Walks every regex match in the code, emits plain spans for the gaps and
/// styled spans for the matches. If the language is unknown (or not specified), returns a
/// single default-colored span — code still renders in the monospace font, just no colors.
/// </summary>
public static List<Span> Highlight(string code, string? language, double fontSize)
{
var lang = Resolve(language);
var spans = new List<Span>();
if (lang is null || !Tokenizers.TryGetValue(lang, out var tokenizer))
{
spans.Add(MakeSpan(code, DefaultColor, fontSize));
return spans;
}
var keywords = Keywords.GetValueOrDefault(lang);
int cursor = 0;
foreach (Match m in tokenizer.Matches(code))
{
if (m.Index > cursor)
spans.Add(MakeSpan(code[cursor..m.Index], DefaultColor, fontSize));
spans.Add(SpanForMatch(m, lang, keywords, fontSize));
cursor = m.Index + m.Length;
}
if (cursor < code.Length)
spans.Add(MakeSpan(code[cursor..], DefaultColor, fontSize));
return spans;
}
/// <summary>
/// Maps a regex Match to a colored Span by inspecting which named group succeeded. Words
/// fall through to a keyword-set lookup; in C#/JS/TS, uppercase-start words that aren't
/// keywords are treated as type names (a cheap heuristic that works surprisingly well).
/// </summary>
private static Span SpanForMatch(Match m, string lang, HashSet<string>? keywords, double fontSize)
{
if (m.Groups["comment"].Success)
return MakeSpan(m.Value, CommentColor, fontSize, italic: true);
if (m.Groups["string"].Success)
return MakeSpan(m.Value, StringColor, fontSize);
if (m.Groups["number"].Success)
return MakeSpan(m.Value, NumberColor, fontSize);
if (m.Groups["variable"].Success)
return MakeSpan(m.Value, AttrColor, fontSize);
if (m.Groups["tag"].Success)
return MakeSpan(m.Value, TagColor, fontSize);
if (m.Groups["attr"].Success)
return MakeSpan(m.Value, AttrColor, fontSize);
if (m.Groups["selector"].Success)
return MakeSpan(m.Value, TypeColor, fontSize);
if (m.Groups["prop"].Success)
return MakeSpan(m.Value, AttrColor, fontSize);
if (m.Groups["key"].Success)
return MakeSpan(m.Value, AttrColor, fontSize);
if (m.Groups["add"].Success)
return MakeSpan(m.Value, Color.FromArgb("#6A9955"), fontSize);
if (m.Groups["del"].Success)
return MakeSpan(m.Value, Color.FromArgb("#F48771"), fontSize);
if (m.Groups["hunk"].Success)
return MakeSpan(m.Value, KeywordColor, fontSize);
if (m.Groups["header"].Success)
return MakeSpan(m.Value, KeywordColor, fontSize, bold: true);
if (m.Groups["bold"].Success)
return MakeSpan(m.Value, DefaultColor, fontSize, bold: true);
if (m.Groups["italic"].Success)
return MakeSpan(m.Value, DefaultColor, fontSize, italic: true);
if (m.Groups["code"].Success)
return MakeSpan(m.Value, StringColor, fontSize);
if (m.Groups["link"].Success)
return MakeSpan(m.Value, AttrColor, fontSize);
if (m.Groups["word"].Success)
{
var word = m.Value;
var compareSet = keywords;
if (compareSet is not null && compareSet.Contains(word))
return MakeSpan(word, KeywordColor, fontSize);
if (lang is "csharp" or "javascript" or "typescript" && word.Length > 0 && char.IsUpper(word[0]))
return MakeSpan(word, TypeColor, fontSize);
return MakeSpan(word, DefaultColor, fontSize);
}
return MakeSpan(m.Value, DefaultColor, fontSize);
}
/// <summary>Helper: build a Span with the monospace code font and the given colour + bold/italic flags.</summary>
private static Span MakeSpan(string text, Color color, double fontSize, bool bold = false, bool italic = false)
{
var attrs = FontAttributes.None;
if (bold) attrs |= FontAttributes.Bold;
if (italic) attrs |= FontAttributes.Italic;
return new Span
{
Text = text,
TextColor = color,
FontSize = fontSize,
FontFamily = FontFamily,
FontAttributes = attrs
};
}
/// <summary>Normalises a user-supplied language tag through the Aliases table. Returns null for empty/whitespace input.</summary>
private static string? Resolve(string? language)
{
if (string.IsNullOrWhiteSpace(language)) return null;
var lower = language.Trim().ToLowerInvariant();
return Aliases.GetValueOrDefault(lower, lower);
}
}

View File

@@ -1,36 +1,99 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" <ContentPage
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="RelayClient.MainPage"
x:Class="RelayClient.MainPage"> xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
<ScrollView> Title="Relay Client">
<VerticalStackLayout
Padding="30,0" <Grid RowDefinitions="Auto,*,Auto"
Spacing="25"> ColumnDefinitions="220,*"
<Image Padding="12"
Source="dotnet_bot.png" RowSpacing="10"
HeightRequest="185" ColumnSpacing="10">
Aspect="AspectFit"
SemanticProperties.Description="dot net bot in a submarine number ten" /> <!-- Header -->
<Border Grid.Row="0" Grid.ColumnSpan="2" StrokeThickness="1" Padding="10">
<Label <VerticalStackLayout Spacing="2">
Text="Hello, World!" <Label x:Name="UserLabel" Text="Logged in as: Unknown"
Style="{StaticResource Headline}" FontAttributes="Bold" FontSize="18" />
SemanticProperties.HeadingLevel="Level1" /> <Label x:Name="ChannelLabel" Text="No channel selected" FontSize="14" />
<Label x:Name="TypingLabel" Text="" FontSize="11"
<Label FontAttributes="Italic" TextColor="Gray" IsVisible="False" />
Text="Welcome to &#10;.NET Multi-platform App UI" </VerticalStackLayout>
Style="{StaticResource SubHeadline}" </Border>
SemanticProperties.HeadingLevel="Level2"
SemanticProperties.Description="Welcome to dot net Multi platform App U I" /> <!-- Sidebar: channel list -->
<Border Grid.Row="1" Grid.Column="0" StrokeThickness="1" Padding="10">
<Button <ScrollView>
x:Name="CounterBtn" <VerticalStackLayout Spacing="8">
Text="Click me" <Grid ColumnDefinitions="*,Auto">
SemanticProperties.Hint="Counts the number of times you click" <Label Grid.Column="0" Text="Channels"
Clicked="OnCounterClicked" FontAttributes="Bold" FontSize="16"
HorizontalOptions="Fill" /> VerticalOptions="Center" />
</VerticalStackLayout> <Button Grid.Column="1" Text="+"
</ScrollView> FontSize="16" Padding="6,2"
HeightRequest="30" WidthRequest="30"
</ContentPage> Clicked="AddChannel_OnClicked" />
</Grid>
<VerticalStackLayout x:Name="SidebarList" Spacing="4" />
</VerticalStackLayout>
</ScrollView>
</Border>
<!-- Messages view (text channels) -->
<Border x:Name="MessagesView" Grid.Row="1" Grid.Column="1" StrokeThickness="1" Padding="10">
<ScrollView x:Name="MessagesScrollView">
<VerticalStackLayout x:Name="MessagesLayout" Spacing="8" />
</ScrollView>
</Border>
<!-- RTC view (voice channels) -->
<Border x:Name="RtcView" Grid.Row="1" Grid.Column="1"
StrokeThickness="1" Padding="10" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<HybridWebView x:Name="hybridWebView"
RawMessageReceived="OnHybridWebViewRawMessageReceived"
Grid.Row="1" />
</Grid>
</Border>
<!-- Input area -->
<VerticalStackLayout x:Name="InputArea" Grid.Row="2" Grid.Column="1" Spacing="4">
<!-- Context bar (reply / edit mode) -->
<Border x:Name="ContextBar" IsVisible="False" StrokeThickness="1" Padding="8,4">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
<Label x:Name="ContextBarLabel" Grid.Column="0"
VerticalOptions="Center" FontSize="12"
LineBreakMode="TailTruncation" />
<Button Grid.Column="1" Text="✕" FontSize="11"
Padding="6,2" HeightRequest="30"
Clicked="CancelContext_OnClicked" />
</Grid>
</Border>
<!-- Entry row: attach button + editor + send -->
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="6">
<Button Grid.Column="0" Text="📎"
FontSize="16" Padding="6,2"
HeightRequest="40" WidthRequest="40"
Clicked="AttachFile_OnClicked"
ToolTipProperties.Text="Attach a file or image" />
<Editor x:Name="MessageEntry"
Grid.Column="1"
Placeholder="Type a message… (Shift+Enter for newline)"
AutoSize="TextChanges"
MaximumHeightRequest="120"
TextChanged="MessageEntry_OnTextChanged" />
<Button x:Name="SendButton" Grid.Column="2"
Text="Send" VerticalOptions="End"
Clicked="SendButton_OnClicked" />
</Grid>
</VerticalStackLayout>
<!-- Bottom-left: kept empty (swap button removed) -->
<ContentView Grid.Row="2" Grid.Column="0" />
</Grid>
</ContentPage>

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,26 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Maui.Hosting; using WebSocketSharp;
namespace RelayClient; namespace RelayClient;
public static class MauiProgram public static class MauiProgram
{ {
public static MauiApp CreateMauiApp() public static MauiApp CreateMauiApp()
{ {
var builder = MauiApp.CreateBuilder(); var builder = MauiApp.CreateBuilder();
builder builder.UseMauiApp<App>().ConfigureFonts(fonts =>
.UseMauiApp<App>() {
.ConfigureFonts(fonts => fonts.AddFont("AnonymousPro-Bold.ttf", "AnonymousProBold");
{ fonts.AddFont("AnonymousPro-BoldItalic.ttf", "AnonymousProBoldItalic");
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("AnonymousPro-Italic.ttf", "AnonymousProItalic");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); fonts.AddFont("AnonymousPro-Regular.ttf", "AnonymousProRegular");
}); });
#if DEBUG #if DEBUG
builder.Logging.AddDebug(); builder.Services.AddHybridWebViewDeveloperTools();
#endif builder.Logging.AddDebug();
#endif
return builder.Build();
} return builder.Build();
}
} }

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application> <application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
</manifest> </manifest>

View File

@@ -1,12 +1,12 @@
using Android.App; using Android.App;
using Android.Content.PM; using Android.Content.PM;
using Android.OS; using Android.OS;
namespace RelayClient; namespace RelayClient;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity public class MainActivity : MauiAppCompatActivity
{ {
} }

View File

@@ -1,15 +1,15 @@
using Android.App; using Android.App;
using Android.Runtime; using Android.Runtime;
namespace RelayClient; namespace RelayClient;
[Application] [Application]
public class MainApplication : MauiApplication public class MainApplication : MauiApplication
{ {
public MainApplication(IntPtr handle, JniHandleOwnership ownership) public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership) : base(handle, ownership)
{ {
} }
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
} }

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="colorPrimary">#512BD4</color> <color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color> <color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color> <color name="colorAccent">#2B0B98</color>
</resources> </resources>

View File

@@ -1,9 +1,9 @@
using Foundation; using Foundation;
namespace RelayClient; namespace RelayClient;
[Register("AppDelegate")] [Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate public class AppDelegate : MauiUIApplicationDelegate
{ {
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
} }

View File

@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.--> <!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict> <dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. --> <!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. --> <!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
</dict> </dict>
</plist> </plist>

View File

@@ -1,40 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- The Mac App Store requires you specify if the app uses encryption. --> <!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption --> <!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> --> <!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. --> <!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. --> <!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype --> <!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> --> <!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> --> <!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key> <key>UIDeviceFamily</key>
<array> <array>
<integer>2</integer> <integer>2</integer>
</array> </array>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.lifestyle</string> <string>public.app-category.lifestyle</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
<array> <array>
<string>arm64</string> <string>arm64</string>
</array> </array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>XSAppIconAssets</key> <key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string> <string>Assets.xcassets/appicon.appiconset</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1,15 +1,15 @@
using ObjCRuntime; using ObjCRuntime;
using UIKit; using UIKit;
namespace RelayClient; namespace RelayClient;
public class Program public class Program
{ {
// This is the main entry point of the application. // This is the main entry point of the application.
static void Main(string[] args) static void Main(string[] args)
{ {
// if you want to use a different Application Delegate class from "AppDelegate" // if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here. // you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate)); UIApplication.Main(args, null, typeof(AppDelegate));
} }
} }

View File

@@ -1,8 +1,8 @@
<maui:MauiWinUIApplication <maui:MauiWinUIApplication
x:Class="RelayClient.WinUI.App" x:Class="RelayClient.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui" xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:RelayClient.WinUI"> xmlns:local="using:RelayClient.WinUI">
</maui:MauiWinUIApplication> </maui:MauiWinUIApplication>

View File

@@ -1,23 +1,23 @@
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure, // To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info. // and more about our project templates, see: http://aka.ms/winui-project-info.
namespace RelayClient.WinUI; namespace RelayClient.WinUI;
/// <summary> /// <summary>
/// Provides application-specific behavior to supplement the default Application class. /// Provides application-specific behavior to supplement the default Application class.
/// </summary> /// </summary>
public partial class App : MauiWinUIApplication public partial class App : MauiWinUIApplication
{ {
/// <summary> /// <summary>
/// Initializes the singleton application object. This is the first line of authored code /// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain(). /// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary> /// </summary>
public App() public App()
{ {
this.InitializeComponent(); this.InitializeComponent();
} }
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
} }

View File

@@ -1,46 +1,46 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Package <Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap"> IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" /> <Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="19688391-335B-479B-9E8A-8026C5759DFE" PhonePublisherId="00000000-0000-0000-0000-000000000000"/> <mp:PhoneIdentity PhoneProductId="19688391-335B-479B-9E8A-8026C5759DFE" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties> <Properties>
<DisplayName>$placeholder$</DisplayName> <DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName> <PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo> <Logo>$placeholder$.png</Logo>
</Properties> </Properties>
<Dependencies> <Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> <TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies> </Dependencies>
<Resources> <Resources>
<Resource Language="x-generate" /> <Resource Language="x-generate" />
</Resources> </Resources>
<Applications> <Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> <Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements <uap:VisualElements
DisplayName="$placeholder$" DisplayName="$placeholder$"
Description="$placeholder$" Description="$placeholder$"
Square150x150Logo="$placeholder$.png" Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png" Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent"> BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" /> <uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" /> <uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements> </uap:VisualElements>
</Application> </Application>
</Applications> </Applications>
<Capabilities> <Capabilities>
<rescap:Capability Name="runFullTrust" /> <rescap:Capability Name="runFullTrust" />
</Capabilities> </Capabilities>
</Package> </Package>

View File

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="RelayClient.WinUI.app"/> <assemblyIdentity version="1.0.0.0" name="RelayClient.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings> <windowsSettings>
<!-- The combination of below two tags have the following effect: <!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update 1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update 2) System < Windows 10 Anniversary Update
--> -->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware> <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings> </windowsSettings>
</application> </application>
</assembly> </assembly>

View File

@@ -1,9 +1,9 @@
using Foundation; using Foundation;
namespace RelayClient; namespace RelayClient;
[Register("AppDelegate")] [Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate public class AppDelegate : MauiUIApplicationDelegate
{ {
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
} }

View File

@@ -1,32 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>UIDeviceFamily</key> <key>UIDeviceFamily</key>
<array> <array>
<integer>1</integer> <integer>1</integer>
<integer>2</integer> <integer>2</integer>
</array> </array>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
<array> <array>
<string>arm64</string> <string>arm64</string>
</array> </array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>XSAppIconAssets</key> <key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string> <string>Assets.xcassets/appicon.appiconset</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1,15 +1,15 @@
using ObjCRuntime; using ObjCRuntime;
using UIKit; using UIKit;
namespace RelayClient; namespace RelayClient;
public class Program public class Program
{ {
// This is the main entry point of the application. // This is the main entry point of the application.
static void Main(string[] args) static void Main(string[] args)
{ {
// if you want to use a different Application Delegate class from "AppDelegate" // if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here. // you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate)); UIApplication.Main(args, null, typeof(AppDelegate));
} }
} }

View File

@@ -1,51 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps. This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK. The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application. You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest More information: https://aka.ms/maui-privacy-manifest
--> -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSPrivacyAccessedAPITypes</key> <key>NSPrivacyAccessedAPITypes</key>
<array> <array>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string> <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>C617.1</string> <string>C617.1</string>
</array> </array>
</dict> </dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string> <string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>35F9.1</string> <string>35F9.1</string>
</array> </array>
</dict> </dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string> <string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>E174.1</string> <string>E174.1</string>
</array> </array>
</dict> </dict>
<!-- <!--
The entry below is only needed when you're using the Preferences API in your app. The entry below is only needed when you're using the Preferences API in your app.
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string> <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key> <key>NSPrivacyAccessedAPITypeReasons</key>
<array> <array>
<string>CA92.1</string> <string>CA92.1</string>
</array> </array>
</dict> --> </dict> -->
</array> </array>
</dict> </dict>
</plist> </plist>

View File

@@ -1,8 +1,8 @@
{ {
"profiles": { "profiles": {
"Windows Machine": { "Windows Machine": {
"commandName": "Project", "commandName": "Project",
"nativeDebugging": false "nativeDebugging": false
} }
} }
} }

View File

@@ -1,46 +1,61 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks> <TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))"> <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">
$(TargetFrameworks);net10.0-windows10.0.19041.0 $(TargetFrameworks);net10.0-windows10.0.19041.0
</TargetFrameworks> </TargetFrameworks>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<RootNamespace>RelayClient</RootNamespace> <RootNamespace>RelayClient</RootNamespace>
<UseMaui>true</UseMaui> <UseMaui>true</UseMaui>
<SingleProject>true</SingleProject> <SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<MauiXamlInflator>SourceGen</MauiXamlInflator> <MauiXamlInflator>SourceGen</MauiXamlInflator>
<ApplicationTitle>RelayClient</ApplicationTitle> <ApplicationTitle>RelayClient</ApplicationTitle>
<ApplicationId>com.companyname.relayclient</ApplicationId> <ApplicationId>com.companyname.relayclient</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>1</ApplicationVersion>
<WindowsPackageType>None</WindowsPackageType> <WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion> <TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" /> <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" /> <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<MauiImage Include="Resources\Images\*" /> <MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" /> <MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<MauiFont Include="Resources\Fonts\*" /> <MauiFont Include="Resources\Fonts\*" />
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" /> <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup> <None Remove="Resources\Raw\test.html" />
<MauiAsset Include="Resources\Raw\test.html">
<ItemGroup> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" /> </MauiAsset>
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" /> </ItemGroup>
</ItemGroup>
<ItemGroup>
</Project> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="11.0.0-preview.2.26159.112" />
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageReference Include="SurrealDb.Net" Version="0.9.0" />
<PackageReference Include="WebSocketSharp" Version="1.0.3-rc11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RelayShared\RelayShared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Models\" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg id="New_Version" data-name="New Version" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 759.23 759.23"><defs><style>.cls-1{fill:#2e3338;}</style></defs><path id="Background" class="cls-1" d="M177.77,0H581.46A177.77,177.77,0,0,1,759.23,177.77V581.46A177.77,177.77,0,0,1,581.46,759.23H177.77A177.77,177.77,0,0,1,0,581.46V177.77A177.77,177.77,0,0,1,177.77,0Z"/></svg>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 370 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,15 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`. is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" /> <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials: These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset() async Task LoadMauiAsset()
{ {
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt"); using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd(); var contents = reader.ReadToEnd();
} }

View File

@@ -0,0 +1,24 @@
@import url('https://fonts.googleapis.com/css2?family=Anonymous+Pro');
body {
font-family: 'Anonymous Pro', monospace;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin: 80px 10px;
background-color: #666666;
}
video {
width: 40vw;
height: 30vw;
margin: 2rem;
background: #2c3e50;
}
.videos {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebRTC Demo</title>
</head>
<body>
<h2>1. Start your Webcam</h2>
<div class="videos">
<span>
<h3>Local Stream</h3>
<video id="webcamVideo" autoplay playsinline></video>
</span>
<span>
<h3>Remote Stream</h3>
<video id="remoteVideo" autoplay playsinline></video>
</span>
</div>
<button id="webcamButton">Start webcam</button>
<h2>2. Create a new Call</h2>
<button id="callButton" disabled>Create Call (offer)</button>
<h2>3. Join a Call</h2>
<p>Answer the call from a different browser window or device</p>
<input id="callInput" />
<button id="answerButton" disabled>Answer</button>
<h2>4. Hangup</h2>
<button id="hangupButton" disabled>Hangup</button>
<script type="module" src="test.js"></script>
</body>
</html>

View File

@@ -0,0 +1,146 @@
import './test.css';
import firebase from 'firebase/app';
import 'firebase/firestore';
const firebaseConfig = {
// your config
};
if (!firebase.apps.length) {
firebase.initializeApp(firebaseConfig);
}
const firestore = firebase.firestore();
const servers = {
iceServers: [
{
urls: ['stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302'],
},
],
iceCandidatePoolSize: 10,
};
// Global State
const pc = new RTCPeerConnection(servers);
let localStream = null;
let remoteStream = null;
// HTML elements
const webcamButton = document.getElementById('webcamButton');
const webcamVideo = document.getElementById('webcamVideo');
const callButton = document.getElementById('callButton');
const callInput = document.getElementById('callInput');
const answerButton = document.getElementById('answerButton');
const remoteVideo = document.getElementById('remoteVideo');
const hangupButton = document.getElementById('hangupButton');
// 1. Setup media sources
webcamButton.onclick = async () => {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
remoteStream = new MediaStream();
// Push tracks from local stream to peer connection
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
// Pull tracks from remote stream, add to video stream
pc.ontrack = (event) => {
event.streams[0].getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
};
webcamVideo.srcObject = localStream;
remoteVideo.srcObject = remoteStream;
callButton.disabled = false;
answerButton.disabled = false;
webcamButton.disabled = true;
};
// 2. Create an offer
callButton.onclick = async () => {
// Reference Firestore collections for signaling
const callDoc = firestore.collection('calls').doc();
const offerCandidates = callDoc.collection('offerCandidates');
const answerCandidates = callDoc.collection('answerCandidates');
callInput.value = callDoc.id;
// Get candidates for caller, save to db
pc.onicecandidate = (event) => {
event.candidate && offerCandidates.add(event.candidate.toJSON());
};
// Create offer
const offerDescription = await pc.createOffer();
await pc.setLocalDescription(offerDescription);
const offer = {
sdp: offerDescription.sdp,
type: offerDescription.type,
};
await callDoc.set({ offer });
// Listen for remote answer
callDoc.onSnapshot((snapshot) => {
const data = snapshot.data();
if (!pc.currentRemoteDescription && data?.answer) {
const answerDescription = new RTCSessionDescription(data.answer);
pc.setRemoteDescription(answerDescription);
}
});
// When answered, add candidate to peer connection
answerCandidates.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') {
const candidate = new RTCIceCandidate(change.doc.data());
pc.addIceCandidate(candidate);
}
});
});
hangupButton.disabled = false;
};
// 3. Answer the call with the unique ID
answerButton.onclick = async () => {
const callId = callInput.value;
const callDoc = firestore.collection('calls').doc(callId);
const answerCandidates = callDoc.collection('answerCandidates');
const offerCandidates = callDoc.collection('offerCandidates');
pc.onicecandidate = (event) => {
event.candidate && answerCandidates.add(event.candidate.toJSON());
};
const callData = (await callDoc.get()).data();
const offerDescription = callData.offer;
await pc.setRemoteDescription(new RTCSessionDescription(offerDescription));
const answerDescription = await pc.createAnswer();
await pc.setLocalDescription(answerDescription);
const answer = {
type: answerDescription.type,
sdp: answerDescription.sdp,
};
await callDoc.update({ answer });
offerCandidates.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
console.log(change);
if (change.type === 'added') {
let data = change.doc.data();
pc.addIceCandidate(new RTCIceCandidate(data));
}
});
});
};

View File

@@ -0,0 +1,114 @@
body {
margin: 0;
padding: 16px;
background-color: #121212;
color: #FFFFFF;
font-family: "Segoe UI", Arial, sans-serif;
}
h3 {
margin: 0 0 10px 0;
color: #FFA9D1F4;
}
button {
background-color: #332940;
color: #FFFFFF;
border: none;
padding: 8px 12px;
margin-right: 6px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease, transform 0.05s ease;
}
button:hover {
background-color: #40324f;
}
button:active {
transform: scale(0.97);
}
select {
background-color: #332940;
color: #FFFFFF;
border: none;
padding: 6px;
border-radius: 6px;
margin-left: 4px;
}
label {
font-size: 14px;
}
video {
border-radius: 8px;
border: 1px solid #332940;
background-color: #1F1A24;
}
#localVideoStatus,
#remoteVideoStatus,
#localMediaStatus,
#remoteMediaStatus {
font-size: 12px;
margin-top: 4px;
color: #FFA9D1F4;
}
textarea {
background-color: #1F1A24;
color: #FFFFFF;
border: 1px solid #332940;
border-radius: 8px;
padding: 8px;
font-family: monospace;
resize: none;
}
div {
margin-bottom: 6px;
}
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-thumb {
background: #332940;
border-radius: 4px;
}
.panel {
background: #1F1A24;
border: 1px solid #332940;
border-radius: 10px;
padding: 12px;
}
.remote-media-container {
display: flex;
flex-direction: row;
gap: 16px;
align-items: flex-start;
flex-wrap: nowrap;
overflow-x: auto;
padding: 8px 0;
}
.remote-media-tile,
.remote-tile {
flex: 0 0 auto;
width: 320px;
}
.remote-media-tile video,
.remote-tile video {
width: 320px;
height: 240px;
background: #111;
border-radius: 8px;
object-fit: cover;
}

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title></title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="styles/app.css">
<link rel="stylesheet" href="index.css">
<script src="_framework/hybridwebview.js"></script>
<script src="media.js"></script>
<script src="relaySocket.js"></script>
<script src="rtc.js"></script>
<script src="index.js"></script>
</head>
<body>
<div>
<h3>Relay RTC Test</h3>
</div>
<div>
<button onclick="Media.refreshDevicesAndPreview()">Refresh Devices</button>
<button onclick="RelayRtc.joinChannelCall()">Join Call</button>
</div>
<div style="margin-top: 8px;">
<label for="cameraSelect">Camera:</label>
<select id="cameraSelect"></select>
<label for="micSelect" style="margin-left: 12px;">Microphone:</label>
<select id="micSelect"></select>
</div>
<div style="margin-top: 10px;">
<div style="display: inline-block; margin-right: 20px; vertical-align: top;">
<video id="localVideo" autoplay playsinline muted style="width: 320px; height: 240px; background: #111;"></video>
<div id="localVideoStatus">Local video: waiting...</div>
<div id="localMediaStatus">Waiting for local media...</div>
</div>
<div id="remoteMediaContainer" class="remote-media-container"></div>
</div>
<div style="margin-top: 10px;">
<textarea readonly id="messageLog" style="width: 90%; height: 12em;"></textarea>
</div>
</body>
</html>

View File

@@ -0,0 +1,73 @@
let currentUsername = null;
let currentChannelId = null;
const configuration = {
iceServers: [
{
urls: [
"stun:stun1.l.google.com:19302",
"stun:stun2.l.google.com:19302"
]
}
],
iceCandidatePoolSize: 10
};
window.setUsername = function (name) {
currentUsername = name;
LogMessage("Username set to: " + currentUsername);
};
window.setChannelId = function (channelId) {
currentChannelId = channelId;
LogMessage("Channel set to: " + currentChannelId);
};
function LogMessage(msg) {
const messageLog = document.getElementById("messageLog");
if (!messageLog) {
console.log(msg);
return;
}
messageLog.value += "\r\n" + msg;
messageLog.scrollTop = messageLog.scrollHeight;
}
window.LogMessage = LogMessage;
window.addEventListener("HybridWebViewMessageReceived", function (e) {
LogMessage("Raw message: " + e.detail.message);
});
window.addEventListener("load", async () => {
LogMessage("RTC page loaded");
window.HybridWebView.SendRawMessage("rtc_page_ready");
Media.wireDeviceSelectors();
await Media.loadDevices();
await Media.ensureLocalMedia();
});
function testIndex(rawJson)
{
const data = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson;
if (data.sdp) {
data.sdp = data.sdp.replaceAll("(rn)", "\r\n");
}
handleRtcSignal(JSON.stringify(data));
// if (data.type === "rtc_offer") {
// handleOffer(data)
// }
// if (data.type === "rtc_answer") {
// data.sdp = data.sdp.replaceAll("(rn)", "\r\n");
// handleAnswer(data)
// }
}
function noDataTest()
{
LogMessage("No Data Called!!");
}

View File

@@ -0,0 +1,261 @@
let localStream = null;
const remoteStreams = {};
const Media = {
async loadDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(d => d.kind === "videoinput");
const mics = devices.filter(d => d.kind === "audioinput");
const cameraSelect = document.getElementById("cameraSelect");
const micSelect = document.getElementById("micSelect");
if (!cameraSelect || !micSelect) return;
const selectedCamera = cameraSelect.value;
const selectedMic = micSelect.value;
cameraSelect.innerHTML = "";
micSelect.innerHTML = "";
const noCamera = document.createElement("option");
noCamera.value = "";
noCamera.textContent = "No camera / audio only";
cameraSelect.appendChild(noCamera);
const defaultMic = document.createElement("option");
defaultMic.value = "";
defaultMic.textContent = "Default microphone";
micSelect.appendChild(defaultMic);
for (const camera of cameras) {
const option = document.createElement("option");
option.value = camera.deviceId;
option.textContent = camera.label || `Camera ${cameraSelect.length}`;
cameraSelect.appendChild(option);
}
for (const mic of mics) {
const option = document.createElement("option");
option.value = mic.deviceId;
option.textContent = mic.label || `Microphone ${micSelect.length}`;
micSelect.appendChild(option);
}
cameraSelect.value = [...cameraSelect.options].some(o => o.value === selectedCamera)
? selectedCamera
: "";
micSelect.value = [...micSelect.options].some(o => o.value === selectedMic)
? selectedMic
: "";
LogMessage(`Loaded devices: ${cameras.length} cameras, ${mics.length} mics`);
},
async ensureLocalMedia() {
const cameraSelect = document.getElementById("cameraSelect");
const micSelect = document.getElementById("micSelect");
if (localStream) {
return localStream;
}
const audioDeviceId = micSelect?.value || "";
const videoDeviceId = cameraSelect?.value || "";
const constraints = {
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
video: videoDeviceId
? { deviceId: { exact: videoDeviceId } }
: false
};
try {
localStream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
LogMessage("Selected media failed: " + err);
localStream = await navigator.mediaDevices.getUserMedia({
audio: audioDeviceId
? { deviceId: { exact: audioDeviceId } }
: true,
video: false
});
LogMessage("No camera available, continuing without video");
}
this.attachLocalStream(localStream);
LogMessage("Local media initialized");
return localStream;
},
attachLocalStream(stream) {
const localVideo = document.getElementById("localVideo");
const localMediaStatus = document.getElementById("localMediaStatus");
const localVideoStatus = document.getElementById("localVideoStatus");
const audioTracks = stream.getAudioTracks();
const videoTracks = stream.getVideoTracks();
if (localVideo) {
localVideo.srcObject = videoTracks.length > 0 ? stream : null;
}
if (localMediaStatus) {
localMediaStatus.textContent =
audioTracks.length > 0
? "Microphone active"
: "No microphone";
}
if (localVideoStatus) {
localVideoStatus.textContent =
videoTracks.length > 0
? "Local video active"
: "Local video unavailable";
}
},
async restartLocalMedia() {
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
await this.ensureLocalMedia();
if (window.RelayRtc?.applyLocalStreamToAllPeerConnections) {
await window.RelayRtc.applyLocalStreamToAllPeerConnections();
}
},
async refreshDevicesAndPreview() {
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
await this.loadDevices();
await this.ensureLocalMedia();
if (window.RelayRtc?.applyLocalStreamToAllPeerConnections) {
await window.RelayRtc.applyLocalStreamToAllPeerConnections();
}
},
async applyLocalStreamToPeerConnection(pc, username) {
const stream = await this.ensureLocalMedia();
const existingSenders = pc.getSenders();
for (const track of stream.getTracks()) {
const existingSender = existingSenders.find(sender =>
sender.track && sender.track.kind === track.kind
);
if (existingSender) {
await existingSender.replaceTrack(track);
LogMessage(`Replaced local ${track.kind} track for ${username}`);
} else {
pc.addTrack(track, stream);
LogMessage(`Added local ${track.kind} track for ${username}`);
}
}
},
async applyLocalStreamToAllPeerConnections() {
if (!window.RelayRtc?.peerConnections) return;
for (const [username, pc] of Object.entries(window.RelayRtc.peerConnections)) {
await this.applyLocalStreamToPeerConnection(pc, username);
}
},
attachRemoteStream(username, stream) {
remoteStreams[username] = stream;
const tile = this.ensureRemoteTile(username);
const video = tile.querySelector("video");
const status = tile.querySelector(".remote-media-status");
if (video) {
video.srcObject = stream;
}
const audioTracks = stream.getAudioTracks();
const videoTracks = stream.getVideoTracks();
if (status) {
status.textContent =
`${audioTracks.length > 0 ? "Audio" : "No audio"} / ` +
`${videoTracks.length > 0 ? "Video" : "No video"}`;
}
},
ensureRemoteTile(username) {
const container = document.getElementById("remoteMediaContainer");
if (!container) return null;
let tile = document.getElementById(`remote-tile-${username}`);
if (tile) return tile;
tile = document.createElement("div");
tile.id = `remote-tile-${username}`;
tile.className = "remote-media-tile";
const title = document.createElement("div");
title.className = "remote-media-title";
title.textContent = username;
const video = document.createElement("video");
video.autoplay = true;
video.playsInline = true;
const status = document.createElement("div");
status.className = "remote-media-status";
status.textContent = "Remote media: waiting...";
tile.appendChild(title);
tile.appendChild(video);
tile.appendChild(status);
container.appendChild(tile);
return tile;
},
removeRemoteStream(username) {
delete remoteStreams[username];
const tile = document.getElementById(`remote-tile-${username}`);
if (tile) {
tile.remove();
}
},
wireDeviceSelectors() {
const cameraSelect = document.getElementById("cameraSelect");
const micSelect = document.getElementById("micSelect");
if (cameraSelect) {
cameraSelect.addEventListener("change", async () => {
LogMessage("Camera changed");
await this.restartLocalMedia();
});
}
if (micSelect) {
micSelect.addEventListener("change", async () => {
LogMessage("Microphone changed");
await this.restartLocalMedia();
});
}
}
};
window.Media = Media;

View File

@@ -0,0 +1,46 @@
const RelaySocket = {
async joinRtcChannel() {
await window.HybridWebView.InvokeDotNet("JoinRtcChannel");
},
async leaveRtcChannel() {
await window.HybridWebView.InvokeDotNet("LeaveRtcChannel");
},
async getRtcParticipants() {
const raw = await window.HybridWebView.InvokeDotNet("GetRtcParticipants");
if (!raw) return [];
return typeof raw === "string"
? JSON.parse(raw)
: raw;
},
async sendRtcSignal(signal) {
if (!signal.channelId) signal.channelId = currentChannelId;
if (!signal.from) signal.from = currentUsername;
await window.HybridWebView.InvokeDotNet("SendRtcSignal", [
JSON.stringify(signal)
]);
},
receiveRtcSignal(rawJson) {
LogMessage("RelaySocket.receiveRtcSignal hit");
if (window.RelayRtc?.handleRtcSignal) {
LogMessage("Forwarding RTC signal to RelayRtc.handleRtcSignal");
return window.RelayRtc.handleRtcSignal(rawJson);
}
if (typeof window.handleRtcSignal === "function") {
LogMessage("Forwarding RTC signal to window.handleRtcSignal");
return window.handleRtcSignal(rawJson);
}
LogMessage("No RTC signal handler registered.");
}
};
window.RelaySocket = RelaySocket;

View File

@@ -0,0 +1,235 @@
const peerConnections = {};
async function joinChannelCall() {
LogMessage("Current username: " + currentUsername);
LogMessage("Current channel: " + currentChannelId);
if (!currentUsername || !currentChannelId) {
LogMessage("Cannot join RTC: missing username or channel.");
return;
}
await RelaySocket.joinRtcChannel();
await Media.ensureLocalMedia();
const participants = await RelaySocket.getRtcParticipants();
LogMessage("Participants: " + JSON.stringify(participants));
const existingUsers = participants.filter(x => x !== currentUsername);
if (existingUsers.length === 0) {
LogMessage("Joined call as first participant. Waiting for others...");
return;
}
for (const username of existingUsers) {
await sendOffer(username); //Creates an offer to each person in call for MESH RTC
}
}
async function sendOffer(username) {
const pc = await ensurePeerConnectionForUser(username);
await Media.applyLocalStreamToPeerConnection(pc, username);
const offer = await pc.createOffer();
// LogMessage(`Offer created: ${JSON.stringify(offer)}`);
await pc.setLocalDescription(offer);
await RelaySocket.sendRtcSignal({
type: "rtc_offer",
channelId: currentChannelId,
from: currentUsername,
to: username,
sdp: offer.sdp
});
LogMessage(`Sent offer to ${username}`);
}
async function handleRtcSignal(rawJson) {
try {
const msg = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson;
if (!msg || !msg.type) return;
if (msg.from === currentUsername) return;
if (msg.to && msg.to !== currentUsername) {
LogMessage(`Ignoring RTC signal meant for ${msg.to}`);
return;
}
LogMessage(`Received signal: ${msg.type} from ${msg.from}`);
if (msg.type === "rtc_offer") {
await handleOffer(msg);
return;
}
if (msg.type === "rtc_answer") {
await handleAnswer(msg);
return;
}
if (msg.type === "rtc_ice") {
await handleIce(msg);
return;
}
if (msg.type === "rtc_leave") {
closePeerConnection(msg.from);
return;
}
LogMessage("Unhandled RTC signal type: " + msg.type);
} catch (err) {
LogMessage("handleRtcSignal failed: " + err);
}
}
async function handleOffer(msg) {
LogMessage(`Offer handler: ${msg}`);
const pc = await ensurePeerConnectionForUser(msg.from);
await Media.ensureLocalMedia();
await Media.applyLocalStreamToPeerConnection(pc, msg.from);
// const offer = JSON.parse(msg.offer);
await pc.setRemoteDescription({
type: "offer",
sdp: msg.sdp
});
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await RelaySocket.sendRtcSignal({
type: "rtc_answer",
channelId: currentChannelId,
from: currentUsername,
to: msg.from,
sdp: answer.sdp
});
LogMessage(`Sent answer to ${msg.from}`);
}
async function handleAnswer(msg) {
const pc = peerConnections[msg.from];
if (!pc) {
LogMessage(`No peer connection found for answer from ${msg.from}`);
return;
}
await pc.setRemoteDescription({
type: "answer",
sdp: msg.sdp
});
LogMessage(`Applied answer from ${msg.from}`);
}
async function handleIce(msg) {
const pc = peerConnections[msg.from];
if (!pc) {
LogMessage(`No peer connection found for ICE from ${msg.from}`);
return;
}
if (!msg.candidate) return;
const candidateInit = {
candidate: msg.candidate,
sdpMid: msg.sdpMid,
sdpMLineIndex: msg.sdpMLineIndex
};
await pc.addIceCandidate(candidateInit);
LogMessage(`Applied ICE from ${msg.from}`);
}
async function ensurePeerConnectionForUser(username) {
if (peerConnections[username]) {
return peerConnections[username];
}
const pc = new RTCPeerConnection(configuration);
peerConnections[username] = pc;
pc.onicecandidate = async event => {
if (!event.candidate) return;
await RelaySocket.sendRtcSignal({
type: "rtc_ice",
channelId: currentChannelId,
from: currentUsername,
to: username,
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
});
};
pc.ontrack = event => {
LogMessage(`Remote track received from ${username}`);
const stream = event.streams[0];
if (!stream) return;
Media.attachRemoteStream(username, stream);
};
pc.onconnectionstatechange = () => {
LogMessage(`Connection ${username}: ${pc.connectionState}`);
if (
pc.connectionState === "failed" ||
pc.connectionState === "closed" ||
pc.connectionState === "disconnected"
) {
closePeerConnection(username);
}
};
return pc;
}
async function leaveChannelCall() {
await RelaySocket.sendRtcSignal({
type: "rtc_leave",
channelId: currentChannelId,
from: currentUsername
});
for (const username of Object.keys(peerConnections)) {
closePeerConnection(username);
}
await RelaySocket.leaveRtcChannel();
LogMessage("Left RTC channel");
}
function closePeerConnection(username) {
const pc = peerConnections[username];
if (!pc) return;
pc.close();
delete peerConnections[username];
Media.removeRemoteStream(username);
LogMessage(`Closed RTC connection with ${username}`);
}
window.RelayRtc = {
joinChannelCall,
leaveChannelCall,
handleRtcSignal,
peerConnections
};
window.handleRtcSignal = handleRtcSignal;

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> <path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> <path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> <path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" /> <path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,44 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary <ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml --> <!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
<Color x:Key="Primary">#512BD4</Color> <Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#ac99ea</Color> <Color x:Key="PrimaryDark">#ac99ea</Color>
<Color x:Key="PrimaryDarkText">#242424</Color> <Color x:Key="PrimaryDarkText">#242424</Color>
<Color x:Key="Secondary">#DFD8F7</Color> <Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDarkText">#9880e5</Color> <Color x:Key="SecondaryDarkText">#9880e5</Color>
<Color x:Key="Tertiary">#2B0B98</Color> <Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">White</Color> <Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color> <Color x:Key="Black">Black</Color>
<Color x:Key="Magenta">#D600AA</Color> <Color x:Key="Magenta">#D600AA</Color>
<Color x:Key="MidnightBlue">#190649</Color> <Color x:Key="MidnightBlue">#190649</Color>
<Color x:Key="OffBlack">#1f1f1f</Color> <Color x:Key="OffBlack">#1f1f1f</Color>
<Color x:Key="Gray100">#E1E1E1</Color> <Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color> <Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color> <Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color> <Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color> <Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color> <Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color> <Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color> <Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/> <SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/> <SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/> <SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/> <SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/> <SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/> <SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/> <SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/> <SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/> <SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/> <SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/> <SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/> <SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/> <SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
</ResourceDictionary> </ResourceDictionary>

View File

@@ -1,434 +1,434 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<ResourceDictionary <ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator"> <Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" /> <Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</Style> </Style>
<Style TargetType="IndicatorView"> <Style TargetType="IndicatorView">
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/> <Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/> <Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
</Style> </Style>
<Style TargetType="Border"> <Style TargetType="Border">
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" /> <Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="StrokeShape" Value="Rectangle"/> <Setter Property="StrokeShape" Value="Rectangle"/>
<Setter Property="StrokeThickness" Value="1"/> <Setter Property="StrokeThickness" Value="1"/>
</Style> </Style>
<Style TargetType="BoxView"> <Style TargetType="BoxView">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" /> <Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style> </Style>
<Style TargetType="Button"> <Style TargetType="Button">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" /> <Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/> <Setter Property="FontFamily" Value="AnonymousProRegular"/>
<Setter Property="FontSize" Value="14"/> <Setter Property="FontSize" Value="14"/>
<Setter Property="BorderWidth" Value="0"/> <Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="8"/> <Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,10"/> <Setter Property="Padding" Value="14,10"/>
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" /> <Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
<VisualState x:Name="PointerOver" /> <VisualState x:Name="PointerOver" />
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="CheckBox"> <Style TargetType="CheckBox">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" /> <Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="DatePicker"> <Style TargetType="DatePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/> <Setter Property="FontFamily" Value="AnonymousProRegular"/>
<Setter Property="FontSize" Value="14"/> <Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="Editor"> <Style TargetType="Editor">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/> <Setter Property="FontFamily" Value="AnonymousProRegular"/>
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" /> <Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="Entry"> <Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/> <Setter Property="FontFamily" Value="AnonymousProRegular"/>
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" /> <Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="ImageButton"> <Style TargetType="ImageButton">
<Setter Property="Opacity" Value="1" /> <Setter Property="Opacity" Value="1" />
<Setter Property="BorderColor" Value="Transparent"/> <Setter Property="BorderColor" Value="Transparent"/>
<Setter Property="BorderWidth" Value="0"/> <Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="0"/> <Setter Property="CornerRadius" Value="0"/>
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="Opacity" Value="0.5" /> <Setter Property="Opacity" Value="0.5" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
<VisualState x:Name="PointerOver" /> <VisualState x:Name="PointerOver" />
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="Label"> <Style TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" /> <Setter Property="FontFamily" Value="AnonymousProRegular" />
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="Label" x:Key="Headline"> <Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" /> <Setter Property="FontSize" Value="32" />
<Setter Property="HorizontalOptions" Value="Center" /> <Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" /> <Setter Property="HorizontalTextAlignment" Value="Center" />
</Style> </Style>
<Style TargetType="Label" x:Key="SubHeadline"> <Style TargetType="Label" x:Key="SubHeadline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="24" /> <Setter Property="FontSize" Value="24" />
<Setter Property="HorizontalOptions" Value="Center" /> <Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" /> <Setter Property="HorizontalTextAlignment" Value="Center" />
</Style> </Style>
<Style TargetType="Picker"> <Style TargetType="Picker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" /> <Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/> <Setter Property="FontFamily" Value="AnonymousProRegular"/>
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="ProgressBar"> <Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" /> <Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="RadioButton"> <Style TargetType="RadioButton">
<Setter Property="BackgroundColor" Value="Transparent"/> <Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/> <Setter Property="FontFamily" Value="AnonymousProRegular"/>
<Setter Property="FontSize" Value="14"/> <Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="RefreshView"> <Style TargetType="RefreshView">
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" /> <Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style> </Style>
<Style TargetType="SearchBar"> <Style TargetType="SearchBar">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" /> <Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" /> <Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" /> <Setter Property="FontFamily" Value="AnonymousProRegular" />
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="SearchHandler"> <Style TargetType="SearchHandler">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" /> <Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" /> <Setter Property="FontFamily" Value="AnonymousProRegular" />
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="Shadow"> <Style TargetType="Shadow">
<Setter Property="Radius" Value="15" /> <Setter Property="Radius" Value="15" />
<Setter Property="Opacity" Value="0.5" /> <Setter Property="Opacity" Value="0.5" />
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" /> <Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
<Setter Property="Offset" Value="10,10" /> <Setter Property="Offset" Value="10,10" />
</Style> </Style>
<Style TargetType="Slider"> <Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" /> <Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" /> <Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" /> <Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/> <Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/> <Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/> <Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="SwipeItem"> <Style TargetType="SwipeItem">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" /> <Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
</Style> </Style>
<Style TargetType="Switch"> <Style TargetType="Switch">
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" /> <Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="ThumbColor" Value="{StaticResource White}" /> <Setter Property="ThumbColor" Value="{StaticResource White}" />
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
<VisualState x:Name="On"> <VisualState x:Name="On">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" /> <Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" /> <Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
<VisualState x:Name="Off"> <VisualState x:Name="Off">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" /> <Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<Style TargetType="TimePicker"> <Style TargetType="TimePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent"/> <Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="FontFamily" Value="OpenSansRegular"/> <Setter Property="FontFamily" Value="AnonymousProRegular"/>
<Setter Property="FontSize" Value="14"/> <Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/> <Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/> <Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates"> <VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" /> <VisualState x:Name="Normal" />
<VisualState x:Name="Disabled"> <VisualState x:Name="Disabled">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" /> <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
<!-- <!--
<Style TargetType="TitleBar"> <Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/> <Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups"> <Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList> <VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates"> <VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive"> <VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="BackgroundColor" Value="Transparent" /> <Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" /> <Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
<VisualState x:Name="TitleBarTitleInactive"> <VisualState x:Name="TitleBarTitleInactive">
<VisualState.Setters> <VisualState.Setters>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" /> <Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" /> <Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters> </VisualState.Setters>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
</VisualStateGroupList> </VisualStateGroupList>
</Setter> </Setter>
</Style> </Style>
--> -->
<Style TargetType="Page" ApplyToDerivedTypes="True"> <Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/> <Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" /> <Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</Style> </Style>
<Style TargetType="Shell" ApplyToDerivedTypes="True"> <Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" /> <Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" /> <Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" /> <Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" /> <Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" /> <Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" /> <Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" /> <Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" /> <Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" /> <Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" /> <Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style> </Style>
<Style TargetType="NavigationPage"> <Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" /> <Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" /> <Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" /> <Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
</Style> </Style>
<Style TargetType="TabbedPage"> <Style TargetType="TabbedPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" /> <Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" /> <Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" /> <Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" /> <Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style> </Style>
</ResourceDictionary> </ResourceDictionary>

194
RelayClient/ServerAPI.cs Normal file
View File

@@ -0,0 +1,194 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using RelayShared.Services;
namespace RelayClient;
public class ServerAPI
{
static HttpClient client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5000/") };
static HttpClient core = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:1337/") };
// static HttpClient client = new HttpClient { BaseAddress = new Uri("http://192.168.1.92:5000/") };
// static HttpClient core = new HttpClient { BaseAddress = new Uri("http://192.168.1.92:1337/") };
public static async Task setupClient()
{
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
core.DefaultRequestHeaders.Accept.Clear();
core.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
core.DefaultRequestHeaders.Add("User-Agent", "RelayClient");
MainPage._userToken = await CoreUserSignin(new AuthSignin
{
UserName = MainPage._username,
Password = "password"
});
await CoreUserAlive(new AuthSignin
{
UserName = MainPage._username,
Password = MainPage._userToken
});
}
public static async Task<Uri> CoreUserAlive(AuthSignin data)
{
HttpResponseMessage response = await core.PostAsJsonAsync("user/isAlive", data);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<string> CoreUserSignin(AuthSignin data)
{
HttpResponseMessage response = await core.PostAsJsonAsync("user/signin", data);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
public static async Task<Uri> PostOfferAsync(DBOffer offer)
{
HttpResponseMessage response = await client.PostAsJsonAsync(
"api/rtc/offer", offer);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> GetAllOffersAsync()
{
HttpResponseMessage response = await client.GetAsync("api/rtc/offers");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<bool> GetIsChannelActiveAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/active/{channelId}");
response.EnsureSuccessStatusCode();
return bool.Parse(response.Content.ReadAsStringAsync().Result);
}
public static async Task<RtcDescription> GetOffersForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/offers/{channelId}");
response.EnsureSuccessStatusCode();
RtcDescription? offer = JsonSerializer.Deserialize<RtcDescription>(await response.Content.ReadAsStringAsync());
return offer;
}
public static async Task<Uri?> PostAnswerAsync(DBOffer answer)
{
HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/answer", answer);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine("PostAnswerAsync status: " + response.StatusCode);
Console.WriteLine("PostAnswerAsync body: " + body);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> GetAnswersForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/answers/{channelId}");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> GetLatestAnswerForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/latest/{channelId}");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> PostIceCandidateAsync(DBIceCandidate candidate)
{
HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/candidate", candidate);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> GetIceCandidatesForChannelAsync(string channelId)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/candidates/{channelId}");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> GetIceCandidatesForChannelByUserAsync(string channelId, string userId, string directions)
{
HttpResponseMessage response = await client.GetAsync($"api/rtc/candidates/{channelId}/{userId}/{directions}");
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<Uri> PostLeave(RtcLeave leave)
{
HttpResponseMessage response = await client.PostAsJsonAsync("api/rtc/leave", leave);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<RtcDescription?> GetAnswerForChannelAsync(string? channelId)
{
if (string.IsNullOrWhiteSpace(channelId))
return null;
HttpResponseMessage response = await client.GetAsync($"api/rtc/answer/{channelId}");
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<RtcDescription>(json);
}
public static async Task<List<string>> GetRtcParticipantsAsync(string? channelId)
{
if (string.IsNullOrWhiteSpace(channelId))
return new List<string>();
HttpResponseMessage response = await client.GetAsync($"api/rtc/participants/{channelId}");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
}
}
public class RtcDescription
{
public string type { get; set; }
public string sdp { get; set; }
}
public class DBOffer
{
public required string ChannelId { get; set; }
public required string Username { get; set; }
public required RtcDescription SessionDescription { get; set; }
}
public class IceCandidate
{
public required string candidate { get; set; }
public required string sdpMid { get; set; }
public required int sdpMLineIndex { get; set; }
public required string usernameFragment { get; set; }
}
public class DBIceCandidate
{
public required string ChannelId { get; set; }
public required string Username { get; set; }
public required IceCandidate Candidate { get; set; }
}
public class RtcLeave
{
public string ChannelId { get; set; }
public string Username { get; set; }
}

View File

@@ -0,0 +1,281 @@
using System.Text.Json;
using RelayClient.Crypto;
using RelayShared.Services;
using WebSocketSharp;
namespace RelayClient.Services;
/// <summary>
/// The client-side WebSocket transport. Mirrors ChatSocketBehavior on the server.
///
/// Sending: typed helpers (SendGetHistory, SendRtcJoinChannel, SendEditMessage, …) build the
/// appropriate WsControlMessage or SocketEncryptedMessage and route through SendRaw. SendRaw
/// always uses synchronous _socket.Send because WebSocketSharp's SendAsync calls
/// Action.BeginInvoke internally, which throws PlatformNotSupportedException on .NET 5+.
/// Callers that need non-blocking sends (e.g. MainPage.SendMessage for image attachments)
/// wrap the call in Task.Run.
///
/// Receiving: OnMessage peeks the JSON. If it has an "Event" property → WsEventMessage (acks).
/// If it has a "Type" property → SignalType discriminator, deserialise into the right Socket*
/// type, fire the matching C# event. MainPage subscribes to these events.
///
/// Connect order matters: the first frame after the handshake is Authenticate (so the server
/// can verify the Core-issued token), then RegisterKey (so the server has our public key
/// before any encrypted message arrives), then GetServerKey + GetChannels.
/// </summary>
public sealed class RelaySocketClient
{
/// <summary>Username this socket is authenticated as. Captured at construction.</summary>
private readonly string _username;
/// <summary>The underlying WebSocketSharp client. Owned (constructed) by this class.</summary>
private readonly WebSocket _socket;
/// <summary>
/// The server's RSA public key, cached after the first GetServerKey response.
/// MainPage reads this to encrypt outbound chat payloads.
/// </summary>
public string? ServerPublicKey { get; private set; }
/// <summary>Fires for every raw incoming text frame. Mostly used for debug logging.</summary>
public event Action<string>? RawMessageReceived;
/// <summary>Fires when the server pushes a fresh channel list (initial connect or after CRUD).</summary>
public event Action<SocketChannelList>? ChannelListReceived;
/// <summary>Fires for newly-arrived chat messages (SignalType.EncryptedChat).</summary>
public event Action<SocketEncryptedMessage>? EncryptedChatReceived;
/// <summary>Fires when an existing message is edited by its author (SignalType.MessageEdited).</summary>
public event Action<SocketEncryptedMessage>? MessageEdited;
/// <summary>Fires when a message is deleted (SignalType.MessageDeleted).</summary>
public event Action<SocketMessageDeletedEvent>? MessageDeleted;
/// <summary>Fires when another user is typing in a channel.</summary>
public event Action<SocketTypingEvent>? TypingReceived;
/// <summary>Fires in response to a SendGetEditHistory request.</summary>
public event Action<SocketEditHistoryResponse>? EditHistoryReceived;
/// <summary>Fires for encrypted RTC SDP/ICE signals — RtcBridgeService forwards into the JS engine.</summary>
public event Action<SocketRtcSignalMessage>? EncryptedRtcSignalReceived;
/// <summary>Fires once when the server's public key arrives. Mainly used by tests; production reads ServerPublicKey directly.</summary>
public event Action<string>? ServerPublicKeyReceived;
/// <summary>Diagnostic logger. MainPage subscribes Console.WriteLine here.</summary>
public event Action<string>? Log;
/// <summary>Default URL points at localhost dev server. Production passes a remote URL.</summary>
public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/")
{
_username = username;
_socket = new WebSocket(url);
_socket.OnMessage += OnMessage;
}
/// <summary>
/// Opens the WebSocket and fires the four-step boot handshake IN ORDER:
/// Authenticate → RegisterKey → GetServerKey → GetChannels. Order matters because the
/// server uses RegisterKey to populate its session→username map (needed for permission
/// checks on subsequent messages).
/// </summary>
public void Connect()
{
_socket.Connect();
var publicKey = KeyStorage.LoadPublicKey(_username);
SendControlMessage(new WsControlMessage { Action = WsAction.Authenticate, Username = _username, Token = MainPage._userToken });
SendControlMessage(new WsControlMessage { Action = WsAction.RegisterKey, Username = _username, PublicKey = publicKey });
SendControlMessage(new WsControlMessage { Action = WsAction.GetServerKey });
SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels });
}
/// <summary>Detaches the message handler and closes the socket. Called from MainPage.OnDisappearing.</summary>
public void Disconnect()
{
_socket.OnMessage -= OnMessage;
if (_socket.ReadyState == WebSocketState.Open)
_socket.Close();
}
/// <summary>Generic control-plane send. Serialises the WsControlMessage to JSON and ships it.</summary>
public void SendControlMessage(WsControlMessage message) =>
SendRaw(JsonSerializer.Serialize(message));
/// <summary>Request the message history for a channel. Server streams it back as individual EncryptedChat frames.</summary>
public void SendGetHistory(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.GetHistory, Username = _username, ChannelId = channelId });
/// <summary>Tell the server we've joined a voice channel. Fires Speak permission check server-side.</summary>
public void SendRtcJoinChannel(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.RtcJoin, Username = _username, ChannelId = channelId });
/// <summary>Tell the server we've left the voice channel. Idempotent server-side.</summary>
public void SendRtcLeaveChannel(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.RtcLeave, Username = _username, ChannelId = channelId });
/// <summary>Notify channel peers that we're typing. Server broadcasts a SocketTypingEvent to everyone but us.</summary>
public void SendTyping(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.SendTyping, Username = _username, ChannelId = channelId });
/// <summary>Request all historical versions of a message. Server replies with SocketEditHistoryResponse.</summary>
public void SendGetEditHistory(string messageId, string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.GetEditHistory, Username = _username, MessageId = messageId, ChannelId = channelId });
/// <summary>Create a new channel. Permission-gated server-side; on success the server broadcasts a fresh channel list.</summary>
public void SendCreateChannel(string name, ChannelType type, string group = "") =>
SendControlMessage(new WsControlMessage
{
Action = WsAction.CreateChannel,
ChannelName = name,
ChannelType = (int)type,
ChannelGroup = group
});
/// <summary>Soft-delete a channel. Permission-gated server-side.</summary>
public void SendDeleteChannel(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.DeleteChannel, ChannelId = channelId });
/// <summary>
/// Send an edit for an existing message. Caller is responsible for encrypting the new
/// content (with the server's public key) before calling — same encryption shape as a new send.
/// </summary>
public void SendEditMessage(string messageId, string channelId, EncryptedPayload encrypted) =>
SendJson(new SocketEncryptedMessage
{
Type = SignalType.ClientEditMessage, MessageId = messageId,
SenderUsername = _username, ChannelId = channelId,
CipherText = encrypted.CipherText, Nonce = encrypted.Nonce,
Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey
});
/// <summary>Request soft-delete of one of our own messages. Server checks ownership before honoring.</summary>
public void SendDeleteMessage(string messageId, string channelId) =>
SendJson(new SocketEncryptedMessage
{
Type = SignalType.ClientDeleteMessage, MessageId = messageId,
SenderUsername = _username, ChannelId = channelId
});
/// <summary>
/// The single send pinch point. Synchronous (WebSocketSharp's SendAsync is broken on .NET 5+
/// due to Action.BeginInvoke). All exceptions are logged AND rethrown so the calling
/// Task.Run can surface them to the user via DisplayAlert.
/// </summary>
public void SendRaw(string message)
{
if (_socket.ReadyState != WebSocketState.Open)
{
Log?.Invoke($"[{_username}] Drop: socket not open ({_socket.ReadyState}), {message.Length} bytes.");
return;
}
try
{
_socket.Send(message);
}
catch (Exception ex)
{
Log?.Invoke($"[{_username}] Send failed ({message.Length} bytes): {ex.Message}");
throw;
}
}
/// <summary>Convenience: JSON-serialise any payload and ship it. Used for all SocketEncryptedMessage and WsControlMessage sends.</summary>
public void SendJson<T>(T payload) => SendRaw(JsonSerializer.Serialize(payload));
/// <summary>
/// WebSocketSharp callback for every incoming text frame. Peeks the JSON to decide whether
/// it's a control-plane ack (Event property) or data-plane message (Type property), then
/// fires the matching public C# event. Exceptions are caught locally so a malformed frame
/// can't drop the connection.
/// </summary>
private void OnMessage(object? sender, MessageEventArgs e)
{
RawMessageReceived?.Invoke(e.Data);
Log?.Invoke($"[{_username}] RAW: {e.Data[..Math.Min(200, e.Data.Length)]}");
try
{
using var doc = JsonDocument.Parse(e.Data);
var root = doc.RootElement;
if (root.TryGetProperty("Event", out var evEl))
{
var wsEvent = (WsEvent)evEl.GetInt32();
switch (wsEvent)
{
case WsEvent.KeyRegistered: Log?.Invoke($"[{_username}] Key registered."); return;
case WsEvent.Authenticated: Log?.Invoke($"[{_username}] Authenticated."); return;
case WsEvent.Error:
var det = root.TryGetProperty("Detail", out var d) ? d.GetString() : null;
Log?.Invoke($"[{_username}] Server error: {det}");
return;
}
return;
}
if (!root.TryGetProperty("Type", out var typeEl)) return;
var type = (SignalType)typeEl.GetInt32();
switch (type)
{
case SignalType.ChannelList:
{
var p = JsonSerializer.Deserialize<SocketChannelList>(e.Data);
if (p is not null) ChannelListReceived?.Invoke(p);
return;
}
case SignalType.ServerPublicKey:
{
var p = JsonSerializer.Deserialize<ServerPublicKeyMessage>(e.Data);
if (p is not null) { ServerPublicKey = p.PublicKey; ServerPublicKeyReceived?.Invoke(p.PublicKey); }
return;
}
case SignalType.EncryptedSignal:
{
var p = JsonSerializer.Deserialize<SocketRtcSignalMessage>(e.Data);
if (p is not null) EncryptedRtcSignalReceived?.Invoke(p);
return;
}
case SignalType.EncryptedChat:
{
var p = JsonSerializer.Deserialize<SocketEncryptedMessage>(e.Data);
if (p is not null) EncryptedChatReceived?.Invoke(p);
return;
}
case SignalType.MessageEdited:
{
var p = JsonSerializer.Deserialize<SocketEncryptedMessage>(e.Data);
if (p is not null) MessageEdited?.Invoke(p);
return;
}
case SignalType.MessageDeleted:
{
var p = JsonSerializer.Deserialize<SocketMessageDeletedEvent>(e.Data);
if (p is not null) MessageDeleted?.Invoke(p);
return;
}
case SignalType.TypingIndicator:
{
var p = JsonSerializer.Deserialize<SocketTypingEvent>(e.Data);
if (p is not null) TypingReceived?.Invoke(p);
return;
}
case SignalType.EditHistory:
{
var p = JsonSerializer.Deserialize<SocketEditHistoryResponse>(e.Data);
if (p is not null) EditHistoryReceived?.Invoke(p);
return;
}
}
}
catch (Exception ex)
{
Log?.Invoke($"[{_username}] WS parse error: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,304 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using RelayClient.Crypto;
using RelayShared.Rtc;
using RelayShared.Services;
namespace RelayClient.Services;
/// <summary>
/// The bridge between the C# WebSocket pipe and the JavaScript WebRTC engine
/// running inside the HybridWebView (which is shown when a Voice channel is open).
///
/// Outbound (JS → C# → server): the WebView JS calls into C# via SendRtcSignal(json).
/// We deserialise to RtcSignalMessage, encrypt with the server's public key, wrap in
/// SocketRtcSignalMessage, and send through the WebSocket.
///
/// Inbound (server → C# → JS): the WebSocket fires EncryptedRtcSignalReceived. MainPage
/// hands it to HandleIncomingRtcSignalAsync, which decrypts with the user's private key
/// and calls back into JS via hybridWebView.InvokeJavaScriptAsync("testIndex", …).
///
/// JoinRtcChannel / LeaveRtcChannel just send WsAction control messages; presence tracking
/// happens server-side in RtcChannelPresenceService.
/// </summary>
public sealed class RtcBridgeService
{
/// <summary>The currently-signed-in username. Stamped onto outgoing RTC signals.</summary>
private readonly string _username;
/// <summary>The shared WebSocket to RelayServer. Outbound RTC signals ride on this.</summary>
private readonly RelaySocketClient _socket;
/// <summary>The MAUI HybridWebView that hosts the JS WebRTC engine. We push JS calls into it.</summary>
private readonly HybridWebView _hybridWebView;
/// <summary>Lazy view into MainPage._currentChannelId so we always have the current voice channel.</summary>
private readonly Func<string?> _getCurrentChannelId;
/// <summary>Diagnostic logger that surfaces messages back to the WebView UI. Used for status/error reporting.</summary>
private readonly Action<string> _sendRawToWebView;
/// <summary>Captures collaborators. MainPage constructs this once and never replaces it.</summary>
public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView,
Func<string?> getCurrentChannelId, Action<string> sendRawToWebView)
{
_username = username;
_socket = socket;
_hybridWebView = hybridWebView;
_getCurrentChannelId = getCurrentChannelId;
_sendRawToWebView = sendRawToWebView;
}
/// <summary>Sends RtcJoin for the currently-selected channel. Server-side, this triggers the Speak permission check and presence registration.</summary>
public Task JoinRtcChannel()
{
var channelId = _getCurrentChannelId();
if (string.IsNullOrWhiteSpace(channelId))
return Task.CompletedTask;
_socket.SendRtcJoinChannel(channelId);
return Task.CompletedTask;
}
/// <summary>Sends RtcLeave for the currently-selected channel. Clears server-side voice presence so peers stop seeing us.</summary>
public void LeaveRtcChannel()
{
var channelId = _getCurrentChannelId();
if (string.IsNullOrWhiteSpace(channelId))
return;
_socket.SendRtcLeaveChannel(channelId);
}
/// <summary>
/// Called from JavaScript (via the HybridWebView bridge) when the WebRTC engine wants to
/// send an SDP offer/answer or ICE candidate to other peers. Parses the JSON, fills in
/// missing ChannelId/From, encrypts with the server's public key, ships as
/// SocketRtcSignalMessage. The server then forwards it (re-encrypted per-recipient) to
/// every other session in the same voice channel.
/// </summary>
public void SendRtcSignal(string json)
{
if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey))
{
_sendRawToWebView("SendRtcSignal failed: server public key not loaded.");
return;
}
RtcSignalMessage? rtcSignal;
try
{
rtcSignal = JsonSerializer.Deserialize<RtcSignalMessage>(json);
}
catch (Exception ex)
{
_sendRawToWebView("SendRtcSignal failed to parse RTC signal: " + ex.Message);
return;
}
if (rtcSignal is null)
return;
rtcSignal.ChannelId ??= _getCurrentChannelId();
rtcSignal.From ??= _username;
// _sendRawToWebView($"RTC_SIGNAL file: {JsonSerializer.Serialize(rtcSignal)}");
if (string.IsNullOrWhiteSpace(rtcSignal.ChannelId))
{
_sendRawToWebView("SendRtcSignal failed: missing channel id.");
return;
}
var outgoingJson = JsonSerializer.Serialize(rtcSignal);
try
{
var encrypted = E2EeHelper.EncryptForRecipient(outgoingJson, _socket.ServerPublicKey);
var payload = new SocketRtcSignalMessage
{
Type = SignalType.EncryptedSignal,
SenderUsername = _username,
ChannelId = rtcSignal.ChannelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
_socket.SendJson(payload);
_sendRawToWebView($"SendRtcSignal sent: {rtcSignal.Type} -> {rtcSignal.To}");
}
catch (Exception ex)
{
_sendRawToWebView("SendRtcSignal failed: " + ex.Message);
}
}
/// <summary>JS bridge: returns the current voice-channel roster as JSON. Hits ServerAPI's REST endpoint, not the WebSocket.</summary>
public async Task<string> GetRtcParticipants()
{
var channelId = _getCurrentChannelId();
if (string.IsNullOrWhiteSpace(channelId))
return "[]";
var participants = await ServerAPI.GetRtcParticipantsAsync(channelId);
return JsonSerializer.Serialize(participants ?? []);
}
/// <summary>
/// MainPage hands incoming SocketRtcSignalMessage frames here. Filters out our own
/// frames, validates the channel scope, decrypts with the user's private key, parses to
/// RtcSignalMessage, then pushes into the JS RTC engine via SendRtcSignalToJsAsync.
/// </summary>
public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload)
{
// _sendRawToWebView("HandleIncomingRtcSignal called");
var currentChannelId = _getCurrentChannelId();
if (payload.ChannelId != currentChannelId)
{
_sendRawToWebView("Channel id does not match");
return;
}
if (payload.SenderUsername == _username)
{
_sendRawToWebView("Received own message");
return;
}
string decryptedJson;
try
{
var privateKey = KeyStorage.LoadPrivateKey(_username);
decryptedJson = E2EeHelper.DecryptForRecipient(
new EncryptedPayload
{
CipherText = payload.CipherText,
Nonce = payload.Nonce,
Tag = payload.Tag,
EncryptedKey = payload.EncryptedKey
},
privateKey
);
}
catch (Exception ex)
{
_sendRawToWebView("RTC decrypt failed: " + ex.Message);
return;
}
RtcSignalMessage? rtcSignal;
try
{
rtcSignal = JsonSerializer.Deserialize<RtcSignalMessage>(decryptedJson);
// _sendRawToWebView($"Received Encrypted Signal: [{rtcSignal.From}]: {rtcSignal.Offer}");
}
catch (Exception ex)
{
_sendRawToWebView("RTC signal parse failed: " + ex.Message);
return;
}
if (rtcSignal is null)
{
_sendRawToWebView("rtcSignal is null");
return;
}
if (!string.IsNullOrWhiteSpace(rtcSignal.To) &&
!string.Equals(rtcSignal.To, _username, StringComparison.OrdinalIgnoreCase))
{
_sendRawToWebView($"Ignoring RTC signal meant for {rtcSignal.To}");
return;
}
// _sendRawToWebView("Received encrypted RTC signal: " + decryptedJson);
await SendRtcSignalToJsAsync(rtcSignal);
}
/// <summary>
/// Pushes the current username and channelId into JS globals (window.setUsername, window.setChannelId).
/// Called whenever the user switches voice channels OR the JS engine reports rtc_page_ready.
/// </summary>
public Task PushRtcContextToJsAsync()
{
MainThread.BeginInvokeOnMainThread(async () =>
{
var usernameJson = JsonSerializer.Serialize(_username);
var channelIdJson = JsonSerializer.Serialize(_getCurrentChannelId());
await _hybridWebView.EvaluateJavaScriptAsync($"window.setUsername({usernameJson})");
await _hybridWebView.EvaluateJavaScriptAsync($"window.setChannelId({channelIdJson})");
});
return Task.CompletedTask;
}
/// <summary>
/// Final hop: hands a decrypted RtcSignalMessage off to the JS engine via
/// hybridWebView.InvokeJavaScriptAsync("testIndex", …). SDP strings have their newlines
/// escaped as "(rn)" because the JSON marshalling otherwise breaks them.
/// </summary>
private Task SendRtcSignalToJsAsync(RtcSignalMessage data)
{
if (data.Type == "rtc_offer" || data.Type == "rtc_answer")
{
data.Sdp = data.Sdp.Replace("\r\n", "(rn)");
}
MainThread.BeginInvokeOnMainThread(async () =>
{
try
{
// await _hybridWebView.InvokeJavaScriptAsync("testIndex", [JsonSerializer.Serialize(data)], [RtcJsType.Default.String]);
await _hybridWebView.InvokeJavaScriptAsync("testIndex", [data], [RtcJsType.Default.RtcSignalMessage]);
#region OldDebugger
// var jsArg = JsonSerializer.Serialize(data);
//
// await _hybridWebView.EvaluateJavaScriptAsync($@"
// try {{
// window.HybridWebView.SendRawMessage('C# eval entered');
//
// if (!window.RelaySocket) {{
// window.HybridWebView.SendRawMessage('window.RelaySocket missing');
// }} else if (typeof window.RelaySocket.receiveRtcSignal !== 'function') {{
// window.HybridWebView.SendRawMessage('RelaySocket.receiveRtcSignal missing');
// }} else {{
// window.HybridWebView.SendRawMessage('Calling RelaySocket.receiveRtcSignal');
// window.RelaySocket.receiveRtcSignal({jsArg});
// }}
// }} catch (err) {{
// window.HybridWebView.SendRawMessage('RTC JS dispatch failed: ' + err);
// }}
// ");
#endregion
}
catch (Exception ex)
{
_sendRawToWebView("SendRtcSignalToJsAsync failed: " + ex.Message);
}
});
return Task.CompletedTask;
}
}
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(RtcDescription))]
[JsonSerializable(typeof(List<RtcSignalMessage>))]
[JsonSerializable(typeof(RtcSignalMessage))]
[JsonSerializable(typeof(IceCandidate))]
[JsonSerializable(typeof(List<IceCandidate>))]
[JsonSerializable(typeof(string))]
internal partial class RtcJsType : JsonSerializerContext
{
}

184
RelayCore/.gitignore vendored
View File

@@ -1,93 +1,93 @@
############################################ ############################################
# .NET Build # .NET Build
############################################ ############################################
bin/ bin/
obj/ obj/
out/ out/
publish/ publish/
############################################ ############################################
# Visual Studio # Visual Studio
############################################ ############################################
.vs/ .vs/
*.user *.user
*.suo *.suo
*.userprefs *.userprefs
*.csproj.user *.csproj.user
*.dbmdl *.dbmdl
*.cache *.cache
*.pdb *.pdb
*.opendb *.opendb
############################################ ############################################
# Rider / JetBrains # Rider / JetBrains
############################################ ############################################
.idea/ .idea/
*.sln.iml *.sln.iml
############################################ ############################################
# VSCode # VSCode
############################################ ############################################
.vscode/ .vscode/
############################################ ############################################
# NuGet # NuGet
############################################ ############################################
*.nupkg *.nupkg
*.snupkg *.snupkg
packages/ packages/
.nuget/ .nuget/
.nuget/packages/ .nuget/packages/
############################################ ############################################
# Logs # Logs
############################################ ############################################
*.log *.log
logs/ logs/
############################################ ############################################
# OS files # OS files
############################################ ############################################
.DS_Store .DS_Store
Thumbs.db Thumbs.db
############################################ ############################################
# Local secrets / environment # Local secrets / environment
############################################ ############################################
.env .env
.env.* .env.*
secrets.json secrets.json
appsettings.Development.json appsettings.Development.json
############################################ ############################################
# E2EE private keys # E2EE private keys
############################################ ############################################
keys/* keys/*
!keys/.gitkeep !keys/.gitkeep
############################################ ############################################
# Local test databases / data folders # Local test databases / data folders
############################################ ############################################
data/ data/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
############################################ ############################################
# Temporary files # Temporary files
############################################ ############################################
*.tmp *.tmp
*.temp *.temp
*.bak *.bak
*.swp *.swp

View File

@@ -1,82 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayCore;
public static class E2EeHelper
{
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
{
using var rsa = RSA.Create(2048);
var publicKey = Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo());
var privateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey());
return (publicKey, privateKey);
}
public static EncryptedMessagePayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64)
{
var aesKey = RandomNumberGenerator.GetBytes(32);
var nonce = RandomNumberGenerator.GetBytes(12);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[16];
using (var aes = new AesGcm(aesKey, 16))
{
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
}
var recipientPublicKey = Convert.FromBase64String(recipientPublicKeyBase64);
byte[] encryptedKey;
using (var rsa = RSA.Create())
{
rsa.ImportSubjectPublicKeyInfo(recipientPublicKey, out _);
encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256);
}
return new EncryptedMessagePayload
{
CipherText = Convert.ToBase64String(cipherBytes),
Nonce = Convert.ToBase64String(nonce),
Tag = Convert.ToBase64String(tag),
EncryptedKey = Convert.ToBase64String(encryptedKey)
};
}
public static string DecryptForRecipient(EncryptedMessagePayload payload, string recipientPrivateKeyBase64)
{
var encryptedKey = Convert.FromBase64String(payload.EncryptedKey);
var privateKey = Convert.FromBase64String(recipientPrivateKeyBase64);
byte[] aesKey;
using (var rsa = RSA.Create())
{
rsa.ImportPkcs8PrivateKey(privateKey, out _);
aesKey = rsa.Decrypt(encryptedKey, RSAEncryptionPadding.OaepSHA256);
}
var nonce = Convert.FromBase64String(payload.Nonce);
var tag = Convert.FromBase64String(payload.Tag);
var cipherBytes = Convert.FromBase64String(payload.CipherText);
var plainBytes = new byte[cipherBytes.Length];
using (var aes = new AesGcm(aesKey, 16))
{
aes.Decrypt(nonce, cipherBytes, tag, plainBytes);
}
return Encoding.UTF8.GetString(plainBytes);
}
}
public class EncryptedMessagePayload
{
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required string EncryptedKey { get; set; }
}

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Primitives;
using RelayCore.Services;
using RelayShared.Services;
namespace RelayCore.Endpoints;
public static class AuthEndpoints
{
public static void MapAuthEndpoints(this WebApplication app)
{
app.MapPost("/user/signin", async (AuthSignin request, APIAuthService service, HttpContext context) =>
{
string ip = "";
StringValues userAgent = "";
if (context != null)
{
ip = context.Connection.RemoteIpAddress?.MapToIPv4().ToString();
context.Request.Headers.TryGetValue("User-Agent", out userAgent);
}
var token = await service.UserSigninAsync(request, ip, userAgent.ToString());
return token != null ? Results.Ok(token) : Results.Unauthorized();
});
app.MapGet("/users", async (APIAuthService service) =>
{
return Results.Ok(await service.GetUsersAsync());
});
app.MapPost("/user/register", async (AuthRegister request, APIAuthService service, HttpContext context) =>
{
var ip = context.Connection.RemoteIpAddress?.MapToIPv4().ToString();
context.Request.Headers.TryGetValue("User-Agent", out var userAgent);
var token = await service.UserRegisterAsync(request, ip, userAgent);
return token != null ? Results.Ok(token) : Results.Ok("Username or Email already exists!");
});
app.MapPost("/user/isAlive", async (AuthSignin request, HttpContext context) =>
{
var ip = context.Connection.RemoteIpAddress?.MapToIPv4().ToString();
context.Request.Headers.TryGetValue("User-Agent", out var userAgent);
Console.WriteLine($"UN: {request.UserName}\nToken: {request.Password}\nIP: {ip}\nUserAgent: {userAgent}");
return Results.Ok();
});
app.MapPost("/server/verify/user", async (AuthUserVerify request, APIAuthService service) =>
{
bool valid = await service.ServerVerifyUser(request);
Console.WriteLine($"UN: {request.Username}\nToken: {request.Token}");
return Results.Ok(valid);
});
app.MapPost("/server/license/generate", async (AuthServerLicenseGenerate request, APIAuthService service) =>
{
var license = await service.ServerLicenseGenerate(request);
return license != null ? Results.Ok(license) : Results.BadRequest();
});
app.MapPost("/server/license/verify", async (AuthServerLicenseVerify request, APIAuthService service) =>
{
bool valid = await service.ServerVerifyLicense(request);
return Results.Ok(valid);
});
}
}

View File

@@ -0,0 +1,9 @@
namespace RelayCore.Enums;
public enum AccountStatuses
{
Active,
Suspended,
Banned,
Deleted
}

View File

@@ -0,0 +1,9 @@
namespace RelayCore.Enums;
public enum LicenseStatuses
{
Active,
Expired,
Renewable,
Revoked
}

View File

@@ -0,0 +1,10 @@
namespace RelayCore.Enums;
public enum LicenseType
{
Free,
Basic,
Advanced,
Pro,
Enterprise
}

View File

@@ -0,0 +1,11 @@
namespace RelayCore.Enums;
public enum LogEvents
{
LoginSuccess,
LoginFailure,
LogoutSuccess,
LogoutFailure,
PasswordResetSuccess,
PasswordResetFailure,
}

View File

@@ -0,0 +1,10 @@
namespace RelayCore.Enums;
public enum OnlineStatuses
{
Online,
Busy,
DND,
Invisible,
Offline
}

View File

@@ -0,0 +1,14 @@
using SurrealDb.Net.Models;
namespace RelayCore.Models;
public class AuthAudits : Record
{
public required string UserId { get; set; }
public required int EventType { get; set; }
public bool Success { get; set; }
public required string IpAddress { get; set; }
public required string UserAgent { get; set; }
public required string Details { get; set; }
public required DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
using SurrealDb.Net.Models;
namespace RelayCore.Models;
public class Licenses : Record
{
public required string UserId { get; set; }
public required int LicenseType { get; set; }
public required int Status { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime StartsAt { get; set; }
public required DateTime UpdatedAt { get; set; }
public required DateTime ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,8 @@
using SurrealDb.Net.Models;
namespace RelayCore.Models;
public class PasswordHash : Record
{
public string? Password { get; set; }
}

View File

@@ -1,109 +1,108 @@
using System; using System.Security.Cryptography;
using System.Security.Cryptography; using System.Text;
using System.Text; using Konscious.Security.Cryptography;
using Konscious.Security.Cryptography;
namespace RelayCore.Models
namespace PasswordHasher {
{ /// <summary>
/// <summary> /// Provides secure password hashing functionality using Argon2id algorithm
/// Provides secure password hashing functionality using Argon2id algorithm /// </summary>
/// </summary> public class PasswordHasher
public class PasswordHasher {
{ /// <summary>
/// <summary> /// Size of the salt in bytes
/// Size of the salt in bytes /// </summary>
/// </summary> private const int SaltSize = 16;
private const int SaltSize = 16;
/// <summary>
/// <summary> /// Size of the hash in bytes
/// Size of the hash in bytes /// </summary>
/// </summary> private const int HashSize = 32;
private const int HashSize = 32;
/// <summary>
/// <summary> /// Number of threads to use for parallel computation
/// Number of threads to use for parallel computation /// </summary>
/// </summary> private const int DegreeOfParallelism = 2;
private const int DegreeOfParallelism = 1;
/// <summary>
/// <summary> /// Number of iterations for the Argon2id algorithm
/// Number of iterations for the Argon2id algorithm /// </summary>
/// </summary> private const int Iterations = 2;
private const int Iterations = 2;
/// <summary>
/// <summary> /// Memory size in KB to use
/// Memory size in KB to use /// </summary>
/// </summary> private const int MemorySize = 19456; // 19 MB
private const int MemorySize = 19456; // 19 MB
/// <summary>
/// <summary> /// Generates a secure hash of a password using Argon2id with a random salt
/// Generates a secure hash of a password using Argon2id with a random salt /// </summary>
/// </summary> /// <param name="password">The plain text password to hash</param>
/// <param name="password">The plain text password to hash</param> /// <returns>A Base64 string containing the combined salt and hash</returns>
/// <returns>A Base64 string containing the combined salt and hash</returns> /// <exception cref="ArgumentNullException">Thrown when password is null</exception>
/// <exception cref="ArgumentNullException">Thrown when password is null</exception> public string HashPassword(string password)
public string HashPassword(string password) {
{ // Generate a random salt
// Generate a random salt byte[] salt = new byte[SaltSize];
byte[] salt = new byte[SaltSize]; using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) {
{ rng.GetBytes(salt);
rng.GetBytes(salt); }
}
// Create hash
// Create hash byte[] hash = HashPassword(password, salt);
byte[] hash = HashPassword(password, salt);
// Combine salt and hash
// Combine salt and hash var combinedBytes = new byte[salt.Length + hash.Length];
var combinedBytes = new byte[salt.Length + hash.Length]; Array.Copy(salt, 0, combinedBytes, 0, salt.Length);
Array.Copy(salt, 0, combinedBytes, 0, salt.Length); Array.Copy(hash, 0, combinedBytes, salt.Length, hash.Length);
Array.Copy(hash, 0, combinedBytes, salt.Length, hash.Length);
// Convert to base64 for storage
// Convert to base64 for storage return Convert.ToBase64String(combinedBytes);
return Convert.ToBase64String(combinedBytes); }
}
/// <summary>
/// <summary> /// Generates a password hash using Argon2id with a specific salt
/// Generates a password hash using Argon2id with a specific salt /// </summary>
/// </summary> /// <param name="password">The plain text password</param>
/// <param name="password">The plain text password</param> /// <param name="salt">The salt to use for hashing</param>
/// <param name="salt">The salt to use for hashing</param> /// <returns>A byte array containing the password hash</returns>
/// <returns>A byte array containing the password hash</returns> private byte[] HashPassword(string password, byte[] salt)
private byte[] HashPassword(string password, byte[] salt) {
{ var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) {
{ Salt = salt,
Salt = salt, DegreeOfParallelism = DegreeOfParallelism,
DegreeOfParallelism = DegreeOfParallelism, Iterations = Iterations,
Iterations = Iterations, MemorySize = MemorySize
MemorySize = MemorySize };
}; return argon2.GetBytes(HashSize);
return argon2.GetBytes(HashSize); }
}
/// <summary>
/// <summary> /// Verifies if a password matches a stored hash
/// Verifies if a password matches a stored hash /// </summary>
/// </summary> /// <param name="password">The plain text password to verify</param>
/// <param name="password">The plain text password to verify</param> /// <param name="hashedPassword">The stored hash in Base64 format</param>
/// <param name="hashedPassword">The stored hash in Base64 format</param> /// <returns>True if the password matches the hash, false otherwise</returns>
/// <returns>True if the password matches the hash, false otherwise</returns> /// <exception cref="ArgumentNullException">Thrown when password or hashedPassword are null</exception>
/// <exception cref="ArgumentNullException">Thrown when password or hashedPassword are null</exception> /// <exception cref="FormatException">Thrown when hashedPassword is not in valid Base64 format</exception>
/// <exception cref="FormatException">Thrown when hashedPassword is not in valid Base64 format</exception> public bool VerifyPassword(string password, string hashedPassword)
public bool VerifyPassword(string password, string hashedPassword) {
{ // Decode the stored hash
// Decode the stored hash byte[] combinedBytes = Convert.FromBase64String(hashedPassword);
byte[] combinedBytes = Convert.FromBase64String(hashedPassword);
// Extract salt and hash
// Extract salt and hash byte[] salt = new byte[SaltSize];
byte[] salt = new byte[SaltSize]; byte[] hash = new byte[HashSize];
byte[] hash = new byte[HashSize]; Array.Copy(combinedBytes, 0, salt, 0, SaltSize);
Array.Copy(combinedBytes, 0, salt, 0, SaltSize); Array.Copy(combinedBytes, SaltSize, hash, 0, HashSize);
Array.Copy(combinedBytes, SaltSize, hash, 0, HashSize);
// Compute hash for the input password
// Compute hash for the input password byte[] newHash = HashPassword(password, salt);
byte[] newHash = HashPassword(password, salt);
// Compare the hashes
// Compare the hashes return CryptographicOperations.FixedTimeEquals(hash, newHash);
return CryptographicOperations.FixedTimeEquals(hash, newHash); }
} }
}
} }

View File

@@ -0,0 +1,12 @@
using SurrealDb.Net.Models;
namespace RelayCore.Models;
public class PasswordReset : Record
{
public required string UserId { get; set; }
public required string TokenHash { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime ExpiresAt { get; set; }
public bool Revoked { get; set; }
}

View File

@@ -0,0 +1,16 @@
using SurrealDb.Net.Models;
namespace RelayCore.Models;
public class Sessions : Record
{
public required RecordId UserId { get; set; }
public required string TokenHash { get; set; }
public required DateTime IssuedAt { get; set; }
public required DateTime ExpiresAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public bool Revoked { get; set; }
public required string DeviceName { get; set; }
public required string IpAddress { get; set; }
public required string UserAgent { get; set; }
}

18
RelayCore/Models/Users.cs Normal file
View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
using SurrealDb.Net.Models;
namespace RelayCore.Models;
public class Users : Record
{
public required string Username { get; set; }
public string? Password { get; set; }
public required string Email { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime UpdatedAt { get; set; }
public required DateTime LastLogin { get; set; }
public bool TwoFactorEnabled { get; set; }
public bool EmailVerified { get; set; }
public required int AccountStatus { get; set; }
public required int OnlineStatus { get; set; }
}

View File

@@ -1,298 +1,130 @@
using SurrealDb.Net; using SurrealDb.Net;
using SurrealDb.Net.Models; using SurrealDb.Net.Models.Auth;
using SurrealDb.Net.Models.Auth; using System.Text.Json;
using System.Text.Json; using System.Net;
using PasswordHasher; using System.Text;
using RelayCore;
using RelayCore.Enums;
using var db = new SurrealDbClient("ws://127.0.0.1:8000/rpc"); using RelayCore.Models;
using RelayCore.Endpoints;
await db.SignIn(new RootAuth { Username = "root", Password = "secret" }); using RelayCore.Services;
await db.Use("test", "test");
var keeper = await CreateUserAsync(db, "Keeper317", "Keeper317@gmail.com", "password"); await using var db = new SurrealDbClient("ws://127.0.0.1:8000/rpc");
var kira = await CreateUserAsync(db, "Ru_Kira", "jduesling13@gmail.com", "password"); await db.SignIn(new RootAuth { Username = "root", Password = "secret" });
await db.Use("test", "test");
Console.WriteLine($"Keeper created: {ToJsonString(keeper)}");
Console.WriteLine($"Kira created: {ToJsonString(kira)}"); var keeper = await CreateUserAsync(db, "Keeper317", "Keeper317@gmail.com", "password");
var kira = await CreateUserAsync(db, "Ru_Kira", "jduesling13@gmail.com", "password");
var keeperKeys = E2EeHelper.GenerateRsaKeyPair(); var test = await CreateUserAsync(db, "Test", "test@gmail.com", "password");
var kiraKeys = E2EeHelper.GenerateRsaKeyPair();
var server = new Program();
KeyStorage.SavePrivateKey("Keeper317", keeperKeys.privateKey);
KeyStorage.SavePrivateKey("Ru_Kira", kiraKeys.privateKey); Console.WriteLine($"Keeper created: {ToJsonString(keeper)}");
Console.WriteLine($"Kira created: {ToJsonString(kira)}");
await db.Create("user_keys", new UserKeys Console.WriteLine($"Test created: {ToJsonString(test)}");
{
UserId = keeper.Id.ToString(), var builder = WebApplication.CreateBuilder(args);
PublicKey = keeperKeys.publicKey, builder.WebHost.UseUrls("http://127.0.0.1:1337/");
CreatedAt = DateTime.UtcNow, // builder.WebHost.UseUrls("http://192.168.1.92:1337");
UpdatedAt = DateTime.UtcNow builder.Services.AddSingleton(db);
}); builder.Services.AddScoped<APIAuthService>();
await db.Create("user_keys", new UserKeys var app = builder.Build();
{ app.MapGet("/", () => "Auth Server Running!");
UserId = kira.Id.ToString(), app.MapAuthEndpoints();
PublicKey = kiraKeys.publicKey,
CreatedAt = DateTime.UtcNow, // await server.Main(db);
UpdatedAt = DateTime.UtcNow
}); await app.StartAsync();
Console.WriteLine("API Started");
Console.WriteLine("Public keys stored for both users."); Console.WriteLine("\n\n\n");
var conversation = await db.Create("conversations", new Conversations Console.Write("Press any key to stop.");
{ Console.ReadKey(true);
CreatedByUserId = keeper.Id.ToString(),
CreatedAt = DateTime.UtcNow, await app.StopAsync();
UpdatedAt = DateTime.UtcNow, return;
Title = "Keeper317 + Ru_Kira",
IsDirectMessage = true static string ToJsonString(object? o)
}); {
return JsonSerializer.Serialize(o, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine($"Conversation created: {ToJsonString(conversation)}"); }
await db.Create("conversation_members", new ConversationMembers static async Task<Users> CreateUserAsync(SurrealDbClient db, string username, string email, string rawPassword)
{ {
ConversationId = conversation.Id.ToString(), var now = DateTime.UtcNow;
UserId = keeper.Id.ToString(),
JoinedAt = DateTime.UtcNow var user = new Users
}); {
Username = username,
await db.Create("conversation_members", new ConversationMembers Email = email,
{ CreatedAt = now,
ConversationId = conversation.Id.ToString(), UpdatedAt = now,
UserId = kira.Id.ToString(), LastLogin = now,
JoinedAt = DateTime.UtcNow TwoFactorEnabled = false,
}); EmailVerified = false,
AccountStatus = (int)AccountStatuses.Active,
Console.WriteLine("Conversation members added."); OnlineStatus = (int)OnlineStatuses.Online,
};
var encrypted = E2EeHelper.EncryptForRecipient("hello from Keeper317", kiraKeys.publicKey);
var created = await db.Create("auth_users", user);
var savedMessage = await db.Create("messages", new Messages
{ var hasher = new PasswordHasher();
ConversationId = conversation.Id.ToString(), var passwordHash = hasher.HashPassword(created.Id.ToString() + rawPassword);
SenderUserId = keeper.Id.ToString(),
RecipientUserId = kira.Id.ToString(), var updated = await db.Merge<PasswordHash, Users>(new PasswordHash
CipherText = encrypted.CipherText, {
Nonce = encrypted.Nonce, Id = created.Id,
Tag = encrypted.Tag, Password = passwordHash
EncryptedKey = encrypted.EncryptedKey, });
CreatedAt = DateTime.UtcNow
}); return updated;
}
Console.WriteLine($"Encrypted message saved: {ToJsonString(savedMessage)}");
partial class Program
var decrypted = E2EeHelper.DecryptForRecipient(encrypted, kiraKeys.privateKey); {
Console.WriteLine($"Decrypted for Ru_Kira: {decrypted}"); public async Task Main(SurrealDbClient db)
{
return; // Set up listener
using var listener = new HttpListener();
static string ToJsonString(object? o) listener.Prefixes.Add("http://127.0.0.1:8080/");
{ listener.Start();
return JsonSerializer.Serialize(o, new JsonSerializerOptions { WriteIndented = true }); Console.WriteLine("API Started: http://127.0.0.1:8080/");
}
while (true)
static async Task<Users> CreateUserAsync(SurrealDbClient db, string username, string email, string rawPassword) {
{ // Process requests
var now = DateTime.UtcNow; var context = await listener.GetContextAsync();
var req = context.Request;
var user = new Users var res = context.Response;
{
Username = username, if (req.Url.AbsolutePath == "/api/hello" && req.HttpMethod == "GET")
Email = email, {
CreatedAt = now, var data = new { Message = "Hello, world!", Time = DateTime.Now };
UpdatedAt = now, byte[] buf = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data));
LastLogin = now, res.ContentType = "application/json";
TwoFactorEnabled = false, await res.OutputStream.WriteAsync(buf, 0, buf.Length);
EmailVerified = false, }
AccountStatus = (int)AccountStatuses.Active,
OnlineStatus = (int)OnlineStatuses.Online, if (req.Url.AbsolutePath == "/api/users" && req.HttpMethod == "GET")
}; {
var data = new { Message = GetDBUsers(db).Result, Time = DateTime.Now };
var created = await db.Create("users", user); byte[] buf = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data));
res.ContentType = "application/json";
var hasher = new PasswordHasher.PasswordHasher(); await res.OutputStream.WriteAsync(buf, 0, buf.Length);
var passwordHash = hasher.HashPassword(created.Id.ToString() + rawPassword); }
res.Close();
var updated = await db.Merge<PasswordHash, Users>(new PasswordHash }
{ }
Id = created.Id, static async Task<Users[]> GetDBUsers(SurrealDbClient db)
Password = passwordHash {
}); var users = await db.Select<Users>("users");
Console.WriteLine(ToJsonString(users));
return updated; return users.ToArray();
} }
static string ToJsonString(object? o)
public static class KeyStorage {
{ return JsonSerializer.Serialize(o, new JsonSerializerOptions { WriteIndented = true, });
public static void SavePrivateKey(string username, string privateKey) }
{
Directory.CreateDirectory("keys");
File.WriteAllText(Path.Combine("keys", $"{username}.private.key"), privateKey);
}
public static string LoadPrivateKey(string username)
{
return File.ReadAllText(Path.Combine("keys", $"{username}.private.key"));
}
public static bool PrivateKeyExists(string username)
{
return File.Exists(Path.Combine("keys", $"{username}.private.key"));
}
}
public class ResponsibilityMerge : Record
{
public bool Marketing { get; set; }
}
public class Group
{
public bool Marketing { get; set; }
public int Count { get; set; }
}
public class Users : Record
{
public required string Username { get; set; }
public string? Password { get; set; }
public required string Email { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime UpdatedAt { get; set; }
public required DateTime LastLogin { get; set; }
public bool TwoFactorEnabled { get; set; }
public bool EmailVerified { get; set; }
public required int AccountStatus { get; set; }
public required int OnlineStatus { get; set; }
}
public class PasswordHash : Record
{
public string? Password { get; set; }
}
public class Sessions : Record
{
public required string UserId { get; set; }
public required string TokenHash { get; set; }
public required DateTime IssuedAt { get; set; }
public required DateTime ExpiresAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public bool Revoked { get; set; }
public required string DeviceName { get; set; }
public required string IpAddress { get; set; }
public required string UserAgent { get; set; }
}
public class PasswordReset : Record
{
public required string UserId { get; set; }
public required string TokenHash { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime ExpiresAt { get; set; }
public bool Revoked { get; set; }
}
public class Licenses : Record
{
public required string UserId { get; set; }
public required int LicenseType { get; set; }
public required int Status { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime StartsAt { get; set; }
public required DateTime UpdatedAt { get; set; }
public required DateTime ExpiresAt { get; set; }
}
public class AuthAudits : Record
{
public required string UserId { get; set; }
public required int EventType { get; set; }
public bool Success { get; set; }
public required string IpAddress { get; set; }
public required string UserAgent { get; set; }
public required string Details { get; set; }
public required DateTime CreatedAt { get; set; }
}
public class UserKeys : Record
{
public required string UserId { get; set; }
public required string PublicKey { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime UpdatedAt { get; set; }
}
public class Conversations : Record
{
public required string CreatedByUserId { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime UpdatedAt { get; set; }
public string? Title { get; set; }
public bool IsDirectMessage { get; set; }
}
public class ConversationMembers : Record
{
public required string ConversationId { get; set; }
public required string UserId { get; set; }
public required DateTime JoinedAt { get; set; }
}
public class Messages : Record
{
public required string ConversationId { get; set; }
public required string SenderUserId { get; set; }
public required string RecipientUserId { get; set; }
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required string EncryptedKey { get; set; }
public required DateTime CreatedAt { get; set; }
}
enum AccountStatuses
{
Active,
Suspended,
Banned,
Deleted
}
enum OnlineStatuses
{
Online,
Busy,
DND,
Invisible,
Offline
}
enum LicenseStatuses
{
Active,
Expired,
Renewable,
Revoked
}
enum LicenseType
{
Free,
Basic,
Advanced,
Pro,
Enterprise
}
enum LogEvents
{
LoginSuccess,
LoginFailure,
LogoutSuccess,
LogoutFailure,
PasswordResetSuccess,
PasswordResetFailure,
} }

View File

@@ -1,16 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="SurrealDb.Net" Version="0.9.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.9" />
</ItemGroup> <PackageReference Include="SurrealDb.Net" Version="0.9.0" />
</ItemGroup>
</Project>
<ItemGroup>
<ProjectReference Include="..\RelayShared\RelayShared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,126 @@
using Microsoft.AspNetCore.WebUtilities;
using Newtonsoft.Json;
using RelayCore.Endpoints;
using RelayCore.Enums;
using RelayCore.Models;
using RelayShared.Services;
using SurrealDb.Net;
using SurrealDb.Net.Models;
namespace RelayCore.Services;
public class APIAuthService(SurrealDbClient _db)
{
public async Task<List<Users>> GetUsersAsync()
{
var users = await _db.Select<Users>("auth_users");
return users.Where(x => x.Username is not null).OrderByDescending(x=>x.CreatedAt).ToList();
}
public async Task<string?> UserSigninAsync(AuthSignin request, string ip, string userAgent)
{
var hasher = new PasswordHasher();
var users = await _db.Select<Users>("auth_users");
var user = users.FirstOrDefault(x => (x.Username.ToLower() == request.UserName.ToLower() ||
x.Email.ToLower() == request.UserName.ToLower()) &&
hasher.VerifyPassword(x.Id + request.Password, x.Password));
if (user == null)
return null;
var tokens = await _db.Select<Sessions>("auth_sessions");
var token = tokens.Where(x => x.UserId == user.Id && x.IpAddress == ip && x.UserAgent == userAgent && !x.Revoked)
.OrderByDescending(x => x.ExpiresAt).FirstOrDefault();
if (token != null)
if (token.ExpiresAt > DateTime.UtcNow)
return token.TokenHash;
//TODO: Generate TOKEN
var newToken = hasher.HashPassword($"{request.UserName}{userAgent}");
//TODO: Store TOKEN and Username for verification
var sessionId = await _db.Create("auth_sessions", new Sessions
{
UserId = user.Id,
TokenHash = newToken,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(30),
DeviceName = "",
Revoked = false,
IpAddress = ip,
UserAgent = userAgent
});
//TODO: Add invalidation to TOKENs
return newToken;
}
public async Task<string?> UserRegisterAsync(AuthRegister request, string ip, string userAgent)
{
var hasher = new PasswordHasher();
var users = await _db.Select<Users>("auth_users");
var user = users.FirstOrDefault(x => x.Username.ToLower() == request.Username.ToLower() || x.Email.ToLower() == request.Email.ToLower());
if (user == null)
{
var now = DateTime.Now;
var created = await _db.Create("auth_users", new Users
{
Username = request.Username,
Email = request.Email,
CreatedAt = now,
UpdatedAt = now,
LastLogin = now,
TwoFactorEnabled = false,
EmailVerified = false,
AccountStatus = (int)AccountStatuses.Active,
OnlineStatus = (int)OnlineStatuses.Online,
});
var passwordHash = hasher.HashPassword(created.Id + request.Password);
await _db.Merge<PasswordHash, Users>(new PasswordHash
{
Id = created.Id,
Password = passwordHash
});
return await UserSigninAsync(new AuthSignin{UserName=request.Username, Password = request.Password}, ip, userAgent);
}
return null;
}
public async Task<bool> ServerVerifyUser(AuthUserVerify request)
{
var users = await _db.Select<Users>("auth_users");
var user = users.FirstOrDefault(x => x.Username == request.Username);
if (user == null)
return false;
var sessions = await _db.Select<Sessions>("auth_sessions");
var session = sessions.FirstOrDefault(x => x.TokenHash == request.Token && x.UserId == user.Id);
if (session == null)
return false;
return true;
}
public async Task<string?> ServerLicenseGenerate(AuthServerLicenseGenerate request)
{
var hasher = new PasswordHasher();
string token = null;
token = hasher.HashPassword(DateTime.Now.ToString("yyyyMMddHHmmss"));
var created = await _db.Create("auth_licenses", new DBLicense
{
Token = token,
IsClient = false,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(365),
IsExpired = false,
});
return token;
}
public async Task<bool> ServerVerifyLicense(AuthServerLicenseVerify request)
{
var tokens = await _db.Select<DBLicense>("auth_licenses");
var token = tokens.FirstOrDefault(x => x.Token == request.License);
if (token != null)
return true;
return false;
}
}

185
RelayServer/.gitignore vendored
View File

@@ -1,93 +1,94 @@
############################################ ############################################
# .NET Build # .NET Build
############################################ ############################################
bin/ bin/
obj/ obj/
out/ out/
publish/ publish/
############################################ ############################################
# Visual Studio # Visual Studio
############################################ ############################################
.vs/ .vs/
*.user *.user
*.suo *.suo
*.userprefs *.userprefs
*.csproj.user *.csproj.user
*.dbmdl *.dbmdl
*.cache *.cache
*.pdb *.pdb
*.opendb *.opendb
############################################ ############################################
# Rider / JetBrains # Rider / JetBrains
############################################ ############################################
.idea/ .idea/
*.sln.iml *.sln.iml
############################################ ############################################
# VSCode # VSCode
############################################ ############################################
.vscode/ .vscode/
############################################ ############################################
# NuGet # NuGet
############################################ ############################################
*.nupkg *.nupkg
*.snupkg *.snupkg
packages/ packages/
.nuget/ .nuget/
.nuget/packages/ .nuget/packages/
############################################ ############################################
# Logs # Logs
############################################ ############################################
*.log *.log
logs/ logs/
############################################ ############################################
# OS files # OS files
############################################ ############################################
.DS_Store .DS_Store
Thumbs.db Thumbs.db
############################################ ############################################
# Local secrets / environment # Local secrets / environment
############################################ ############################################
.env .env
.env.* .env.*
secrets.json secrets.json
appsettings.Development.json appsettings.Development.json
############################################ ############################################
# E2EE private keys # E2EE private keys
############################################ ############################################
keys/* keys/*
!keys/.gitkeep !keys/.gitkeep
############################################ ############################################
# Local test databases / data folders # Local test databases / data folders
############################################ ############################################
data/ data/
*.db !Services/Data/
*.sqlite *.db
*.sqlite3 *.sqlite
*.sqlite3
############################################
# Temporary files ############################################
############################################ # Temporary files
############################################
*.tmp
*.temp *.tmp
*.bak *.temp
*.bak
*.swp *.swp

View File

@@ -0,0 +1,141 @@
using System.Text.Json;
using RelayShared.Rtc;
using RelayServer.Services.Rtc;
using RelayShared.Services;
namespace RelayServer.Endpoints;
public static class RtcEndpoints
{
/// <summary>
/// Maps all RTC-related HTTP endpoints used for storing offers and answers,
/// writing ICE candidates, checking active calls, and leaving active calls.
/// </summary>
/// <param name="app">The web application to map endpoints onto.</param>
public static void MapRtcEndpoints(this WebApplication app)
{
// Store or update the current SDP offer for a channel call.
app.MapPost("/api/rtc/offer", async (RtcOffer request, RtcCallService rtcCallService) =>
{
await rtcCallService.WriteOfferAsync(request.ChannelId, request.Username, request.SessionDescription);
RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage
{
Type = SignalType.OfferUpdated,
ChannelId = request.ChannelId,
Username = request.Username
});
return Results.Ok();
});
// List all offers.
app.MapGet("/api/rtc/offers", async (RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.GetOffersAsync());
});
// Return whether the specified channel currently has an active call.
app.MapGet("/api/rtc/active/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.HasActiveCallAsync(channelId));
});
// Return the latest stored SDP offer for the specified channel.
app.MapGet("/api/rtc/offers/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
var offer = await rtcCallService.GetOfferAsync(channelId);
return offer is null ? Results.NotFound() : Results.Ok(offer);
});
// Store a new SDP answer for the specified channel call.
app.MapPost("/api/rtc/answer", async (RtcOffer request, RtcCallService rtcCallService) =>
{
Console.WriteLine($"RTC answer received for channel {request.ChannelId} from {request.Username}");
await rtcCallService.WriteAnswerAsync(request.ChannelId, request.SessionDescription);
Console.WriteLine($"Broadcasting rtc_answer_updated for {request.ChannelId}");
RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage
{
Type = SignalType.AnswerUpdated,
ChannelId = request.ChannelId
});
return Results.Ok();
});
// Return all answers stored for the specified channel.
app.MapGet("/api/rtc/answers/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.GetAnswersAsync(channelId));
});
app.MapGet("/api/rtc/participants/{channelId}", (string channelId) =>
{
return Results.Ok(RtcChannelPresenceService.GetUsersInChannel(channelId));
});
// Return the latest answer stored for the specified channel.
app.MapGet("/api/rtc/answer/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
var answer = await rtcCallService.GetLatestAnswerAsync(channelId);
return answer is null ? Results.NotFound() : Results.Ok(answer);
});
// Store a new ICE candidate for the specified channel call.
app.MapPost("/api/rtc/candidate", async (RtcIceCandidate request, RtcCallService rtcCallService) =>
{
await rtcCallService.WriteIceCandidateAsync(
request.ChannelId,
request.Username,
request.Candidate.candidate,
request.Candidate.sdpMid,
request.Candidate.sdpMLineIndex
);
RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage
{
Type = SignalType.CandidateAdded,
ChannelId = request.ChannelId,
Username = request.Username,
Direction = JsonSerializer.Serialize(request.Candidate)
});
return Results.Ok();
});
// Return all ICE candidates stored for the specified channel.
app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.GetIceCandidatesAsync(channelId));
});
// Return ICE candidates for the specified channel that belong to other users
// and match the requested direction.
app.MapGet("/api/rtc/candidates/{channelId}/{username}/{direction}", async (
string channelId,
string username,
string direction,
RtcCallService rtcCallService) =>
{
return Results.Ok(await rtcCallService.GetIceCandidatesForOthersAsync(channelId, username, direction));
});
// Leave the active call for the specified channel.
app.MapPost("/api/rtc/leave", async (RtcLeaveRequest request, RtcCallService rtcCallService) =>
{
await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username);
RtcNotificationService.BroadcastToChannel(new RtcNotificationMessage
{
Type = SignalType.CallLeft,
ChannelId = request.ChannelId,
Username = request.Username
});
return Results.Ok();
});
}
}

View File

@@ -0,0 +1,25 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `channel_message_edits` table. One row per historical version of
/// an edited message — written by HandleEditMessage BEFORE overwriting the live row.
///
/// Encrypted with the channel AES key (same as ChannelMessages), so HandleGetEditHistory
/// can decrypt + re-encrypt per requester.
/// </summary>
public class ChannelMessageEdits : Record
{
/// <summary>"channel_messages:abc" — which live message this version belonged to.</summary>
public required string MessageId { get; set; }
/// <summary>Base64 AES-GCM ciphertext of the JSON-serialised previous ChatMessageContent.</summary>
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
/// <summary>When this version was the current text (i.e. when it was replaced).</summary>
public required DateTime EditedAt { get; set; }
}

View File

@@ -0,0 +1,37 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `channel_messages` table. One row per message.
///
/// Encryption: CipherText/Nonce/Tag use the channel AES key (ChannelDbKey), NOT any user's
/// RSA keypair. This means the server can decrypt for history queries; the per-recipient
/// RSA wrapping happens at delivery time in DeliverToServerMembers.
/// </summary>
public class ChannelMessages : Record
{
/// <summary>"channels:xyz" — which channel this belongs to.</summary>
public required string ChannelId { get; set; }
/// <summary>"users:keeper317" — who wrote it. Lowercased to match CoreClientService's id format.</summary>
public required string SenderUserId { get; set; }
/// <summary>Base64 AES-GCM ciphertext of the JSON-serialised ChatMessageContent.</summary>
public required string CipherText { get; set; }
/// <summary>Base64 AES-GCM 96-bit nonce. Different every message.</summary>
public required string Nonce { get; set; }
/// <summary>Base64 AES-GCM 128-bit authentication tag.</summary>
public required string Tag { get; set; }
/// <summary>UTC timestamp of original send. Drives history ordering.</summary>
public required DateTime CreatedAt { get; set; }
/// <summary>UTC timestamp of last edit. Null = never edited. Drives the (edited) bubble footer.</summary>
public DateTime? EditedAt { get; set; }
/// <summary>Soft-delete flag. Tombstones in history responses; bubbles show "deleted" placeholder.</summary>
public bool IsDeleted { get; set; }
}

View File

@@ -0,0 +1,40 @@
using SurrealDb.Net.Models;
using RelayShared.Services;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `channels` table. One row per channel.
///
/// Lifecycle: created by HandleCreateChannel (or seeded by ServerBootstrapService at boot).
/// Soft-deleted by HandleDeleteChannel (IsDeleted flipped, row stays for audit).
/// </summary>
public class Channels : Record
{
/// <summary>Sidebar display name. Lowercased and dash-separated for new channels.</summary>
public required string Name { get; set; }
/// <summary>Creation timestamp. Drives sidebar sort order.</summary>
public required DateTime CreatedAt { get; set; }
/// <summary>Drives client rendering and server routing — Text/Voice/File/Forum/Stage.</summary>
public ChannelType Type { get; set; } = ChannelType.Text;
/// <summary>Sidebar category header (e.g. "General"). Empty means default group.</summary>
public string Group { get; set; } = string.Empty;
/// <summary>
/// True for announcement-style channels (#welcome, #files). Non-admins are blocked from
/// posting via PermissionService.CanSendMessagesAsync.
/// </summary>
public bool IsReadOnly { get; set; }
/// <summary>Soft-delete flag. Filtered out of channel-list builds in BuildChannelListForUser.</summary>
public bool IsDeleted { get; set; }
/// <summary>
/// Surreal record id of a File channel ("channels:xyz"). When set, ChatSocketBehavior's
/// MirrorAttachmentIfNeeded auto-copies non-gif attachments into the linked channel.
/// </summary>
public string? LinkedFileChannelId { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace RelayServer.Models;
public class SocketChannelInfo
{
public required string ChannelId { get; set; }
public required string Name { get; set; }
public required DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,26 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `client_public_keys` table. Stores the RSA public key each user
/// has registered. Written by HandleRegisterKey, read by DeliverToServerMembers and history
/// fetches to encrypt outbound messages per recipient.
///
/// When a client reinstalls and regenerates a keypair, the existing row is updated rather
/// than duplicated (ClientKeyService.RegisterOrUpdateKeyAsync).
/// </summary>
public class ClientPublicKeys : Record
{
/// <summary>Mixed-case username as the user registered it. Used as the lookup key.</summary>
public required string Username { get; set; }
/// <summary>Base64 SubjectPublicKeyInfo (DER) of the user's RSA public key.</summary>
public required string PublicKey { get; set; }
/// <summary>When the user first registered.</summary>
public required DateTime CreatedAt { get; set; }
/// <summary>When the key was last updated (key rotation, reinstall).</summary>
public required DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,29 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `server_encryption_keys` table. Stores both:
/// - The server's RSA keypair (for receiving encrypted client→server payloads).
/// - The single AES-256 key used to encrypt channel_messages at rest.
///
/// Generated once on first boot by ServerBootstrapService. Loaded into static fields on
/// ChatSocketBehavior at boot so handlers can use them without a DB round-trip.
/// </summary>
public class ServerEncryptionKeys : Record
{
/// <summary>Base64 AES-256 key used by ChannelCryptoService for at-rest message encryption.</summary>
public required string KeyBase64 { get; set; }
/// <summary>Base64 SubjectPublicKeyInfo of the server's RSA public key. Sent to clients on GetServerKey.</summary>
public required string PublicKey { get; set; }
/// <summary>Base64 PKCS8 of the server's RSA private key. Never leaves the server.</summary>
public required string PrivateKey { get; set; }
/// <summary>When the keys were generated.</summary>
public required DateTime CreatedAt { get; set; }
/// <summary>When the keys were last rotated. Currently same as CreatedAt — rotation isn't implemented.</summary>
public required DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,25 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `channel_permissions` table. Per-(channel, role) override of a
/// role's base permissions.
///
/// Allow and Deny are independent masks (NOT a tri-state). Deny wins over Allow when both
/// have the same flag set. Bits not set in either fall through to the role's base permissions.
/// </summary>
public class ChannelPermissions : Record
{
/// <summary>"channels:xyz" — which channel this override applies in.</summary>
public required string ChannelId { get; set; }
/// <summary>"roles:abc" — which role this override applies to.</summary>
public required string RoleId { get; set; }
/// <summary>Permissions explicitly granted here (overrides "role doesn't have it" for this channel).</summary>
public PermissionFlags Allow { get; set; }
/// <summary>Permissions explicitly denied here. Wins over Allow.</summary>
public PermissionFlags Deny { get; set; }
}

View File

@@ -0,0 +1,50 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// The permission bitfield. The whole permission model is just:
///
/// ServerMembers.IsOwner = true → unconditional Administrator
/// roles.Permissions has Administrator flag → unconditional everything
/// channel_permissions.Deny has a specific flag → that permission denied here
/// channel_permissions.Allow has a specific flag → that permission allowed here
/// roles.Permissions has the flag → fallback (channel-independent)
///
/// PermissionService.HasPermissionAsync walks that ladder in order. See that class for the
/// authoritative implementation.
/// </summary>
[Flags]
public enum PermissionFlags
{
None = 0,
ReadMessages = 1 << 0,
SendMessages = 1 << 1,
ManageMessages = 1 << 2, // Edit / delete others' messages
ManageChannels = 1 << 3, // Create channels (umbrella manage permission)
ManageMembers = 1 << 4, // Kick / ban members
Administrator = 1 << 5, // All permissions, bypasses channel overrides
ViewChannel = 1 << 6, // "Visibility" — can see the channel at all
Speak = 1 << 7, // Can transmit in a voice channel
EditChannel = 1 << 8, // Rename / reconfigure a channel
DeleteChannel = 1 << 9 // Delete a channel
}
/// <summary>
/// Surreal record for the `roles` table. Defines a named permission bundle that can be
/// assigned to users via UserRoles.
/// </summary>
public class Roles : Record
{
/// <summary>Display name ("Admin", "Moderator", "Member").</summary>
public required string Name { get; set; }
/// <summary>Base permission bitfield. Channel-level overrides in ChannelPermissions can add or remove.</summary>
public required PermissionFlags Permissions { get; set; }
/// <summary>When the role was seeded.</summary>
public required DateTime CreatedAt { get; set; }
/// <summary>Tie-breaker for future multi-role-per-user scenarios. Lower = higher priority. Not used by the current ladder.</summary>
public int Priority { get; set; }
}

View File

@@ -0,0 +1,23 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `server_members` table. Membership list.
/// Drives DeliverToServerMembers (the fan-out target list for every chat message) and the
/// authoritative ownership flag for PermissionService.
/// </summary>
public class ServerMembers : Record
{
/// <summary>"users:keeper317" — references the Core users table by name convention.</summary>
public required string UserId { get; set; }
/// <summary>When the user was added to this server.</summary>
public required DateTime JoinedAt { get; set; }
/// <summary>
/// Authoritative owner flag. Owner gets unconditional Administrator via
/// PermissionService.IsServerOwnerAsync, independent of role assignments.
/// </summary>
public bool IsOwner { get; set; }
}

View File

@@ -0,0 +1,19 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `servers` table. Currently single-row (one server per deployment),
/// but the schema supports multi-server in the future.
/// </summary>
public class Servers : Record
{
/// <summary>Display name (currently "Test Server" from bootstrap).</summary>
public required string Name { get; set; }
/// <summary>"users:keeper317" — the owner. Mirrored as IsOwner=true on the matching ServerMembers row.</summary>
public required string OwnerUserId { get; set; }
/// <summary>Server creation timestamp.</summary>
public required DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,22 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
/// <summary>
/// Surreal record for the `user_roles` table. Join table linking users to roles.
///
/// Invariant: ServerBootstrapService.SetUserRoleAsync guarantees exactly one row per user.
/// Multi-role-per-user isn't currently supported by the permission ladder — adding it would
/// just be a matter of removing the bootstrap's "delete stale rows" step.
/// </summary>
public class UserRoles : Record
{
/// <summary>"users:keeper317" — the assignee.</summary>
public required string UserId { get; set; }
/// <summary>"roles:abc" — the role being granted.</summary>
public required string RoleId { get; set; }
/// <summary>When the assignment was made.</summary>
public required DateTime AssignedAt { get; set; }
}

View File

@@ -1 +1,72 @@
Console.WriteLine("Hello, World!"); // =============================================================================
// RelayServer entrypoint.
//
// Boot sequence:
// 1. Connect to SurrealDB (port 8000) via SurrealService.
// 2. Wire static singletons onto ChatSocketBehavior (it's a WebSocketSharp
// WebSocketBehavior, so DI is impossible — fields are static).
// 3. Run ServerBootstrapService.InitializeAsync — seeds users, server, members,
// channels (welcome, general, files, voice-general), roles, role assignments,
// channel permission overrides, and encryption keys. Idempotent across reboots.
// 4. Start two listeners in parallel:
// - HTTP API on 127.0.0.1:5000 (RtcEndpoints — REST for RTC call orchestration)
// - WebSocket server on 127.0.0.1:5001 (ChatSocketBehavior — the chat/RTC-signal pipe)
// 5. Block on ConsoleCommandService.ShutdownTokenSource for graceful shutdown.
//
// Why two listeners? The HTTP API is used for one-shot RPC-style calls (e.g. "fetch
// the participant list for this voice channel"). The WebSocket is the persistent
// duplex pipe used for chat, typing, presence, encrypted RTC signalling.
// =============================================================================
using RelayServer.Endpoints;
using RelayServer.Services.Chat;
using RelayServer.Services.Core;
using RelayServer.Services.Data;
using RelayServer.Services.Rtc;
using RelayShared.Rtc;
using RelayShared.Services;
using WebSocketSharp.Server;
var surrealService = new SurrealService();
var coreClient = new CoreClientService();
var cryptoService = new ChannelCryptoService();
await using var db = await surrealService.ConnectAsync();
ChatSocketBehavior.ClientKeyService = new ClientKeyService(db);
ChatSocketBehavior.PermissionService = new PermissionService(db);
ChatSocketBehavior.Db = db;
ChatSocketBehavior.ChannelCryptoService = cryptoService;
var bootstrapService = new ServerBootstrapService(db, coreClient, cryptoService);
await bootstrapService.InitializeAsync();
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:5000/");
// builder.WebHost.UseUrls("http://192.168.1.92:5000/");
builder.Services.AddSingleton(db);
builder.Services.AddScoped<RtcCallService>();
var app = builder.Build();
app.MapGet("/", () => "Server Running!");
app.MapRtcEndpoints();
var wssv = new WebSocketServer("ws://127.0.0.1:5001");
// var wssv = new WebSocketServer("ws://192.168.1.92:5001");
wssv.AddWebSocketService<ChatSocketBehavior>("/");
RtcNotificationService.Server = wssv;
wssv.Start();
Console.WriteLine("WebSocket server started");
await app.StartAsync();
Console.WriteLine("HTTP API started");
ConsoleCommandService.Start();
await Task.Delay(Timeout.Infinite, ConsoleCommandService.ShutdownTokenSource.Token);
wssv.Stop();
await app.StopAsync();
return;

View File

@@ -1,10 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
</Project> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.9" />
<PackageReference Include="SurrealDb.Net" Version="0.9.0" />
<PackageReference Include="WebSocketSharp" Version="1.0.3-rc11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RelayShared\RelayShared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,63 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayServer.Services.Chat;
/// <summary>
/// AES-GCM-256 only (no RSA). Used exclusively for "at-rest" encryption of channel messages
/// in the SurrealDB channel_messages table.
///
/// Why a separate service from E2EeHelper:
/// - E2EeHelper is for *transit* between a specific sender and a specific recipient — it
/// wraps an ephemeral AES key with the recipient's RSA public key.
/// - ChannelCryptoService is for *storage* — the server is both the encryptor and the
/// decryptor, and it stores the symmetric channel key in server_encryption_keys.KeyBase64.
/// There's no recipient to wrap for.
///
/// Server flow for a chat message:
/// incoming SocketEncryptedMessage (encrypted with server's RSA public key, by client)
/// → E2EeHelper.DecryptForRecipient(serverPrivateKey) → plaintext
/// → ChannelCryptoService.Encrypt(channelDbKey) → stored ciphertext
/// → … later, on history fetch …
/// → ChannelCryptoService.Decrypt(channelDbKey) → plaintext
/// → E2EeHelper.EncryptForRecipient(clientPublicKey) → delivered ciphertext
/// </summary>
public sealed class ChannelCryptoService
{
public string GenerateKey()
{
return Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
}
public (string cipherText, string nonce, string tag) Encrypt(string plainText, string keyBase64)
{
var key = Convert.FromBase64String(keyBase64);
var nonce = RandomNumberGenerator.GetBytes(12);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, 16);
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
return (
Convert.ToBase64String(cipherBytes),
Convert.ToBase64String(nonce),
Convert.ToBase64String(tag)
);
}
public string Decrypt(string cipherTextBase64, string nonceBase64, string tagBase64, string keyBase64)
{
var key = Convert.FromBase64String(keyBase64);
var nonce = Convert.FromBase64String(nonceBase64);
var tag = Convert.FromBase64String(tagBase64);
var cipherBytes = Convert.FromBase64String(cipherTextBase64);
var plainBytes = new byte[cipherBytes.Length];
using var aes = new AesGcm(key, 16);
aes.Decrypt(nonce, cipherBytes, tag, plainBytes);
return Encoding.UTF8.GetString(plainBytes);
}
}

View File

@@ -0,0 +1,915 @@
using System.Net.Http.Headers;
using System.Text.Json;
using RelayServer.Models;
using RelayServer.Services.Crypto;
using RelayServer.Services.Data;
using RelayServer.Services.Rtc;
using WebSocketSharp;
using WebSocketSharp.Server;
using ErrorEventArgs = WebSocketSharp.ErrorEventArgs;
using RelayShared.Services;
namespace RelayServer.Services.Chat;
/// <summary>
/// The server-side WebSocket endpoint. Every client connection creates one instance of this
/// class. WebSocketSharp owns the lifecycle: it constructs the behavior, calls OnMessage for
/// each incoming frame, and calls OnClose when the connection drops.
///
/// MESSAGE FLOW (data plane — chat message):
/// 1. Client sends a SocketEncryptedMessage with SignalType.ClientEncryptedChat.
/// Payload is JSON-serialised ChatMessageContent, encrypted with the server's public key.
/// 2. OnMessage parses the JSON, identifies Type, routes to HandleEncryptedChatMessage.
/// 3. Permission check via PermissionService.CanSendMessagesAsync.
/// 4. Decrypt with ServerPrivateKey → get plaintext JSON.
/// 5. Re-encrypt with ChannelDbKey (AES-GCM only, no RSA) → store in channel_messages table.
/// 6. For each connected server member: re-encrypt with their client public key, deliver
/// via Sessions.SendTo to every one of their active sessions (multi-device).
/// 7. If the origin channel has LinkedFileChannelId set, MirrorAttachmentIfNeeded also
/// stores+delivers a trimmed copy into the linked File channel.
///
/// MESSAGE FLOW (control plane — e.g. CreateChannel):
/// 1. Client sends a WsControlMessage with Action=CreateChannel.
/// 2. OnMessage sees the "Action" JSON property, routes via DispatchControl.
/// 3. Permission check, DB write, then BroadcastChannelList rebuilds the channel list per
/// user (because CanPost/CanManage are computed per-user) and pushes it to everyone.
///
/// STATE STORES used here:
/// - ConnectedClientService: session ↔ username mapping (in-memory, multi-device aware).
/// Populated by HandleRegisterKey, cleared by OnClose.
/// - RtcChannelPresenceService: session ↔ voice channel mapping. Populated by RtcJoin,
/// cleared by RtcLeave / OnClose.
/// - SurrealDB tables: channel_messages, channels, server_members, roles, user_roles,
/// channel_permissions, client_public_keys, server_encryption_keys, channel_message_edits.
///
/// CRITICAL invariant: this class is constructed by WebSocketSharp and has no constructor
/// hook for DI, so ALL services are static (set once by Program.cs at boot).
/// </summary>
public class ChatSocketBehavior : WebSocketBehavior
{
/// <summary>Reads/writes the client_public_keys table. Wired by Program.cs at boot.</summary>
public static ClientKeyService? ClientKeyService { get; set; }
/// <summary>The permission ladder evaluator. Wired by Program.cs at boot.</summary>
public static PermissionService? PermissionService { get; set; }
/// <summary>Base64 RSA public key — clients use this to encrypt outbound payloads to the server.</summary>
public static string? ServerPublicKey { get; set; }
/// <summary>Base64 RSA private key — used to decrypt inbound payloads. Never leaves the server.</summary>
public static string? ServerPrivateKey { get; set; }
/// <summary>Base64 AES-256 key for at-rest encryption of channel_messages.CipherText rows.</summary>
public static string? ChannelDbKey { get; set; }
/// <summary>AES-GCM-only encryption for stored messages. Wired by Program.cs at boot.</summary>
public static ChannelCryptoService? ChannelCryptoService { get; set; }
/// <summary>The SurrealDB connection. Wired by Program.cs at boot.</summary>
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
/// <summary>
/// WebSocketSharp callback fired for every incoming text frame. Peeks the JSON to identify
/// "Action" (control-plane) vs "Type" (data-plane), then routes to the right handler.
/// All exceptions are caught and logged — they MUST NOT propagate or WebSocketSharp will
/// drop the connection.
/// </summary>
protected override void OnMessage(MessageEventArgs e)
{
var msg = e.Data;
try
{
using var doc = JsonDocument.Parse(msg);
var root = doc.RootElement;
if (root.TryGetProperty("Action", out var actionProp))
{
var action = (WsAction)actionProp.GetInt32();
var control = JsonSerializer.Deserialize<WsControlMessage>(msg)!;
DispatchControl(action, control);
return;
}
if (root.TryGetProperty("Type", out var typeProp))
{
var type = (SignalType)typeProp.GetInt32();
switch (type)
{
case SignalType.EncryptedSignal: HandleEncryptedRtcSignal(msg); return;
case SignalType.ClientEncryptedChat: HandleEncryptedChatMessage(msg); return;
case SignalType.ClientEditMessage: HandleEditMessage(msg); return;
case SignalType.ClientDeleteMessage: HandleDeleteMessage(msg); return;
}
}
Console.WriteLine($"Unrecognised WS message session={ID}: {msg[..Math.Min(120, msg.Length)]}");
}
catch (Exception ex)
{
Console.WriteLine($"WS message error session={ID}: {ex.Message}");
}
}
/// <summary>Switches on WsAction to the matching Handle* method. Pure routing — no I/O.</summary>
private void DispatchControl(WsAction action, WsControlMessage c)
{
switch (action)
{
case WsAction.Authenticate: HandleAuthenticate(c); break;
case WsAction.RegisterKey: HandleRegisterKey(c); break;
case WsAction.GetServerKey: HandleGetServerKey(); break;
case WsAction.GetChannels: HandleGetChannels(); break;
case WsAction.GetHistory: HandleGetHistory(c); break;
case WsAction.RtcJoin: HandleRtcJoinChannel(c); break;
case WsAction.RtcLeave: HandleRtcLeaveChannel(c); break;
case WsAction.SendTyping: HandleTyping(c); break;
case WsAction.GetEditHistory: HandleGetEditHistory(c); break;
case WsAction.CreateChannel: HandleCreateChannel(c); break;
case WsAction.DeleteChannel: HandleDeleteChannel(c); break;
default: Console.WriteLine($"Unknown WsAction {action} session={ID}"); break;
}
}
/// <summary>
/// Verifies a Core-issued user token against the Core service. The HTTP call is wrapped in
/// try/catch so that a Core outage doesn't drop the chat session — we still ack with
/// WsEvent.Authenticated so the rest of the boot handshake can proceed.
///
/// NOTE async void here is unavoidable (it's an event handler) but every exception path
/// must be caught locally or WebSocketSharp will tear down the session.
/// </summary>
private async void HandleAuthenticate(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.Token))
{
Console.WriteLine("Invalid Authenticate payload.");
return;
}
try
{
using var core = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:1337") };
core.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
core.DefaultRequestHeaders.Add("User-Agent", "RelayServer");
var resp = await core.PostAsJsonAsync("/server/verify/user",
new AuthUserVerify { Username = c.Username, Token = c.Token });
Console.WriteLine($"Auth [{c.Username}]: {await resp.Content.ReadAsStringAsync()}");
}
catch (Exception ex)
{
Console.WriteLine($"Auth failed for {c.Username}: {ex.Message}");
}
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Authenticated, Detail = c.Username }));
}
/// <summary>
/// Stores or updates the client's RSA public key in client_public_keys, then registers the
/// (sessionId, username) mapping in ConnectedClientService. After this fires the server can
/// route encrypted chat messages to this user's connected devices.
/// </summary>
private void HandleRegisterKey(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.PublicKey))
{
Console.WriteLine("Invalid RegisterKey payload.");
return;
}
if (ClientKeyService is null) { Console.WriteLine("ClientKeyService null."); return; }
RegisterOrUpdateClientKeySync(c.Username, c.PublicKey);
ConnectedClientService.Register(ID, c.Username);
Console.WriteLine($"Key registered: {c.Username} (session={ID})");
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = c.Username }));
}
/// <summary>Sends the server's public RSA key. Called once per session right after RegisterKey.</summary>
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; }
Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey }));
}
/// <summary>
/// Sends a channel list with CanPost/CanManage/visibility resolved for this specific user.
/// The username is looked up by session ID so the client never has to spoof it.
/// </summary>
private void HandleGetChannels()
{
if (Db is null) { Console.WriteLine("Db null."); return; }
// Resolve the requesting user so we can compute per-user CanPost for each channel.
var username = ConnectedClientService.GetUsernameForSession(ID) ?? string.Empty;
var channels = BuildChannelListForUser(username);
Send(JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels }));
}
/// <summary>
/// Streams the channel's full message history back to the requester. Each message is:
/// 1. Decrypted from the channel DB key (ChannelCryptoService.Decrypt).
/// 2. Re-encrypted with the requester's public key (E2EeHelper.EncryptForRecipient).
/// 3. Sent as an individual SocketEncryptedMessage frame.
/// Deleted messages are sent as tombstones (IsDeleted=true, no ciphertext) so the client
/// can render a placeholder without trying to decrypt.
/// </summary>
private void HandleGetHistory(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
{
Console.WriteLine("Invalid GetHistory payload.");
return;
}
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return;
var targetClient = GetClientPublicKeyByUsernameSync(c.Username);
if (targetClient is null) { Console.WriteLine($"No public key for history user {c.Username}"); return; }
var messages = GetChannelMessagesSync()
.Where(m => m.ChannelId == c.ChannelId)
.OrderBy(m => m.CreatedAt)
.ToList();
Console.WriteLine($"Sending {messages.Count} history messages to {c.Username}");
foreach (var dbMsg in messages)
{
var msgId = GetRecordId(dbMsg.Id);
if (dbMsg.IsDeleted)
{
Send(JsonSerializer.Serialize(new SocketEncryptedMessage
{
Type = SignalType.EncryptedChat, MessageId = msgId,
SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId),
RecipientUsername = c.Username, ChannelId = c.ChannelId, IsDeleted = true
}));
continue;
}
string plainText;
try { plainText = ChannelCryptoService.Decrypt(dbMsg.CipherText, dbMsg.Nonce, dbMsg.Tag, ChannelDbKey); }
catch (Exception ex) { Console.WriteLine($"History decrypt failed {dbMsg.Id}: {ex.Message}"); continue; }
var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
Send(JsonSerializer.Serialize(new SocketEncryptedMessage
{
Type = SignalType.EncryptedChat, MessageId = msgId,
SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId),
RecipientUsername = c.Username, ChannelId = c.ChannelId,
CipherText = encrypted.CipherText, Nonce = encrypted.Nonce,
Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey,
IsEdited = dbMsg.EditedAt.HasValue
}));
}
}
/// <summary>
/// Marks the session as present in a voice channel. Gated by CanSpeakAsync — if the user's
/// role is denied Speak here we reject with WsEvent.Error and refuse to register presence.
/// </summary>
private void HandleRtcJoinChannel(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
{
Console.WriteLine("Invalid RtcJoin payload.");
return;
}
if (PermissionService is not null &&
!PermissionService.CanSpeakAsync(c.Username, c.ChannelId).GetAwaiter().GetResult())
{
Console.WriteLine($"RTC join denied (no Speak): user={c.Username}, channel={c.ChannelId}");
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You don't have permission to speak in this channel." }));
return;
}
RtcChannelPresenceService.SetUser(ID, c.Username);
RtcChannelPresenceService.JoinChannel(ID, c.ChannelId);
Console.WriteLine($"RTC join: session={ID}, user={c.Username}, channel={c.ChannelId}");
}
/// <summary>Clears the session's voice-channel presence. Idempotent — safe to call when not in a channel.</summary>
private void HandleRtcLeaveChannel(WsControlMessage c)
{
if (!string.IsNullOrWhiteSpace(c.ChannelId) && RtcChannelPresenceService.IsInChannel(ID, c.ChannelId))
RtcChannelPresenceService.LeaveChannel(ID);
Console.WriteLine($"RTC leave: session={ID}, user={c.Username}");
}
/// <summary>
/// Broadcasts "{Username} is typing…" to every connected server member EXCEPT the sender.
/// Sender's username comes from ConnectedClientService (not the message payload) so a
/// malicious client can't impersonate someone else's typing.
/// </summary>
private void HandleTyping(WsControlMessage c)
{
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
if (string.IsNullOrWhiteSpace(senderUsername) || string.IsNullOrWhiteSpace(c.ChannelId)) return;
var json = JsonSerializer.Serialize(new SocketTypingEvent
{
Type = SignalType.TypingIndicator,
Username = senderUsername,
ChannelId = c.ChannelId
});
foreach (var member in GetServerMembersSync())
{
var rawUsername = ExtractUsernameFromUserId(member.UserId);
if (string.Equals(rawUsername, senderUsername, StringComparison.OrdinalIgnoreCase)) continue;
foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername))
Sessions.SendTo(json, sid);
}
}
/// <summary>
/// Streams every prior version of a message back to the requester. Each entry is decrypted
/// from the channel key then re-encrypted for the requester's public key. Drives the
/// "(edited)" tap-popup on the client.
/// </summary>
private void HandleGetEditHistory(WsControlMessage c)
{
if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return;
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return;
var targetClient = GetClientPublicKeyByUsernameSync(c.Username);
if (targetClient is null) return;
var edits = GetChannelMessageEditsSync(c.MessageId)
.OrderBy(e => e.EditedAt)
.ToList();
var entries = new List<SocketEditHistoryEntry>();
foreach (var edit in edits)
{
string plainText;
try { plainText = ChannelCryptoService.Decrypt(edit.CipherText, edit.Nonce, edit.Tag, ChannelDbKey); }
catch (Exception ex) { Console.WriteLine($"Edit history decrypt failed: {ex.Message}"); continue; }
var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
entries.Add(new SocketEditHistoryEntry
{
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey,
EditedAt = edit.EditedAt
});
}
Send(JsonSerializer.Serialize(new SocketEditHistoryResponse
{
Type = SignalType.EditHistory,
MessageId = c.MessageId,
Entries = entries
}));
}
/// <summary>
/// Permission-gated channel creation. On success, broadcasts the new channel list to every
/// connected member (computed per-user since CanPost/CanManage depend on the recipient).
/// </summary>
private void HandleCreateChannel(WsControlMessage c)
{
var username = ConnectedClientService.GetUsernameForSession(ID);
if (string.IsNullOrWhiteSpace(username)) return;
if (PermissionService is null || !PermissionService.CanManageChannelsAsync(username).GetAwaiter().GetResult())
{
Console.WriteLine($"CreateChannel denied for {username}: insufficient permissions.");
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." }));
return;
}
if (string.IsNullOrWhiteSpace(c.ChannelName))
{
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Channel name is required." }));
return;
}
var type = (ChannelType)c.ChannelType;
Task.Run(async () => await Db!.Create("channels", new Channels
{
Name = c.ChannelName,
Type = type,
Group = c.ChannelGroup ?? string.Empty,
CreatedAt = DateTime.UtcNow
})).GetAwaiter().GetResult();
Console.WriteLine($"Channel created: {c.ChannelName} ({type}) by {username}");
BroadcastChannelList();
}
/// <summary>
/// Permission-gated soft-delete (sets IsDeleted on the row, doesn't actually remove it).
/// Broadcasts a fresh channel list after — clients drop the channel from their sidebar.
/// </summary>
private void HandleDeleteChannel(WsControlMessage c)
{
var username = ConnectedClientService.GetUsernameForSession(ID);
if (string.IsNullOrWhiteSpace(username)) return;
if (PermissionService is null || !PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult())
{
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." }));
return;
}
if (string.IsNullOrWhiteSpace(c.ChannelId)) return;
var all = GetChannelsSync();
var target = all.FirstOrDefault(ch => GetRecordId(ch.Id) == c.ChannelId);
if (target is null) return;
target.IsDeleted = true;
Task.Run(async () => await Db!.Merge<Channels, Channels>(target))
.GetAwaiter().GetResult();
Console.WriteLine($"Channel deleted: {target.Name} by {username}");
BroadcastChannelList();
}
/// <summary>
/// Relays an encrypted WebRTC SDP/ICE signal to every other session in the same voice
/// channel. Decrypts with the server's private key, re-encrypts per-recipient. The server
/// never stores RTC signals — pure forwarding.
/// </summary>
private void HandleEncryptedRtcSignal(string msg)
{
SocketRtcSignalMessage? payload;
try { payload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg); }
catch { Console.WriteLine("Failed to parse RTC signal."); return; }
if (payload is null || string.IsNullOrWhiteSpace(payload.ChannelId)) return;
string plainText;
try
{
plainText = E2EeHelper.DecryptForRecipient(
new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey },
ServerPrivateKey);
}
catch (Exception ex) { Console.WriteLine($"RTC decrypt failed: {ex.Message}"); return; }
foreach (var sid in RtcChannelPresenceService.GetSessionsInChannel(payload.ChannelId))
{
if (sid == ID) continue;
var uname = RtcChannelPresenceService.GetUsernameForSession(sid);
if (string.IsNullOrWhiteSpace(uname)) continue;
var key = GetClientPublicKeyByUsernameSync(uname);
if (key is null) continue;
var enc = E2EeHelper.EncryptForRecipient(plainText, key.PublicKey);
Sessions.SendTo(JsonSerializer.Serialize(new SocketRtcSignalMessage
{
Type = SignalType.EncryptedSignal, SenderUsername = payload.SenderUsername,
ChannelId = payload.ChannelId, CipherText = enc.CipherText,
Nonce = enc.Nonce, Tag = enc.Tag, EncryptedKey = enc.EncryptedKey
}), sid);
}
}
/// <summary>
/// The main chat-message path. Permission gate → server-side decrypt → store with channel
/// key → DeliverToServerMembers (per-user re-encrypt + send) → MirrorAttachmentIfNeeded.
/// </summary>
private void HandleEncryptedChatMessage(string msg)
{
SocketEncryptedMessage? payload;
try { payload = JsonSerializer.Deserialize<SocketEncryptedMessage>(msg); }
catch { Console.WriteLine("Failed to parse chat payload."); return; }
if (payload is null || payload.Type != SignalType.ClientEncryptedChat) return;
if (!EnsureCoreReady() || !EnsureCryptoReady()) return;
// Permission check.
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
if (string.IsNullOrWhiteSpace(senderUsername)) return;
if (PermissionService is not null &&
!PermissionService.CanSendMessagesAsync(senderUsername, payload.ChannelId).GetAwaiter().GetResult())
{
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You cannot send messages in this channel." }));
return;
}
string plainText;
try
{
plainText = E2EeHelper.DecryptForRecipient(
new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey },
ServerPrivateKey);
}
catch (Exception ex) { Console.WriteLine($"Chat decrypt failed: {ex.Message}"); return; }
Console.WriteLine($"Decrypted chat from {payload.SenderUsername}");
string messageId;
try
{
var dbEnc = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey);
var saved = CreateChannelMessageSync(new ChannelMessages
{
ChannelId = payload.ChannelId,
SenderUserId = $"users:{payload.SenderUsername.ToLower()}",
CipherText = dbEnc.cipherText,
Nonce = dbEnc.nonce,
Tag = dbEnc.tag,
CreatedAt = DateTime.UtcNow
});
messageId = GetRecordId(saved.Id);
Console.WriteLine($"Message saved: {messageId}");
}
catch (Exception ex) { Console.WriteLine($"Save failed: {ex.Message}"); return; }
DeliverToServerMembers(plainText, payload.SenderUsername, payload.ChannelId,
messageId, SignalType.EncryptedChat, isEdited: false);
MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId);
}
/// <summary>
/// If the origin channel has LinkedFileChannelId set and the message has a non-gif
/// attachment, stores+delivers a trimmed copy ("📎 Shared from #X by Y" + attachment)
/// into the linked File channel. No-op for plain text messages.
/// </summary>
private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId)
{
ChatMessageContent? content;
try { content = JsonSerializer.Deserialize<ChatMessageContent>(plainText); }
catch { return; }
if (content is null || string.IsNullOrWhiteSpace(content.AttachmentBase64))
return;
// The user wants images, zips, docs — but not gifs (and links/text aren't attachments anyway).
var mime = content.AttachmentMimeType ?? string.Empty;
if (mime.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
return;
var origin = GetChannelsSync().FirstOrDefault(ch => GetRecordId(ch.Id) == originChannelId);
if (origin?.LinkedFileChannelId is null) return;
var fileChannelId = origin.LinkedFileChannelId;
if (originChannelId == fileChannelId) return;
var mirror = new ChatMessageContent
{
Text = $"📎 Shared from #{origin.Name} by {senderUsername}",
AttachmentBase64 = content.AttachmentBase64,
AttachmentMimeType = content.AttachmentMimeType,
AttachmentFileName = content.AttachmentFileName
};
var mirrorPlain = JsonSerializer.Serialize(mirror);
string mirrorId;
try
{
var dbEnc = ChannelCryptoService!.Encrypt(mirrorPlain, ChannelDbKey);
var saved = CreateChannelMessageSync(new ChannelMessages
{
ChannelId = fileChannelId,
SenderUserId = $"users:{senderUsername.ToLower()}",
CipherText = dbEnc.cipherText,
Nonce = dbEnc.nonce,
Tag = dbEnc.tag,
CreatedAt = DateTime.UtcNow
});
mirrorId = GetRecordId(saved.Id);
}
catch (Exception ex) { Console.WriteLine($"File mirror save failed: {ex.Message}"); return; }
DeliverToServerMembers(mirrorPlain, senderUsername, fileChannelId,
mirrorId, SignalType.EncryptedChat, isEdited: false);
Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}");
}
/// <summary>
/// Ownership-gated edit. Saves the OLD ciphertext as a ChannelMessageEdits row before
/// overwriting the current row, so the edit chain is preserved. Broadcasts MessageEdited
/// with the new ciphertext so every recipient updates their bubble in place.
/// </summary>
private void HandleEditMessage(string msg)
{
SocketEncryptedMessage? request;
try { request = JsonSerializer.Deserialize<SocketEncryptedMessage>(msg); }
catch { Console.WriteLine("Failed to parse edit request."); return; }
if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return;
if (!EnsureCoreReady() || !EnsureCryptoReady()) return;
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
if (string.IsNullOrWhiteSpace(senderUsername)) return;
var existing = GetChannelMessageByIdSync(request.MessageId);
if (existing is null) { Console.WriteLine($"Edit: message {request.MessageId} not found."); return; }
if (!string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($"Edit denied: {senderUsername} does not own {request.MessageId}.");
return;
}
string newPlainText;
try
{
newPlainText = E2EeHelper.DecryptForRecipient(
new EncryptedPayload { CipherText = request.CipherText, Nonce = request.Nonce, Tag = request.Tag, EncryptedKey = request.EncryptedKey },
ServerPrivateKey);
}
catch (Exception ex) { Console.WriteLine($"Edit decrypt failed: {ex.Message}"); return; }
try
{
CreateChannelMessageEditSync(new ChannelMessageEdits
{
MessageId = request.MessageId,
CipherText = existing.CipherText,
Nonce = existing.Nonce,
Tag = existing.Tag,
EditedAt = existing.EditedAt ?? existing.CreatedAt
});
}
catch (Exception ex) { Console.WriteLine($"Edit history save failed: {ex.Message}"); }
try
{
var dbEnc = ChannelCryptoService!.Encrypt(newPlainText, ChannelDbKey);
existing.CipherText = dbEnc.cipherText;
existing.Nonce = dbEnc.nonce;
existing.Tag = dbEnc.tag;
existing.EditedAt = DateTime.UtcNow;
UpdateChannelMessageSync(existing);
Console.WriteLine($"Message {request.MessageId} edited by {senderUsername}.");
}
catch (Exception ex) { Console.WriteLine($"Edit DB update failed: {ex.Message}"); return; }
DeliverToServerMembers(newPlainText, senderUsername, request.ChannelId,
request.MessageId, SignalType.MessageEdited, isEdited: true);
}
/// <summary>
/// Soft-delete (sets IsDeleted on the row). Allowed for the message author OR anyone with
/// ManageMessages permission in the channel. Broadcasts a tombstone event to every
/// connected member; their client swaps the bubble to a "deleted" placeholder.
/// </summary>
private void HandleDeleteMessage(string msg)
{
SocketEncryptedMessage? request;
try { request = JsonSerializer.Deserialize<SocketEncryptedMessage>(msg); }
catch { Console.WriteLine("Failed to parse delete request."); return; }
if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return;
if (!EnsureCoreReady()) return;
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
if (string.IsNullOrWhiteSpace(senderUsername)) return;
var existing = GetChannelMessageByIdSync(request.MessageId);
if (existing is null) return;
bool isOwner = string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase);
bool canManage = PermissionService?.CanManageMessagesAsync(senderUsername, request.ChannelId).GetAwaiter().GetResult() ?? false;
if (!isOwner && !canManage)
{
Console.WriteLine($"Delete denied: {senderUsername} does not own {request.MessageId}.");
return;
}
try
{
existing.IsDeleted = true;
UpdateChannelMessageSync(existing);
Console.WriteLine($"Message {request.MessageId} deleted by {senderUsername}.");
}
catch (Exception ex) { Console.WriteLine($"Delete DB update failed: {ex.Message}"); return; }
var deletedEvent = JsonSerializer.Serialize(new SocketMessageDeletedEvent
{
Type = SignalType.MessageDeleted, MessageId = request.MessageId, ChannelId = request.ChannelId
});
foreach (var member in GetServerMembersSync())
{
var rawUsername = ExtractUsernameFromUserId(member.UserId);
foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername))
Sessions.SendTo(deletedEvent, sid);
}
}
/// <summary>
/// The fan-out for any chat-message delivery (new send, edit broadcast). For each
/// server_members row, looks up active sessions, fetches that user's public key, encrypts
/// the plaintext for them, and sends to every one of their sessions (multi-device).
///
/// "ProperUsername" is the mixed-case version captured at RegisterKey time, used so the
/// client's case-insensitive compare picks up the message instead of dropping it silently.
/// </summary>
private void DeliverToServerMembers(
string plainText, string senderUsername, string channelId,
string messageId, SignalType signalType, bool isEdited)
{
foreach (var member in GetServerMembersSync())
{
var rawUsername = ExtractUsernameFromUserId(member.UserId);
var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername);
if (sessionIds.Count == 0) continue;
var properUsername = sessionIds
.Select(ConnectedClientService.GetUsernameForSession)
.FirstOrDefault(u => u is not null) ?? rawUsername;
var clientKey = GetClientPublicKeyByUsernameSync(properUsername);
if (clientKey is null) { Console.WriteLine($"No public key for {properUsername}, skipping."); continue; }
var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey);
var json = JsonSerializer.Serialize(new SocketEncryptedMessage
{
Type = signalType, MessageId = messageId,
SenderUsername = senderUsername, RecipientUsername = properUsername, ChannelId = channelId,
CipherText = encrypted.CipherText, Nonce = encrypted.Nonce,
Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey,
IsEdited = isEdited
});
foreach (var sid in sessionIds)
Sessions.SendTo(json, sid);
}
}
/// <summary>
/// Pushes a freshly-built channel list to every connected member. Has to compute the list
/// PER user because CanPost/CanManage/visibility are user-specific. Called after Create/Delete.
/// </summary>
private void BroadcastChannelList()
{
foreach (var member in GetServerMembersSync())
{
var rawUsername = ExtractUsernameFromUserId(member.UserId);
var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername);
if (sessionIds.Count == 0) continue;
var properUsername = sessionIds
.Select(ConnectedClientService.GetUsernameForSession)
.FirstOrDefault(u => u is not null) ?? rawUsername;
var channels = BuildChannelListForUser(properUsername);
var json = JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels });
foreach (var sid in sessionIds)
Sessions.SendTo(json, sid);
}
}
/// <summary>
/// Resolves the channel list a specific user can see, with CanPost/CanManage flags filled
/// in. Visibility (ViewChannel) determines inclusion — denied channels are filtered out.
/// </summary>
private List<ChannelItem> BuildChannelListForUser(string username)
{
var rawChannels = GetChannelsSync()
.Where(c => !c.IsDeleted)
.OrderBy(c => c.CreatedAt)
.ToList();
var items = new List<ChannelItem>();
foreach (var c in rawChannels)
{
var channelId = GetRecordId(c.Id);
// "Visibility" — drop channels this user is not allowed to see.
if (PermissionService is not null &&
!PermissionService.CanViewChannelAsync(username, channelId).GetAwaiter().GetResult())
continue;
bool canPost = PermissionService is null
|| PermissionService.CanSendMessagesAsync(username, channelId).GetAwaiter().GetResult();
bool canManage = PermissionService is not null &&
(PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult() ||
PermissionService.CanEditChannelAsync(username).GetAwaiter().GetResult());
items.Add(new ChannelItem
{
ChannelId = channelId,
Name = c.Name,
Type = c.Type,
Group = c.Group,
IsReadOnly = c.IsReadOnly,
CanPost = canPost,
CanManage = canManage,
CreatedAt = c.CreatedAt
});
}
return items;
}
/// <summary>
/// WebSocketSharp callback when the connection drops (clean close OR network drop). Clears
/// both presence registries so other clients aren't trying to send to a dead session.
/// </summary>
protected override void OnClose(CloseEventArgs e)
{
ConnectedClientService.Unregister(ID);
RtcChannelPresenceService.RemoveSession(ID);
Console.WriteLine($"WS closed: session={ID}, code={e.Code}");
base.OnClose(e);
}
/// <summary>WebSocketSharp callback for socket-level errors. Logged but non-fatal.</summary>
protected override void OnError(ErrorEventArgs e)
{
Console.WriteLine($"WS error: session={ID}, message={e.Message}");
base.OnError(e);
}
// -------------------------------------------------------------------------
// Sync DB shims. WebSocketSharp's handler methods are synchronous, so async DB calls
// are wrapped in Task.Run(...).GetAwaiter().GetResult(). Not ideal but pragmatic — the
// alternative is refactoring WebSocketSharp's behavior model.
// -------------------------------------------------------------------------
private void RegisterOrUpdateClientKeySync(string username, string publicKey) =>
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult();
private List<Channels> GetChannelsSync() =>
Task.Run(async () => await Db!.Select<Channels>("channels")).GetAwaiter().GetResult().ToList();
private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username) =>
Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)).GetAwaiter().GetResult();
private List<ChannelMessages> GetChannelMessagesSync() =>
Task.Run(async () => await Db!.Select<ChannelMessages>("channel_messages")).GetAwaiter().GetResult().ToList();
private ChannelMessages? GetChannelMessageByIdSync(string messageId) =>
GetChannelMessagesSync().FirstOrDefault(m => GetRecordId(m.Id) == messageId);
private ChannelMessages CreateChannelMessageSync(ChannelMessages message) =>
Task.Run(async () => await Db!.Create("channel_messages", message)).GetAwaiter().GetResult();
private void UpdateChannelMessageSync(ChannelMessages message) =>
Task.Run(async () => await Db!.Merge<ChannelMessages, ChannelMessages>(message)).GetAwaiter().GetResult();
private void CreateChannelMessageEditSync(ChannelMessageEdits edit) =>
Task.Run(async () => await Db!.Create("channel_message_edits", edit)).GetAwaiter().GetResult();
private List<ChannelMessageEdits> GetChannelMessageEditsSync(string messageId)
{
var all = Task.Run(async () => await Db!.Select<ChannelMessageEdits>("channel_message_edits"))
.GetAwaiter().GetResult().ToList();
return all.Where(e => e.MessageId == messageId).ToList();
}
private List<ServerMembers> GetServerMembersSync() =>
Task.Run(async () => await Db!.Select<ServerMembers>("server_members")).GetAwaiter().GetResult().ToList();
/// <summary>"users:keeper317" → "keeper317". Stored as Surreal record id, displayed as plain name.</summary>
private static string ExtractUsernameFromUserId(string senderUserId)
{
if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown";
var parts = senderUserId.Split(':', 2);
return parts.Length == 2 ? parts[1] : senderUserId;
}
/// <summary>SurrealDB's Id object → "table:recordId" string. Used for storing parent refs as strings in child rows.</summary>
private static string GetRecordId(object? id)
{
if (id is null) return string.Empty;
var json = JsonSerializer.Serialize(id);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
}
/// <summary>Guard: returns true if the DB and key service are both initialised. Logs and returns false otherwise.</summary>
private bool EnsureCoreReady()
{
if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; }
return true;
}
/// <summary>Guard: returns true if encryption keys + channel crypto service are all set. Logs and returns false otherwise.</summary>
private bool EnsureCryptoReady()
{
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null)
{
Console.WriteLine("Crypto keys null.");
return false;
}
return true;
}
}

View File

@@ -0,0 +1,100 @@
using System.Collections.Concurrent;
namespace RelayServer.Services.Chat;
/// <summary>
/// Two-way in-memory mapping between WebSocket session IDs and usernames.
///
/// Why both directions: when a chat message arrives, we need to look up "which sessions does
/// this server member have open right now?" (username → sessions) so we can deliver to each
/// of their devices. When a connection closes, we need to know "which user owned this session?"
/// (session → username) to clean up correctly.
///
/// Multi-device support: one username can have multiple sessions (phone + desktop + web all
/// connected simultaneously). UsernameToSessions stores a HashSet per username; each lock
/// is scoped to that specific HashSet so different users never block each other.
///
/// Username comparisons are case-insensitive (OrdinalIgnoreCase on the outer dictionary)
/// because the DB stores usernames lowercase but clients may register with mixed case.
/// </summary>
public static class ConnectedClientService
{
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Associates a session ID with a username. Called from HandleRegisterKey. If the same
/// session re-registers under a different username (rare — basically only if the client
/// reauthenticates), the old mapping is cleaned up first to avoid double-bookkeeping.
/// </summary>
public static void Register(string sessionId, string username)
{
if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) &&
!string.Equals(oldUsername, username, StringComparison.OrdinalIgnoreCase))
{
RemoveSessionFromUsername(sessionId, oldUsername);
}
SessionToUsername[sessionId] = username;
var sessions = UsernameToSessions.GetOrAdd(
username,
_ => new HashSet<string>(StringComparer.Ordinal));
lock (sessions)
sessions.Add(sessionId);
}
/// <summary>
/// Removes a session from both mappings. Called from OnClose. Idempotent — calling for
/// a session that's already gone is a no-op.
/// </summary>
public static void Unregister(string sessionId)
{
if (SessionToUsername.TryRemove(sessionId, out var username))
RemoveSessionFromUsername(sessionId, username);
}
/// <summary>
/// Returns every active session ID for a given username (case-insensitive lookup).
/// Empty collection if the user is offline. Snapshot-safe: the returned list is a copy,
/// not a live view of the underlying HashSet.
/// </summary>
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
{
if (UsernameToSessions.TryGetValue(username, out var sessions))
{
lock (sessions)
return sessions.ToList();
}
return Array.Empty<string>();
}
/// <summary>
/// Reverse lookup: which user owns this session? Returns the mixed-case username the
/// client registered with (preserves casing for display). Null if the session is unknown.
/// </summary>
public static string? GetUsernameForSession(string sessionId)
{
return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
}
/// <summary>
/// Internal cleanup: pulls a session out of the username→sessions HashSet, and removes
/// the username entry entirely if no sessions remain (keeps the dictionary lean).
/// </summary>
private static void RemoveSessionFromUsername(string sessionId, string username)
{
if (!UsernameToSessions.TryGetValue(username, out var sessions))
return;
lock (sessions)
{
sessions.Remove(sessionId);
if (sessions.Count == 0)
UsernameToSessions.TryRemove(username, out _);
}
}
}

View File

@@ -0,0 +1,17 @@
namespace RelayServer.Services.Core;
public sealed class CoreClientService
{
public Task<CoreUser?> GetUserByUsernameAsync(string username)
{
return Task.FromResult<CoreUser?>(username switch
{
"Keeper317" => new CoreUser("users:keeper317", "Keeper317", true),
"Ru_Kira" => new CoreUser("users:ru_kira", "Ru_Kira", true),
"Test" => new CoreUser("users:test", "Test", true),
_ => null
});
}
}
public sealed record CoreUser(string Id, string Username, bool Licensed);

View File

@@ -0,0 +1,318 @@
using System.Text.Json;
using RelayServer.Models;
using RelayServer.Services.Chat;
using RelayServer.Services.Crypto;
using RelayShared.Services;
using SurrealDb.Net;
namespace RelayServer.Services.Core;
/// <summary>
/// Idempotent server setup. Runs once at boot from Program.cs.
///
/// Each "Ensure*" helper either inserts a missing row or patches an existing one so the
/// declared state matches the code. Running this twice in a row is a no-op.
///
/// What it provisions:
/// - Verifies the three test users exist via CoreClientService (currently a hardcoded stub).
/// - Creates the "Test Server" row in the servers table if missing.
/// - Adds those users to server_members, with Keeper317 as IsOwner=true.
/// - Creates the four premade channels with correct ChannelType and IsReadOnly flags:
/// welcome (Text, read-only) general (Text)
/// files (File, read-only) voice-general (Voice)
/// - Links #general → #files so attachments posted in #general auto-mirror to #files.
/// - Creates the three roles: Admin (all perms), Moderator (manage messages), Member (read+send).
/// - Assigns exactly one role per user (Keeper→Admin, Kira→Moderator, Test→Member).
/// SetUserRoleAsync DELETES stale assignments to guarantee single-role-per-user.
/// - Writes channel_permissions overrides explicitly denying Members SendMessages in
/// #welcome and #files.
/// - Generates the server's RSA keypair + the channel AES key on first boot, stores both
/// in server_encryption_keys, and copies them into ChatSocketBehavior's static fields.
/// </summary>
public sealed class ServerBootstrapService
{
private readonly SurrealDbClient _db;
private readonly CoreClientService _coreClient;
private readonly ChannelCryptoService _cryptoService;
public ServerBootstrapService(
SurrealDbClient db,
CoreClientService coreClient,
ChannelCryptoService cryptoService)
{
_db = db;
_coreClient = coreClient;
_cryptoService = cryptoService;
}
public async Task InitializeAsync()
{
var keeper = await _coreClient.GetUserByUsernameAsync("Keeper317");
var kira = await _coreClient.GetUserByUsernameAsync("Ru_Kira");
var test = await _coreClient.GetUserByUsernameAsync("Test");
if (keeper is null || kira is null || test is null)
throw new InvalidOperationException("One or more required users do not exist in RelayCore.");
if (!keeper.Licensed || !kira.Licensed || !test.Licensed)
throw new InvalidOperationException("One or more required users are not licensed.");
Console.WriteLine($"Core verified: {keeper.Username}, {kira.Username}, {test.Username}");
var server = await GetServerByNameAsync("Test Server");
if (server is null)
{
server = await _db.Create("servers", new Servers
{
Name = "Test Server",
OwnerUserId = keeper.Id,
CreatedAt = DateTime.UtcNow
});
Console.WriteLine($"Server created: {ToJson(server)}");
}
else
{
Console.WriteLine($"Server already exists: {server.Name}");
}
await EnsureServerMemberAsync(keeper.Id, isOwner: true);
await EnsureServerMemberAsync(kira.Id, isOwner: false);
await EnsureServerMemberAsync(test.Id, isOwner: false);
Console.WriteLine("Server members ensured.");
var tBase = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var chWelcome = await EnsureChannelAsync("welcome", ChannelType.Text, group: "General", isReadOnly: true, createdAt: tBase);
var chGeneral = await EnsureChannelAsync("general", ChannelType.Text, group: "General", isReadOnly: false, createdAt: tBase.AddHours(1));
var chFiles = await EnsureChannelAsync("files", ChannelType.File, group: "General", isReadOnly: true, createdAt: tBase.AddHours(2));
var chVoice = await EnsureChannelAsync("voice-general", ChannelType.Voice, group: "General", isReadOnly: false, createdAt: tBase.AddHours(3));
Console.WriteLine($"Channels: {GetRecordId(chWelcome.Id)} | {GetRecordId(chGeneral.Id)} | {GetRecordId(chFiles.Id)} | {GetRecordId(chVoice.Id)}");
await EnsureFileChannelLinkAsync(chGeneral, GetRecordId(chFiles.Id));
var adminRole = await EnsureRoleAsync("Admin", PermissionFlags.Administrator, priority: 0);
var modRole = await EnsureRoleAsync("Moderator", PermissionFlags.ReadMessages | PermissionFlags.SendMessages | PermissionFlags.ManageMessages, priority: 1);
var memberRole = await EnsureRoleAsync("Member", PermissionFlags.ReadMessages | PermissionFlags.SendMessages, priority: 2);
Console.WriteLine($"Roles ensured: Admin={GetRecordId(adminRole.Id)}, Mod={GetRecordId(modRole.Id)}, Member={GetRecordId(memberRole.Id)}");
await SetUserRoleAsync(keeper.Id, GetRecordId(adminRole.Id));
await SetUserRoleAsync(kira.Id, GetRecordId(modRole.Id));
await SetUserRoleAsync(test.Id, GetRecordId(memberRole.Id));
Console.WriteLine("User roles set.");
await EnsureChannelPermissionAsync(GetRecordId(chWelcome.Id), GetRecordId(memberRole.Id),
allow: PermissionFlags.ReadMessages, deny: PermissionFlags.SendMessages);
await EnsureChannelPermissionAsync(GetRecordId(chFiles.Id), GetRecordId(memberRole.Id),
allow: PermissionFlags.ReadMessages, deny: PermissionFlags.SendMessages);
Console.WriteLine("Channel permissions ensured.");
var existingKey = await GetLatestServerEncryptionKeyAsync();
if (existingKey is null)
{
var keyBase64 = _cryptoService.GenerateKey();
var serverKeys = E2EeHelper.GenerateRsaKeyPair();
existingKey = await _db.Create("server_encryption_keys", new ServerEncryptionKeys
{
KeyBase64 = keyBase64,
PublicKey = serverKeys.publicKey,
PrivateKey = serverKeys.privateKey,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
Console.WriteLine("Server encryption key created.");
}
else
{
Console.WriteLine("Server encryption key already exists.");
}
ChatSocketBehavior.ServerPublicKey = existingKey.PublicKey;
ChatSocketBehavior.ServerPrivateKey = existingKey.PrivateKey;
ChatSocketBehavior.ChannelDbKey = existingKey.KeyBase64;
}
private async Task EnsureServerMemberAsync(string userId, bool isOwner)
{
var members = await _db.Select<ServerMembers>("server_members");
var existing = members.FirstOrDefault(m => m.UserId == userId);
if (existing is not null)
{
if (existing.IsOwner != isOwner)
{
existing.IsOwner = isOwner;
await _db.Merge<ServerMembers, ServerMembers>(existing);
Console.WriteLine($"Member IsOwner updated: {userId} → {isOwner}");
}
else
{
Console.WriteLine($"Member already correct: {userId}");
}
return;
}
await _db.Create("server_members", new ServerMembers
{
UserId = userId,
JoinedAt = DateTime.UtcNow,
IsOwner = isOwner
});
Console.WriteLine($"Member created: {userId} (IsOwner={isOwner})");
}
private async Task<Channels> EnsureChannelAsync(
string name, ChannelType type, string group, bool isReadOnly, DateTime createdAt)
{
var channels = await _db.Select<Channels>("channels");
var existing = channels.FirstOrDefault(c => c.Name == name);
if (existing is not null)
{
bool dirty = existing.Type != type || existing.Group != group || existing.IsReadOnly != isReadOnly;
if (dirty)
{
existing.Type = type;
existing.Group = group;
existing.IsReadOnly = isReadOnly;
await _db.Merge<Channels, Channels>(existing);
Console.WriteLine($"Channel updated: {name}");
}
else
{
Console.WriteLine($"Channel already correct: {name}");
}
return existing;
}
var channel = await _db.Create("channels", new Channels
{
Name = name,
Type = type,
Group = group,
IsReadOnly = isReadOnly,
CreatedAt = createdAt
});
Console.WriteLine($"Channel created: {name} ({type})");
return channel;
}
private async Task EnsureFileChannelLinkAsync(Channels channel, string fileChannelId)
{
if (channel.LinkedFileChannelId == fileChannelId)
{
Console.WriteLine($"File link already correct: {channel.Name} → {fileChannelId}");
return;
}
channel.LinkedFileChannelId = fileChannelId;
await _db.Merge<Channels, Channels>(channel);
Console.WriteLine($"File link set: {channel.Name} → {fileChannelId}");
}
private async Task<Roles> EnsureRoleAsync(string name, PermissionFlags permissions, int priority)
{
var roles = await _db.Select<Roles>("roles");
var existing = roles.FirstOrDefault(r => r.Name == name);
if (existing is not null)
{
Console.WriteLine($"Role already exists: {name}");
return existing;
}
var role = await _db.Create("roles", new Roles
{
Name = name,
Permissions = permissions,
Priority = priority,
CreatedAt = DateTime.UtcNow
});
Console.WriteLine($"Role created: {name}");
return role;
}
private async Task SetUserRoleAsync(string userId, string roleId)
{
var userRoles = await _db.Select<UserRoles>("user_roles");
var existing = userRoles
.Where(ur => string.Equals(ur.UserId, userId, StringComparison.OrdinalIgnoreCase))
.ToList();
bool alreadyCorrect = existing.Count == 1 && existing[0].RoleId == roleId;
if (alreadyCorrect)
{
Console.WriteLine($"UserRole already correct: {userId} → {roleId}");
return;
}
foreach (var stale in existing)
{
if (stale.Id is not null)
await _db.Delete(stale.Id);
}
await _db.Create("user_roles", new UserRoles
{
UserId = userId,
RoleId = roleId,
AssignedAt = DateTime.UtcNow
});
Console.WriteLine($"UserRole set: {userId} → {roleId}");
}
private async Task EnsureChannelPermissionAsync(
string channelId, string roleId, PermissionFlags allow, PermissionFlags deny)
{
var perms = await _db.Select<ChannelPermissions>("channel_permissions");
if (perms.Any(cp => cp.ChannelId == channelId && cp.RoleId == roleId))
{
Console.WriteLine($"ChannelPermission already exists: {channelId} → {roleId}");
return;
}
await _db.Create("channel_permissions", new ChannelPermissions
{
ChannelId = channelId,
RoleId = roleId,
Allow = allow,
Deny = deny
});
Console.WriteLine($"ChannelPermission created: {channelId} → {roleId} | allow={allow}, deny={deny}");
}
private async Task<Servers?> GetServerByNameAsync(string name)
{
var servers = await _db.Select<Servers>("servers");
return servers.FirstOrDefault(x => x.Name == name);
}
private async Task<ServerEncryptionKeys?> GetLatestServerEncryptionKeyAsync()
{
var keys = await _db.Select<ServerEncryptionKeys>("server_encryption_keys");
return keys.OrderByDescending(x => x.CreatedAt).FirstOrDefault();
}
private static string GetRecordId(object? id)
{
if (id is null) return string.Empty;
var json = JsonSerializer.Serialize(id);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
}
private static string ToJson(object? obj) =>
JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}

View File

@@ -0,0 +1,98 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayServer.Services.Crypto;
/// <summary>
/// Hybrid RSA-2048 + AES-GCM-256 encryption. Used for any payload that needs to be
/// readable by exactly one party (the holder of a specific RSA private key).
///
/// Encrypt:
/// 1. Generate a fresh 256-bit AES key and 96-bit nonce.
/// 2. Encrypt the plaintext with AES-GCM → CipherText + Tag (auth tag, 128-bit).
/// 3. Encrypt the AES key with the recipient's RSA public key (OAEP-SHA256).
/// 4. Return all four as base64 strings in an EncryptedPayload.
///
/// Decrypt: reverse — RSA-decrypt the AES key, then AES-GCM-decrypt the ciphertext.
///
/// Why hybrid: RSA can only encrypt small inputs (~190 bytes for 2048-bit OAEP-SHA256).
/// Wrapping a symmetric key with RSA lets us encrypt arbitrarily large payloads while
/// still using the recipient's RSA keypair as the access mechanism. This is the same
/// design as PGP, TLS handshakes, etc.
///
/// The identical implementation exists in RelayClient.Crypto.E2EeHelper — they're
/// mirrored on both ends so any payload encrypted on one side decrypts on the other.
/// </summary>
public static class E2EeHelper
{
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
{
using var rsa = RSA.Create(2048);
return (
Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()),
Convert.ToBase64String(rsa.ExportPkcs8PrivateKey())
);
}
public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64)
{
byte[] aesKey = RandomNumberGenerator.GetBytes(32);
byte[] nonce = RandomNumberGenerator.GetBytes(12);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = new byte[plainBytes.Length];
byte[] tag = new byte[16];
using (var aes = new AesGcm(aesKey, 16))
{
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
}
byte[] encryptedKey;
using (var rsa = RSA.Create())
{
rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(recipientPublicKeyBase64), out _);
encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256);
}
return new EncryptedPayload
{
CipherText = Convert.ToBase64String(cipherBytes),
Nonce = Convert.ToBase64String(nonce),
Tag = Convert.ToBase64String(tag),
EncryptedKey = Convert.ToBase64String(encryptedKey)
};
}
public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64)
{
byte[] aesKey;
using (var rsa = RSA.Create())
{
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(recipientPrivateKeyBase64), out _);
aesKey = rsa.Decrypt(Convert.FromBase64String(payload.EncryptedKey), RSAEncryptionPadding.OaepSHA256);
}
byte[] plainBytes = new byte[Convert.FromBase64String(payload.CipherText).Length];
using (var aes = new AesGcm(aesKey, 16))
{
aes.Decrypt(
Convert.FromBase64String(payload.Nonce),
Convert.FromBase64String(payload.CipherText),
Convert.FromBase64String(payload.Tag),
plainBytes
);
}
return Encoding.UTF8.GetString(plainBytes);
}
}
public class EncryptedPayload
{
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required string EncryptedKey { get; set; }
}

Some files were not shown because too many files have changed in this diff Show More