109 Commits

Author SHA1 Message Date
3460ce6b04 need server and core webapp to work at same time for testing purposes 2026-05-03 18:04:40 -04:00
ec6a8c446a Auth setup continued 2026-05-02 16:06:08 -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
111 changed files with 6340 additions and 1950 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
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
############################################
# .NET Build
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
*.swp

138
Relay.sln
View File

@@ -1,62 +1,76 @@

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

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayCore", "RelayCore\RelayCore.csproj", "{346BE501-DE74-4E88-9787-4722FBC8BD0D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayClient", "RelayClient\RelayClient.csproj", "{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayServer", "RelayServer\RelayServer.csproj", "{38995780-E9AA-44D6-B62D-07CCA45E4E4C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RelayShared", "RelayShared\RelayShared.csproj", "{60B17B0B-9910-426A-9B48-AD9377AC89F7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x64.ActiveCfg = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x64.Build.0 = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x86.ActiveCfg = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Debug|x86.Build.0 = Debug|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|Any CPU.Build.0 = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x64.ActiveCfg = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x64.Build.0 = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x86.ActiveCfg = Release|Any CPU
{346BE501-DE74-4E88-9787-4722FBC8BD0D}.Release|x86.Build.0 = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x64.ActiveCfg = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x64.Build.0 = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x86.ActiveCfg = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Debug|x86.Build.0 = Debug|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|Any CPU.Build.0 = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x64.ActiveCfg = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x64.Build.0 = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x86.ActiveCfg = Release|Any CPU
{AB9DA5AB-55DC-4DE4-834C-E1E1BCD0C3CD}.Release|x86.Build.0 = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x64.ActiveCfg = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x64.Build.0 = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x86.ActiveCfg = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Debug|x86.Build.0 = Debug|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|Any CPU.Build.0 = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x64.ActiveCfg = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x64.Build.0 = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x86.ActiveCfg = Release|Any CPU
{38995780-E9AA-44D6-B62D-07CCA45E4E4C}.Release|x86.Build.0 = Release|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60B17B0B-9910-426A-9B48-AD9377AC89F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{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
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
############################################
# .NET Build
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
*.swp

View File

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

View File

@@ -1,16 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
namespace RelayClient;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
namespace RelayClient;
public partial class App : Application
{
public App()
{
InitializeComponent();
var username = Environment.GetCommandLineArgs()
.Skip(1)
.Chunk(2)
.Where(x => x.Length == 2 && x[0] == "--user")
.Select(x => x[1])
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(username))
{
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,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="RelayClient.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:RelayClient"
Title="RelayClient">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
</Shell>
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="RelayClient.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:RelayClient"
Title="RelayClient">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
</Shell>

View File

@@ -1,9 +1,9 @@
namespace RelayClient;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
}
namespace RelayClient;
public partial class AppShell : Shell
{
public AppShell()
{
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,78 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayClient.Crypto;
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; }
}

View File

@@ -0,0 +1,37 @@
namespace RelayClient.Crypto;
public static class KeyStorage
{
private static string GetKeyFolder()
{
var folder = Path.Combine(FileSystem.AppDataDirectory, "keys");
Directory.CreateDirectory(folder);
return folder;
}
public static void SavePrivateKey(string username, string privateKey)
{
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey);
}
public static void SavePublicKey(string username, string publicKey)
{
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"), publicKey);
}
public static string LoadPrivateKey(string username)
{
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"));
}
public static string LoadPublicKey(string username)
{
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"));
}
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

@@ -1,36 +1,93 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="RelayClient.MainPage">
<ScrollView>
<VerticalStackLayout
Padding="30,0"
Spacing="25">
<Image
Source="dotnet_bot.png"
HeightRequest="185"
Aspect="AspectFit"
SemanticProperties.Description="dot net bot in a submarine number ten" />
<Label
Text="Hello, World!"
Style="{StaticResource Headline}"
SemanticProperties.HeadingLevel="Level1" />
<Label
Text="Welcome to &#10;.NET Multi-platform App UI"
Style="{StaticResource SubHeadline}"
SemanticProperties.HeadingLevel="Level2"
SemanticProperties.Description="Welcome to dot net Multi platform App U I" />
<Button
x:Name="CounterBtn"
Text="Click me"
SemanticProperties.Hint="Counts the number of times you click"
Clicked="OnCounterClicked"
HorizontalOptions="Fill" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="RelayClient.MainPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
Title="Relay Client">
<Grid RowDefinitions="Auto,*,Auto"
ColumnDefinitions="220,*"
Padding="12"
RowSpacing="10"
ColumnSpacing="10">
<!-- Header -->
<Border Grid.Row="0"
Grid.ColumnSpan="2"
StrokeThickness="1"
Padding="10">
<VerticalStackLayout Spacing="4">
<Label x:Name="UserLabel"
Text="Logged in as: Unknown"
FontAttributes="Bold"
FontSize="18" />
<Label x:Name="ChannelLabel"
Text="No channel selected"
FontSize="14" />
</VerticalStackLayout>
</Border>
<!-- Sidebar -->
<Border Grid.Row="1"
Grid.Column="0"
StrokeThickness="1"
Padding="10">
<ScrollView>
<VerticalStackLayout Spacing="8">
<Label Text="Channels"
FontAttributes="Bold"
FontSize="16" />
<VerticalStackLayout x:Name="SidebarList"
Spacing="6" />
</VerticalStackLayout>
</ScrollView>
</Border>
<!-- Messages -->
<Border Grid.Row="1"
Grid.Column="1"
StrokeThickness="1"
Padding="10">
<ScrollView x:Name="MessagesScrollView">
<VerticalStackLayout x:Name="MessagesLayout"
Spacing="8" />
</ScrollView>
</Border>
<Border x:Name="RtcView"
Grid.Row="1"
Grid.Column="1"
StrokeThickness="1"
Padding="10"
IsVisible="False">
<!-- <WebView Source="test.html"/> -->
<Grid RowDefinitions="Auto,*"
ColumnDefinitions="*">
<HybridWebView x:Name="hybridWebView"
RawMessageReceived="OnHybridWebViewRawMessageReceived"
Grid.Row="1" />
</Grid>
</Border>
<!-- Input -->
<Grid Grid.Row="2"
Grid.Column="1"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<Entry x:Name="MessageEntry"
Grid.Column="0"
Placeholder="Type a message..."
ReturnType="Send"
Completed="MessageEntry_OnCompleted" />
<Button Grid.Column="1"
Text="Send"
Clicked="SendButton_OnClicked" />
</Grid>
<!-- Swap View -->
<Button x:Name="ViewSwapped" Grid.Row="2" Grid.Column="0"
Text="Swap to WebView"
Clicked="SwapView_OnClicked" />
</Grid>
</ContentPage>

View File

@@ -1,23 +1,342 @@
namespace RelayClient;
public partial class MainPage : ContentPage
{
int count = 0;
public MainPage()
{
InitializeComponent();
}
private void OnCounterClicked(object? sender, EventArgs e)
{
count++;
if (count == 1)
CounterBtn.Text = $"Clicked {count} time";
else
CounterBtn.Text = $"Clicked {count} times";
SemanticScreenReader.Announce(CounterBtn.Text);
}
using System.Text.Json.Serialization;
using RelayClient.Crypto;
using RelayClient.Services;
using RelayShared.Rtc;
using RelayShared.Services;
namespace RelayClient;
public partial class MainPage : ContentPage
{
private readonly string _username;
private readonly RelaySocketClient _socket;
private readonly RtcBridgeService _rtc;
private string? _currentChannelId;
private string? _currentChannelName;
private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new();
private readonly List<ChannelItem> _channels = [];
public MainPage(string username)
{
InitializeComponent();
_username = username;
UserLabel.Text = $"Logged in as: {_username}";
if (!KeyStorage.HasKeys(_username))
{
var keys = E2EeHelper.GenerateRsaKeyPair();
KeyStorage.SavePrivateKey(_username, keys.privateKey);
KeyStorage.SavePublicKey(_username, keys.publicKey);
}
ServerAPI.setupClient();
_socket = new RelaySocketClient(_username);
_rtc = new RtcBridgeService(
_username,
_socket,
hybridWebView,
() => _currentChannelId,
SafeSendRawToWebView
);
hybridWebView.SetInvokeJavaScriptTarget(_rtc);
_socket.Log += Console.WriteLine;
_socket.ChannelListReceived += HandleChannelList;
_socket.EncryptedChatReceived += HandleEncryptedChat;
_socket.EncryptedRtcSignalReceived += payload =>
{
MainThread.BeginInvokeOnMainThread(async () =>
{
await _rtc.HandleIncomingRtcSignalAsync(payload);
});
};
_socket.Connect();
}
private void SendButton_OnClicked(object? sender, EventArgs e)
{
SendMessage();
}
private void MessageEntry_OnCompleted(object? sender, EventArgs e)
{
SendMessage();
}
private void SendMessage()
{
var text = MessageEntry.Text?.Trim();
if (string.IsNullOrWhiteSpace(text))
return;
if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey))
{
Console.WriteLine("Server public key not loaded yet.");
return;
}
if (string.IsNullOrWhiteSpace(_currentChannelId))
{
Console.WriteLine("No channel selected yet.");
return;
}
var encrypted = E2EeHelper.EncryptForRecipient(text, _socket.ServerPublicKey);
var payload = new SocketEncryptedMessage
{
ChannelId = _currentChannelId!,
Type = SignalType.ClientEncryptedChat,
SenderUsername = _username,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
_socket.SendJson(payload);
Console.WriteLine($"[{_username}] sent encrypted message.");
MessageEntry.Text = string.Empty;
MessageEntry.Focus();
}
private void HandleChannelList(SocketChannelList channelList)
{
_channels.Clear();
_channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt));
var defaultChannel = _channels
.Where(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase))
.OrderBy(c => c.CreatedAt)
.FirstOrDefault()
?? _channels.OrderBy(c => c.CreatedAt).FirstOrDefault();
if (defaultChannel is null) return;
_currentChannelId = defaultChannel.ChannelId;
_currentChannelName = defaultChannel.Name;
MainThread.BeginInvokeOnMainThread(async () =>
{
ChannelLabel.Text = $"#{_currentChannelName}";
RenderChannelList();
await _rtc.PushRtcContextToJsAsync();
});
_socket.SendRaw($"GET_HISTORY|{_username}|{_currentChannelId}");
}
private void HandleEncryptedChat(SocketEncryptedMessage payload) {
if (payload.RecipientUsername != _username)
return;
string decryptedText;
try
{
var privateKey = KeyStorage.LoadPrivateKey(_username);
decryptedText = E2EeHelper.DecryptForRecipient(
new EncryptedPayload
{
CipherText = payload.CipherText,
Nonce = payload.Nonce,
Tag = payload.Tag,
EncryptedKey = payload.EncryptedKey
},
privateKey
);
}
catch (Exception ex)
{
Console.WriteLine($"[{_username}] failed to decrypt chat: {ex.Message}");
return;
}
var message = new ChatMessage
{
SenderUsername = payload.SenderUsername,
Text = decryptedText,
Timestamp = DateTime.Now
};
if (!_messagesByChannel.ContainsKey(payload.ChannelId))
_messagesByChannel[payload.ChannelId] = [];
_messagesByChannel[payload.ChannelId].Add(message);
if (payload.ChannelId == _currentChannelId)
{
MainThread.BeginInvokeOnMainThread(() =>
{
RenderSingleMessage(message);
});
}
}
protected override void OnDisappearing()
{
_socket.Disconnect();
base.OnDisappearing();
}
private void RenderChannelList()
{
SidebarList.Children.Clear();
foreach (var channel in _channels.OrderBy(c => c.CreatedAt))
{
var button = new ChannelButton
{
Text = $"#{channel.Name}",
Type = channel.Type,
Group = channel.Group
};
button.Clicked += (_, _) =>
{
_currentChannelId = channel.ChannelId;
_currentChannelName = channel.Name;
MainThread.BeginInvokeOnMainThread(async () =>
{
await _rtc.PushRtcContextToJsAsync();
if (channel.Type == ChannelType.Voice)
{
if (!RtcView.IsVisible)
SwapView();
_ = _rtc.JoinRtcChannel();
}
});
ChannelLabel.Text = $"#{_currentChannelName}";
RenderCurrentChannelMessages();
if (!_messagesByChannel.ContainsKey(channel.ChannelId))
_socket.SendRaw($"GET_HISTORY|{_username}|{channel.ChannelId}");
};
SidebarList.Children.Add(button);
}
}
private void RenderCurrentChannelMessages()
{
MessagesLayout.Children.Clear();
if (_currentChannelId is null)
return;
if (!_messagesByChannel.TryGetValue(_currentChannelId, out var messages))
return;
foreach (var message in messages.OrderBy(m => m.Timestamp))
{
RenderSingleMessage(message);
}
}
private async void RenderSingleMessage(ChatMessage message)
{
var isOwnMessage = message.SenderUsername == _username;
var bubble = new Border
{
StrokeThickness = 1,
Padding = 10,
Margin = isOwnMessage
? new Thickness(40, 0, 0, 0)
: new Thickness(0, 0, 40, 0),
HorizontalOptions = isOwnMessage
? LayoutOptions.End
: LayoutOptions.Start,
Content = new VerticalStackLayout
{
Spacing = 2,
Children =
{
new Label { Text = message.SenderUsername, FontAttributes = FontAttributes.Bold, FontSize = 12 },
new Label { Text = message.Text, FontSize = 14 },
new Label { Text = message.Timestamp.ToString("h:mm tt"), FontSize = 10 }
}
}
};
MessagesLayout.Children.Add(bubble);
await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true);
}
private void SwapView()
{
if (RtcView.IsVisible)
{
MessagesScrollView.IsVisible = true;
RtcView.IsVisible = false;
ViewSwapped.Text = "Swap to Web View";
}
else
{
MessagesScrollView.IsVisible = false;
RtcView.IsVisible = true;
ViewSwapped.Text = "Swap to Message View";
}
}
private void SwapView_OnClicked(object? sender, EventArgs e)
{
SwapView();
}
private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e)
{
if (e.Message == "rtc_page_ready")
{
await _rtc.PushRtcContextToJsAsync();
return;
}
SafeSendRawToWebView($"JS RAW -> C#: {e.Message}");
}
private void SafeSendRawToWebView(string message)
{
MainThread.BeginInvokeOnMainThread(() =>
{
try
{
hybridWebView.SendRawMessage(message);
}
catch (Exception ex)
{
Console.WriteLine($"[{_username}] failed to send raw message to HybridWebView: {ex.Message}");
}
});
}
public class ChannelButton : Button
{
public ChannelType Type { get; set; }
public string Group { get; set; } = string.Empty;
}
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(RtcDescription))]
[JsonSerializable(typeof(List<RtcSignalMessage>))]
[JsonSerializable(typeof(IceCandidate))]
[JsonSerializable(typeof(List<IceCandidate>))]
[JsonSerializable(typeof(string))]
internal partial class HybridJSType : JsonSerializerContext
{
}
}

View File

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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<?xml version="1.0" encoding="utf-8"?>
<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>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,23 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace RelayClient.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// 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().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace RelayClient.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// 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().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -1,46 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<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"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<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" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<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"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<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" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,51 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
You are responsible for adding extra entries as needed for your application.
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">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
You are responsible for adding extra entries as needed for your application.
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">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View File

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

View File

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

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
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}
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
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
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,71 @@
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;
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,227 @@
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;
await pc.addIceCandidate(msg.candidate);
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: JSON.stringify(event.candidate)
});
};
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"?>
<!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" />
<?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>

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

View File

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

161
RelayClient/ServerAPI.cs Normal file
View File

@@ -0,0 +1,161 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace RelayClient;
public class ServerAPI
{
static HttpClient client = new HttpClient { BaseAddress = new Uri("http://localhost:5000/") };
public static void setupClient()
{
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
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,127 @@
using System.Text.Json;
using RelayClient.Crypto;
using RelayShared.Services;
using WebSocketSharp;
namespace RelayClient.Services;
public sealed class RelaySocketClient
{
private readonly string _username;
private readonly WebSocket _socket;
public string? ServerPublicKey { get; private set; }
public event Action<string>? RawMessageReceived;
public event Action<SocketChannelList>? ChannelListReceived;
public event Action<SocketEncryptedMessage>? EncryptedChatReceived;
public event Action<SocketRtcSignalMessage>? EncryptedRtcSignalReceived;
public event Action<string>? ServerPublicKeyReceived;
public event Action<string>? Log;
public RelaySocketClient(string username, string url = "ws://localhost:1337/")
{
_username = username;
_socket = new WebSocket(url);
_socket.OnMessage += OnMessage;
}
public void Connect()
{
_socket.Connect();
var publicKey = KeyStorage.LoadPublicKey(_username);
SendRaw($"REGISTER_KEY|{_username}|{publicKey}");
SendRaw("GET_SERVER_KEY");
SendRaw("GET_CHANNELS");
}
public void SendRaw(string message)
{
if (_socket.ReadyState == WebSocketState.Open)
_socket.Send(message);
}
public void SendJson<T>(T payload)
{
SendRaw(JsonSerializer.Serialize(payload));
}
public void Disconnect()
{
_socket.OnMessage -= OnMessage;
if (_socket.ReadyState == WebSocketState.Open)
_socket.Close();
}
private void OnMessage(object? sender, MessageEventArgs e)
{
if (e.Data.StartsWith("SERVER:REGISTERED_KEY:"))
{
Log?.Invoke(e.Data);
return;
}
RawMessageReceived?.Invoke(e.Data);
Log?.Invoke($"[{_username}] RAW WS DATA: {e.Data}");
try
{
using var doc = JsonDocument.Parse(e.Data);
var root = doc.RootElement;
if (!root.TryGetProperty("Type", out var typeElement))
return;
var type = (SignalType)typeElement.GetInt32();
switch (type)
{
case SignalType.ChannelList:
{
var channelList = JsonSerializer.Deserialize<SocketChannelList>(e.Data);
if (channelList is not null)
ChannelListReceived?.Invoke(channelList);
return;
}
case SignalType.ServerPublicKey:
{
var serverKeyMessage = JsonSerializer.Deserialize<ServerPublicKeyMessage>(e.Data);
if (serverKeyMessage is not null)
{
ServerPublicKey = serverKeyMessage.PublicKey;
ServerPublicKeyReceived?.Invoke(serverKeyMessage.PublicKey);
}
return;
}
case SignalType.EncryptedSignal:
{
var payload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(e.Data);
if (payload is not null)
EncryptedRtcSignalReceived?.Invoke(payload);
return;
}
case SignalType.EncryptedChat:
{
var payload = JsonSerializer.Deserialize<SocketEncryptedMessage>(e.Data);
if (payload is not null)
EncryptedChatReceived?.Invoke(payload);
return;
}
}
}
catch (Exception ex)
{
Log?.Invoke($"[{_username}] failed to process websocket message: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,255 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using RelayClient.Crypto;
using RelayShared.Rtc;
using RelayShared.Services;
namespace RelayClient.Services;
public sealed class RtcBridgeService
{
private readonly string _username;
private readonly RelaySocketClient _socket;
private readonly HybridWebView _hybridWebView;
private readonly Func<string?> _getCurrentChannelId;
private readonly Action<string> _sendRawToWebView;
public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView,
Func<string?> getCurrentChannelId, Action<string> sendRawToWebView)
{
_username = username;
_socket = socket;
_hybridWebView = hybridWebView;
_getCurrentChannelId = getCurrentChannelId;
_sendRawToWebView = sendRawToWebView;
}
public Task JoinRtcChannel()
{
var channelId = _getCurrentChannelId();
if (string.IsNullOrWhiteSpace(channelId))
return Task.CompletedTask;
_socket.SendRaw($"RTC_JOIN_CHANNEL|{_username}|{channelId}");
return Task.CompletedTask;
}
public void LeaveRtcChannel()
{
var channelId = _getCurrentChannelId();
if (string.IsNullOrWhiteSpace(channelId))
return;
_socket.SendRaw($"RTC_LEAVE_CHANNEL|{_username}|{channelId}");
}
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);
}
}
public async Task<string> GetRtcParticipants()
{
var channelId = _getCurrentChannelId();
if (string.IsNullOrWhiteSpace(channelId))
return "[]";
var participants = await ServerAPI.GetRtcParticipantsAsync(channelId);
return JsonSerializer.Serialize(participants ?? []);
}
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);
}
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;
}
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
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
############################################
# .NET Build
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
*.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,59 @@
using RelayCore.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) =>
{
var ip = context.Connection.RemoteIpAddress?.MapToIPv4().ToString();
context.Request.Headers.TryGetValue("User-Agent", out var userAgent);
Console.WriteLine($"IP:{ip}\nUserAgent:{userAgent}");
// var token = await service.UserSigninAsync(request, ip, userAgent);
// return token != null ? Results.Ok(token) : Results.Unauthorized();
return Results.Ok();
});
app.MapPost("/user/register", async (AuthRegister request, APIAuthService service) =>
{
var token = await service.UserRegisterAsync(request);
return token != null ? Results.Ok(token) : Results.Unauthorized();
});
app.MapPost("/server/verify/user", async (AuthUserVerify request, APIAuthService service) =>
{
bool valid = await service.ServerVerifyUser(request);
return Results.Ok(valid);
});
app.MapPost("/server/verify/license", async (AuthServerLicense request, APIAuthService service) =>
{
throw new NotImplementedException();
});
}
}
public class AuthSignin
{
public string UserName { get; set; }
public string Password { get; set; }
}
public class AuthRegister
{
public string Username { get; set; }
public string Password { get; set; }
public string Email { get; set; }
}
public class AuthUserVerify
{
public string Username { get; set; }
public string Token { get; set; }
}
public class AuthServerLicense
{
public string License { get; set; }
}

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

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

@@ -0,0 +1,17 @@
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,129 @@
using SurrealDb.Net;
using SurrealDb.Net.Models;
using SurrealDb.Net.Models.Auth;
using System.Text.Json;
using PasswordHasher;
using RelayCore;
using var db = new SurrealDbClient("ws://127.0.0.1:8000/rpc");
await db.SignIn(new RootAuth { Username = "root", Password = "secret" });
await db.Use("test", "test");
var keeper = await CreateUserAsync(db, "Keeper317", "Keeper317@gmail.com", "password");
var kira = await CreateUserAsync(db, "Ru_Kira", "jduesling13@gmail.com", "password");
Console.WriteLine($"Keeper created: {ToJsonString(keeper)}");
Console.WriteLine($"Kira created: {ToJsonString(kira)}");
var keeperKeys = E2EeHelper.GenerateRsaKeyPair();
var kiraKeys = E2EeHelper.GenerateRsaKeyPair();
KeyStorage.SavePrivateKey("Keeper317", keeperKeys.privateKey);
KeyStorage.SavePrivateKey("Ru_Kira", kiraKeys.privateKey);
await db.Create("user_keys", new UserKeys
{
UserId = keeper.Id.ToString(),
PublicKey = keeperKeys.publicKey,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
await db.Create("user_keys", new UserKeys
{
UserId = kira.Id.ToString(),
PublicKey = kiraKeys.publicKey,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
Console.WriteLine("Public keys stored for both users.");
var conversation = await db.Create("conversations", new Conversations
{
CreatedByUserId = keeper.Id.ToString(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
Title = "Keeper317 + Ru_Kira",
IsDirectMessage = true
});
Console.WriteLine($"Conversation created: {ToJsonString(conversation)}");
await db.Create("conversation_members", new ConversationMembers
{
ConversationId = conversation.Id.ToString(),
UserId = keeper.Id.ToString(),
JoinedAt = DateTime.UtcNow
});
await db.Create("conversation_members", new ConversationMembers
{
ConversationId = conversation.Id.ToString(),
UserId = kira.Id.ToString(),
JoinedAt = DateTime.UtcNow
});
Console.WriteLine("Conversation members added.");
var encrypted = E2EeHelper.EncryptForRecipient("hello from Keeper317", kiraKeys.publicKey);
var savedMessage = await db.Create("messages", new Messages
{
ConversationId = conversation.Id.ToString(),
SenderUserId = keeper.Id.ToString(),
RecipientUserId = kira.Id.ToString(),
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey,
CreatedAt = DateTime.UtcNow
});
Console.WriteLine($"Encrypted message saved: {ToJsonString(savedMessage)}");
var decrypted = E2EeHelper.DecryptForRecipient(encrypted, kiraKeys.privateKey);
Console.WriteLine($"Decrypted for Ru_Kira: {decrypted}");
return;
static string ToJsonString(object? o)
{
return JsonSerializer.Serialize(o, new JsonSerializerOptions { WriteIndented = true });
}
static async Task<Users> CreateUserAsync(SurrealDbClient db, string username, string email, string rawPassword)
{
var now = DateTime.UtcNow;
var user = new Users
{
Username = username,
Email = email,
CreatedAt = now,
UpdatedAt = now,
LastLogin = now,
TwoFactorEnabled = false,
EmailVerified = false,
AccountStatus = (int)AccountStatuses.Active,
OnlineStatus = (int)OnlineStatuses.Online,
};
var created = await db.Create("users", user);
var hasher = new PasswordHasher.PasswordHasher();
var passwordHash = hasher.HashPassword(created.Id.ToString() + rawPassword);
var updated = await db.Merge<PasswordHash, Users>(new PasswordHash
{
Id = created.Id,
Password = passwordHash
});
return updated;
}
public static class KeyStorage
{
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,
using SurrealDb.Net;
using SurrealDb.Net.Models.Auth;
using System.Text.Json;
using System.Net;
using System.Text;
using RelayCore.Enums;
using RelayCore.Models;
using RelayCore.Endpoints;
using RelayCore.Services;
await using var db = new SurrealDbClient("ws://127.0.0.1:8000/rpc");
await db.SignIn(new RootAuth { Username = "root", Password = "secret" });
await db.Use("test", "test");
var keeper = await CreateUserAsync(db, "Keeper317", "Keeper317@gmail.com", "password");
var kira = await CreateUserAsync(db, "Ru_Kira", "jduesling13@gmail.com", "password");
var test = await CreateUserAsync(db, "Test", "test@gmail.com", "password");
var server = new Program();
Console.WriteLine($"Keeper created: {ToJsonString(keeper)}");
Console.WriteLine($"Kira created: {ToJsonString(kira)}");
Console.WriteLine($"Test created: {ToJsonString(test)}");
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:1337/");
builder.Services.AddSingleton(db);
builder.Services.AddScoped<APIAuthService>();
var app = builder.Build();
app.MapGet("/", () => "Auth Server Running!");
app.MapAuthEndpoints();
// await server.Main(db);
await app.StartAsync();
Console.WriteLine("API Started");
Console.WriteLine("\n\n\n");
Console.Write("Press any key to stop.");
Console.ReadKey(true);
await app.StopAsync();
return;
static string ToJsonString(object? o)
{
return JsonSerializer.Serialize(o, new JsonSerializerOptions { WriteIndented = true });
}
static async Task<Users> CreateUserAsync(SurrealDbClient db, string username, string email, string rawPassword)
{
var now = DateTime.UtcNow;
var user = new Users
{
Username = username,
Email = email,
CreatedAt = now,
UpdatedAt = now,
LastLogin = now,
TwoFactorEnabled = false,
EmailVerified = false,
AccountStatus = (int)AccountStatuses.Active,
OnlineStatus = (int)OnlineStatuses.Online,
};
var created = await db.Create("auth_users", user);
var hasher = new PasswordHasher();
var passwordHash = hasher.HashPassword(created.Id.ToString() + rawPassword);
var updated = await db.Merge<PasswordHash, Users>(new PasswordHash
{
Id = created.Id,
Password = passwordHash
});
return updated;
}
partial class Program
{
public async Task Main(SurrealDbClient db)
{
// Set up listener
using var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8080/");
listener.Start();
Console.WriteLine("API Started: http://localhost:8080/");
while (true)
{
// Process requests
var context = await listener.GetContextAsync();
var req = context.Request;
var res = context.Response;
if (req.Url.AbsolutePath == "/api/hello" && req.HttpMethod == "GET")
{
var data = new { Message = "Hello, world!", Time = DateTime.Now };
byte[] buf = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data));
res.ContentType = "application/json";
await res.OutputStream.WriteAsync(buf, 0, buf.Length);
}
if (req.Url.AbsolutePath == "/api/users" && req.HttpMethod == "GET")
{
var data = new { Message = GetDBUsers(db).Result, Time = DateTime.Now };
byte[] buf = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data));
res.ContentType = "application/json";
await res.OutputStream.WriteAsync(buf, 0, buf.Length);
}
res.Close();
}
}
static async Task<Users[]> GetDBUsers(SurrealDbClient db)
{
var users = await db.Select<Users>("users");
Console.WriteLine(ToJsonString(users));
return users.ToArray();
}
static string ToJsonString(object? o)
{
return JsonSerializer.Serialize(o, new JsonSerializerOptions { WriteIndented = true, });
}
}

View File

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

View File

@@ -0,0 +1,49 @@
using RelayCore.Endpoints;
using RelayCore.Models;
using SurrealDb.Net;
using SurrealDb.Net.Models;
namespace RelayCore.Services;
public class APIAuthService(SurrealDbClient _db)
{
public async Task<string> UserSigninAsync(AuthSignin request)
{
var hasher = new PasswordHasher();
var users = await _db.Select<Users>("auth_users");
var user = users.FirstOrDefault(x => (x.Username == request.UserName || x.Email == request.UserName)
&& hasher.VerifyPassword(request.Password, x.Password));
var tokens = await _db.Select<Sessions>("auth_sessions");
var token = tokens.Where(x => x.UserId == user.Id && !x.Revoked).OrderByDescending(x => x.ExpiresAt).FirstOrDefault();
if (token.ExpiresAt > DateTime.UtcNow)
return token.TokenHash;
//TODO: Generate TOKEN
var newToken = hasher.HashPassword($"{user.Email}{user.Username}{user.Password}");
//TODO: Store TOKEN and Username for verification
var sessionId = await _db.Create<Sessions>(new Sessions
{
UserId = user.Id,
TokenHash = newToken,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(30),
DeviceName = "",
Revoked = false,
IpAddress = "",
UserAgent = ""
});
//TODO: Add invalidation to TOKENs
return newToken;
}
public async Task<string> UserRegisterAsync(AuthRegister request)
{
throw new NotImplementedException();
}
public async Task<bool> ServerVerifyUser(AuthUserVerify request)
{
throw new NotImplementedException();
}
}

185
RelayServer/.gitignore vendored
View File

@@ -1,93 +1,94 @@
############################################
# .NET Build
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
############################################
# .NET Build
############################################
bin/
obj/
out/
publish/
############################################
# Visual Studio
############################################
.vs/
*.user
*.suo
*.userprefs
*.csproj.user
*.dbmdl
*.cache
*.pdb
*.opendb
############################################
# Rider / JetBrains
############################################
.idea/
*.sln.iml
############################################
# VSCode
############################################
.vscode/
############################################
# NuGet
############################################
*.nupkg
*.snupkg
packages/
.nuget/
.nuget/packages/
############################################
# Logs
############################################
*.log
logs/
############################################
# OS files
############################################
.DS_Store
Thumbs.db
############################################
# Local secrets / environment
############################################
.env
.env.*
secrets.json
appsettings.Development.json
############################################
# E2EE private keys
############################################
keys/*
!keys/.gitkeep
############################################
# Local test databases / data folders
############################################
data/
!Services/Data/
*.db
*.sqlite
*.sqlite3
############################################
# Temporary files
############################################
*.tmp
*.temp
*.bak
*.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,13 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
public class ChannelMessages : Record
{
public required string ChannelId { get; set; }
public required string SenderUserId { get; set; }
public required string CipherText { get; set; }
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,9 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
public class Channels : Record
{
public required string Name { get; set; }
public required DateTime CreatedAt { 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,11 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
public class ClientPublicKeys : Record
{
public required string Username { get; set; }
public required string PublicKey { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
public class ServerEncryptionKeys : Record
{
public required string KeyBase64 { get; set; }
public required string PublicKey { get; set; }
public required string PrivateKey { get; set; }
public required DateTime CreatedAt { get; set; }
public required DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,10 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
public class ServerMembers : Record
{
public required string UserId { get; set; }
public required DateTime JoinedAt { get; set; }
public bool IsOwner { get; set; }
}

View File

@@ -0,0 +1,10 @@
using SurrealDb.Net.Models;
namespace RelayServer.Models;
public class Servers : Record
{
public required string Name { get; set; }
public required string OwnerUserId { get; set; }
public required DateTime CreatedAt { get; set; }
}

View File

@@ -1 +1,49 @@
Console.WriteLine("Hello, World!");
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.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.Services.AddSingleton(db);
builder.Services.AddScoped<RtcCallService>();
var app = builder.Build();
app.MapGet("/", () => "Server Running!");
app.MapRtcEndpoints();
var wssv = new WebSocketServer("ws://localhost:1337");
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">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<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,44 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayServer.Services.Chat;
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,640 @@
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>
/// Handles websocket-based chat operations including client key registration,
/// server key retrieval, channel listing, channel history loading, and encrypted
/// channel message relay.
/// </summary>
public class ChatSocketBehavior : WebSocketBehavior
{
public static ClientKeyService? ClientKeyService { get; set; }
public static string? ServerPublicKey { get; set; }
public static string? ServerPrivateKey { get; set; }
public static string? ChannelDbKey { get; set; }
public static ChannelCryptoService? ChannelCryptoService { get; set; }
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
/// <summary>
/// Routes incoming websocket messages to the appropriate chat handler.
/// </summary>
/// <param name="e">The websocket message event arguments.</param>
protected override void OnMessage(MessageEventArgs e)
{
var msg = e.Data;
Console.WriteLine(msg);
if (msg.StartsWith("REGISTER_KEY|"))
{
HandleRegisterKey(msg);
return;
}
if (msg == "GET_SERVER_KEY")
{
HandleGetServerKey();
return;
}
if (msg == "GET_CHANNELS")
{
HandleGetChannels();
return;
}
if (msg.StartsWith("GET_HISTORY|"))
{
HandleGetHistory(msg);
return;
}
if (msg.StartsWith("RTC_JOIN_CHANNEL|"))
{
HandleRtcJoinChannel(msg);
return;
}
if (msg.StartsWith("RTC_LEAVE_CHANNEL|"))
{
HandleRtcLeaveChannel(msg);
return;
}
if (IsEncryptedRtcSignal(msg))
{
HandleEncryptedRtcSignal(msg);
return;
}
HandleEncryptedChatMessage(msg);
}
private static bool IsEncryptedRtcSignal(string msg)
{
try
{
using var doc = JsonDocument.Parse(msg);
var root = doc.RootElement;
if (!root.TryGetProperty("Type", out var typeProp))
return false;
var type = (SignalType)typeProp.GetInt32();
return type == SignalType.EncryptedSignal;
}
catch
{
return false;
}
}
private void HandleEncryptedRtcSignal(string msg)
{
Console.WriteLine("RTC SIGNAL HIT");
SocketRtcSignalMessage? clientPayload;
try
{
clientPayload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg);
}
catch
{
Console.WriteLine("Failed to parse encrypted RTC signal payload.");
return;
}
if (clientPayload is null || clientPayload.Type != SignalType.EncryptedSignal)
return;
if (string.IsNullOrWhiteSpace(clientPayload.ChannelId))
{
Console.WriteLine("Encrypted RTC signal missing channel id.");
return;
}
string plainText;
try
{
plainText = E2EeHelper.DecryptForRecipient(
new EncryptedPayload
{
CipherText = clientPayload.CipherText,
Nonce = clientPayload.Nonce,
Tag = clientPayload.Tag,
EncryptedKey = clientPayload.EncryptedKey
},
ServerPrivateKey
);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to decrypt RTC signal: {ex.Message}");
return;
}
var sessionIds = RtcChannelPresenceService.GetSessionsInChannel(clientPayload.ChannelId);
foreach (var sessionId in sessionIds)
{
if (sessionId == ID)
continue;
var username = RtcChannelPresenceService.GetUsernameForSession(sessionId);
if (string.IsNullOrWhiteSpace(username))
continue;
var clientKey = GetClientPublicKeyByUsernameSync(username);
if (clientKey is null)
continue;
var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey);
var outbound = new SocketRtcSignalMessage
{
Type = SignalType.EncryptedSignal,
SenderUsername = clientPayload.SenderUsername,
ChannelId = clientPayload.ChannelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Sessions.SendTo(JsonSerializer.Serialize(outbound), sessionId);
}
Console.WriteLine($"Forwarded encrypted RTC signal from {clientPayload.SenderUsername} to channel {clientPayload.ChannelId}");
}
/// <summary>
///
/// </summary>
/// <param name="e"></param>
protected override void OnClose(CloseEventArgs e)
{
RtcChannelPresenceService.RemoveSession(ID);
Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}");
base.OnClose(e);
}
protected override void OnError(ErrorEventArgs e)
{
Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}");
base.OnError(e);
}
/// <summary>
/// Extracts a display username from a stored user record id value.
/// </summary>
/// <param name="senderUserId">The stored sender user id.</param>
/// <returns>
/// The extracted username when possible; otherwise, a fallback value.
/// </returns>
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>
/// Registers or updates a client's public key from a websocket registration payload.
/// </summary>
/// <param name="msg">The raw websocket registration message.</param>
private void HandleRegisterKey(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid REGISTER_KEY payload.");
return;
}
var username = parts[1];
var publicKey = parts[2];
if (ClientKeyService is null)
{
Console.WriteLine("ClientKeyService is not initialized.");
return;
}
RegisterOrUpdateClientKeySync(username, publicKey);
Send($"SERVER:REGISTERED_KEY:{username}");
}
/// <summary>
/// Sends the current list of channels to the connected websocket client.
/// </summary>
private void HandleGetChannels()
{
if (Db is null)
{
Console.WriteLine("Db is not initialized.");
return;
}
//TODO: Update to include ChannelType and Group String on channels
var channels = GetChannelsSync()
.OrderBy(c => c.CreatedAt)
.Select(c => new ChannelItem()
{
ChannelId = GetRecordId(c.Id),
Name = c.Name,
CreatedAt = c.CreatedAt
})
.ToList();
var payload = new SocketChannelList
{
Type = SignalType.ChannelList,
Channels = channels
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Sends the server's public key to the connected websocket client.
/// </summary>
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey))
{
Console.WriteLine("Server public key is not initialized.");
return;
}
var payload = new ServerPublicKeyMessage
{
Type = SignalType.ServerPublicKey,
PublicKey = ServerPublicKey
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Decrypts an incoming encrypted chat payload, stores it in the database,
/// and rebroadcasts it to connected clients encrypted with each client's public key.
/// </summary>
/// <param name="msg">The raw encrypted chat websocket message.</param>
private void HandleEncryptedChatMessage(string msg)
{
SocketEncryptedMessage? clientPayload;
try
{
clientPayload = JsonSerializer.Deserialize<SocketEncryptedMessage>(msg);
}
catch
{
Console.WriteLine("Failed to parse encrypted client payload.");
return;
}
if (clientPayload is null || clientPayload.Type != SignalType.ClientEncryptedChat)
return;
if (!EnsureCoreReady() || !EnsureCryptoReady())
return;
string plainText;
try
{
plainText = E2EeHelper.DecryptForRecipient(
new EncryptedPayload
{
CipherText = clientPayload.CipherText,
Nonce = clientPayload.Nonce,
Tag = clientPayload.Tag,
EncryptedKey = clientPayload.EncryptedKey
},
ServerPrivateKey
);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to decrypt client payload: {ex.Message}");
return;
}
Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
try
{
var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey);
var savedMessage = CreateChannelMessageSync(new ChannelMessages
{
ChannelId = clientPayload.ChannelId,
SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
CipherText = dbEncrypted.cipherText,
Nonce = dbEncrypted.nonce,
Tag = dbEncrypted.tag,
CreatedAt = DateTime.UtcNow
});
Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to save live message to DB: {ex.Message}");
return;
}
var allKeys = GetAllClientPublicKeysSync();
foreach (var client in allKeys)
{
var encrypted = E2EeHelper.EncryptForRecipient(plainText, client.PublicKey);
Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {client.Username}");
var outbound = new SocketEncryptedMessage
{
Type = SignalType.EncryptedChat,
SenderUsername = clientPayload.SenderUsername,
RecipientUsername = client.Username,
ChannelId = clientPayload.ChannelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Sessions.Broadcast(JsonSerializer.Serialize(outbound));
}
}
/// <summary>
/// Loads stored channel history for a specific user and channel, decrypts it from
/// database storage format, and sends it back encrypted for the requesting client.
/// </summary>
/// <param name="msg">The raw history request websocket message.</param>
private void HandleGetHistory(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid GET_HISTORY payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
{
Console.WriteLine("History dependencies are not initialized.");
return;
}
var targetClient = GetClientPublicKeyByUsernameSync(username);
if (targetClient is null)
{
Console.WriteLine($"No public key found for history request user {username}");
return;
}
var allMessages = GetChannelMessagesSync();
var channelMessages = allMessages
.Where(m => m.ChannelId == channelId)
.OrderBy(m => m.CreatedAt)
.ToList();
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
foreach (var dbMessage in channelMessages)
{
string plainText;
try
{
plainText = ChannelCryptoService.Decrypt(
dbMessage.CipherText,
dbMessage.Nonce,
dbMessage.Tag,
ChannelDbKey
);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to decrypt DB history row {dbMessage.Id}: {ex.Message}");
continue;
}
var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
var outbound = new SocketEncryptedMessage
{
Type = SignalType.EncryptedChat,
SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId),
RecipientUsername = username,
ChannelId = channelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Send(JsonSerializer.Serialize(outbound));
}
}
/// <summary>
/// Converts a SurrealDB record id object into a table:id string representation.
/// </summary>
/// <param name="id">The raw record id object.</param>
/// <returns>
/// A formatted record id string, or an empty string if the input is null.
/// </returns>
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;
var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
var table = root.GetProperty("Table").GetString() ?? string.Empty;
return $"{table}:{recordId}";
}
/// <summary>
/// Synchronously registers or updates a stored client public key using the async key service.
/// </summary>
/// <param name="username">The client username.</param>
/// <param name="publicKey">The client's public key.</param>
private void RegisterOrUpdateClientKeySync(string username, string publicKey)
{
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey))
.GetAwaiter()
.GetResult();
}
/// <summary>
/// Synchronously loads all channels from the database.
/// </summary>
/// <returns>A list of channel records.</returns>
private List<Channels> GetChannelsSync()
{
return Task.Run(async () => await Db!.Select<Channels>("channels"))
.GetAwaiter()
.GetResult()
.ToList();
}
/// <summary>
/// Synchronously gets the stored public key record for the specified user.
/// </summary>
/// <param name="username">The username to look up.</param>
/// <returns>
/// The matching client public key record, or null if none exists.
/// </returns>
private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username)
{
return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username))
.GetAwaiter()
.GetResult();
}
/// <summary>
/// Synchronously loads all stored client public key records.
/// </summary>
/// <returns>A list of all client public key records.</returns>
private List<ClientPublicKeys> GetAllClientPublicKeysSync()
{
return Task.Run(async () => await ClientKeyService!.GetAllAsync())
.GetAwaiter()
.GetResult();
}
/// <summary>
/// Synchronously loads all stored channel messages from the database.
/// </summary>
/// <returns>A list of channel message records.</returns>
private List<ChannelMessages> GetChannelMessagesSync()
{
return Task.Run(async () => await Db!.Select<ChannelMessages>("channel_messages"))
.GetAwaiter()
.GetResult()
.ToList();
}
/// <summary>
/// Synchronously creates a new channel message record in the database.
/// </summary>
/// <param name="message">The message record to create.</param>
/// <returns>The created channel message record.</returns>
private ChannelMessages CreateChannelMessageSync(ChannelMessages message)
{
return Task.Run(async () => await Db!.Create("channel_messages", message))
.GetAwaiter()
.GetResult();
}
/// <summary>
///
/// </summary>
/// <returns></returns>
private bool EnsureCoreReady()
{
if (ClientKeyService is null || Db is null)
{
Console.WriteLine("Core services not initialized.");
return false;
}
return true;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
private bool EnsureCryptoReady()
{
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
{
Console.WriteLine("Crypto keys not initialized.");
return false;
}
if (ChannelCryptoService is null)
{
Console.WriteLine("ChannelCryptoService is not initialized.");
return false;
}
return true;
}
/// <summary>
///
/// </summary>
/// <param name="msg"></param>
private void HandleRtcJoinChannel(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid RTC_JOIN_CHANNEL payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
RtcChannelPresenceService.SetUser(ID, username);
RtcChannelPresenceService.JoinChannel(ID, channelId);
Console.WriteLine($"RTC presence joined: session={ID}, user={username}, channel={channelId}");
}
/// <summary>
///
/// </summary>
/// <param name="msg"></param>
private void HandleRtcLeaveChannel(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid RTC_LEAVE_CHANNEL payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
if (RtcChannelPresenceService.IsInChannel(ID, channelId))
{
RtcChannelPresenceService.LeaveChannel(ID);
}
Console.WriteLine($"RTC presence left: session={ID}, user={username}, channel={channelId}");
}
}

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,195 @@
using System.Text.Json;
using RelayServer.Models;
using RelayServer.Services.Chat;
using RelayServer.Services.Crypto;
using SurrealDb.Net;
namespace RelayServer.Services.Core;
public sealed class ServerBootstrapService
{
// TODO: Make channels dynamically addable
// TODO: Add logic for channel types (ENUM)
// TODO: Add logic for channel groups for future UI use
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 user: {keeper.Username}");
Console.WriteLine($"Core verified user: {kira.Username}");
Console.WriteLine($"Core verified user: {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: {ToJsonString(server)}");
}
else
{
Console.WriteLine($"Server already exists: {ToJsonString(server)}");
}
await EnsureServerMemberAsync(keeper.Id, true);
await EnsureServerMemberAsync(kira.Id, false);
await EnsureServerMemberAsync(test.Id, false);
Console.WriteLine("Server members ensured.");
var channel = await EnsureChannelAsync("general", DateTime.UtcNow);
var channel2 = await EnsureChannelAsync("files", DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0)));
var channel3 = await EnsureChannelAsync("welcome", DateTime.UtcNow.Subtract(new TimeSpan(1, 4, 4, 4)));
var channel4 = await EnsureChannelAsync("voice-general", DateTime.UtcNow.Subtract(new TimeSpan(0, 2, 0, 0)));
Console.WriteLine($"Resolved channelId: {GetRecordId(channel.Id)}");
Console.WriteLine($"Resolved channelId: {GetRecordId(channel2.Id)}");
Console.WriteLine($"Resolved channelId: {GetRecordId(channel3.Id)}");
Console.WriteLine($"Resolved channelId: {GetRecordId(channel4.Id)}");
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 static string ToJsonString(object? obj)
{
return JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
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;
var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
var table = root.GetProperty("Table").GetString() ?? string.Empty;
return $"{table}:{recordId}";
}
private async Task<Servers?> GetServerByNameAsync(string name)
{
var servers = await _db.Select<Servers>("servers");
return servers.FirstOrDefault(x => x.Name == name);
}
private async Task<ServerMembers?> GetServerMemberByUserIdAsync(string userId)
{
var members = await _db.Select<ServerMembers>("server_members");
return members.FirstOrDefault(x => x.UserId == userId);
}
private async Task<Channels?> GetChannelByNameAsync(string name)
{
var channels = await _db.Select<Channels>("channels");
return channels.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 async Task EnsureServerMemberAsync(string userId, bool isOwner)
{
var existing = await GetServerMemberByUserIdAsync(userId);
if (existing is not null)
{
Console.WriteLine($"Server member already exists for {userId}");
return;
}
await _db.Create("server_members", new ServerMembers
{
UserId = userId,
JoinedAt = DateTime.UtcNow,
IsOwner = isOwner
});
Console.WriteLine($"Server member created for {userId}");
}
private async Task<Channels> EnsureChannelAsync(string name, DateTime createdAt)
{
var existing = await GetChannelByNameAsync(name);
if (existing is not null)
{
Console.WriteLine($"Channel already exists: {name}");
return existing;
}
var channel = await _db.Create("channels", new Channels
{
Name = name,
CreatedAt = createdAt
});
Console.WriteLine($"Channel created: {ToJsonString(channel)}");
return channel;
}
}

View File

@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayServer.Services.Crypto;
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; }
}

View File

@@ -0,0 +1,61 @@
using RelayServer.Models;
using SurrealDb.Net;
namespace RelayServer.Services.Data;
public sealed class ClientKeyService
{
private readonly SurrealDbClient _db;
public ClientKeyService(SurrealDbClient db)
{
_db = db;
}
public async Task RegisterOrUpdateKeyAsync(string username, string publicKey)
{
var allKeys = await _db.Select<ClientPublicKeys>("client_public_keys");
var existing = allKeys.FirstOrDefault(x => x.Username == username);
if (existing is null)
{
await _db.Create("client_public_keys", new ClientPublicKeys
{
Username = username,
PublicKey = publicKey,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
Console.WriteLine($"Stored public key for {username}");
return;
}
existing.PublicKey = publicKey;
existing.UpdatedAt = DateTime.UtcNow;
await _db.Merge<ClientPublicKeys, ClientPublicKeys>(new ClientPublicKeys
{
Id = existing.Id,
Username = existing.Username,
PublicKey = existing.PublicKey,
CreatedAt = existing.CreatedAt,
UpdatedAt = existing.UpdatedAt
});
Console.WriteLine($"Updated public key for {username}");
}
public async Task<ClientPublicKeys?> GetByUsernameAsync(string username)
{
var allKeys = await _db.Select<ClientPublicKeys>("client_public_keys");
return allKeys.FirstOrDefault(x => x.Username == username);
}
public async Task<List<ClientPublicKeys>> GetAllAsync()
{
var allKeys = await _db.Select<ClientPublicKeys>("client_public_keys");
return allKeys.ToList();
}
}

View File

@@ -0,0 +1,21 @@
using SurrealDb.Net;
using SurrealDb.Net.Models.Auth;
namespace RelayServer.Services.Data;
public sealed class SurrealService
{
public async Task<SurrealDbClient> ConnectAsync()
{
var db = new SurrealDbClient("ws://127.0.0.1:8000/rpc");
await db.SignIn(new RootAuth
{
Username = "root",
Password = "secret"
});
await db.Use("test", "test");
return db;
}
}

View File

@@ -0,0 +1,253 @@
using RelayShared.Rtc;
using SurrealDb.Net;
using RelayShared.Rtc;
namespace RelayServer.Services.Rtc;
public sealed class RtcCallService
{
private readonly SurrealDbClient _db;
public RtcCallService(SurrealDbClient db)
{
_db = db;
}
/// <summary>
/// Checks whether the specified channel currently has an active RTC call.
/// </summary>
/// <param name="channelId">The channel to inspect.</param>
/// <returns>
/// True if the channel has an active call; otherwise, false.
/// </returns>
public async Task<bool> HasActiveCallAsync(string channelId)
{
var activeCalls = await _db.Select<DBActiveCall>("rtc_active_calls");
return activeCalls.Any(x => x.ChannelId == channelId && x.IsActive);
}
public async Task<DBActiveCall?> GetActiveCallAsync(string channelId)
{
var activeCalls = await _db.Select<DBActiveCall>("rtc_active_calls");
return activeCalls
.Where(x => x.ChannelId == channelId && x.IsActive)
.OrderByDescending(x => x.UpdatedAt)
.FirstOrDefault();
}
/// <summary>
/// Creates or updates the current SDP offer for a user in the specified channel.
/// If no active call exists for the channel, a new active call is created.
/// Otherwise, the existing active call timestamp is refreshed.
/// </summary>
/// <param name="channelId">The channel the offer belongs to.</param>
/// <param name="username">The user creating the offer.</param>
/// <param name="type">The RtcSession Type. </param>
/// <param name="sdp">The SDP offer payload.</param>
public async Task WriteOfferAsync(string channelId, string username, RtcSessionDescription sessionDescription)
{
var activeCalls = await _db.Select<DBActiveCall>("rtc_active_calls");
var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive);
if (activeCall is null)
{
await _db.Create("rtc_active_calls", new DBActiveCall
{
ChannelId = channelId,
OfferUser = username,
Offer = new RtcSessionDescription
{
Type = sessionDescription.Type,
Sdp = sessionDescription.Sdp
},
Answer = null,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsActive = true
});
return;
}
activeCall.OfferUser = username;
activeCall.Offer = new RtcSessionDescription
{
Type = sessionDescription.Type,
Sdp = sessionDescription.Sdp
};
activeCall.UpdatedAt = DateTime.UtcNow;
await _db.Merge<DBActiveCall, DBActiveCall>(activeCall);
}
/// <summary>
/// Gets the current offer stored on the active call for the specified channel.
/// </summary>
/// <param name="channelId">The channel whose offer should be retrieved.</param>
/// <returns>
/// The current offer for the active call, or null if no active call or offer exists.
/// </returns>
public async Task<RtcSessionDescription?> GetOfferAsync(string channelId)
{
var activeCall = await GetActiveCallAsync(channelId);
return activeCall?.Offer;
}
/// <summary>
/// Writes a new SDP answer for the specified channel and refreshes the active call timestamp
/// when a matching active call exists.
/// </summary>
/// <param name="channelId">The channel the answer belongs to.</param>
/// <param name="offerUser">The original offer owner.</param>
/// <param name="sessionDescription">The SDP and type answer payload.</param>
public async Task WriteAnswerAsync(string channelId, RtcSessionDescription sessionDescription)
{
var activeCalls = await _db.Select<DBActiveCall>("rtc_active_calls");
var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive);
if (activeCall is null)
return;
activeCall.Answer = new RtcSessionDescription
{
Type = sessionDescription.Type,
Sdp = sessionDescription.Sdp
};
activeCall.UpdatedAt = DateTime.UtcNow;
await _db.Merge<DBActiveCall, DBActiveCall>(activeCall);
}
/// <summary>
/// Gets all answers stored for the specified channel in creation order.
/// </summary>
/// <param name="channelId">The channel whose answers should be retrieved.</param>
/// <returns>
/// A list of answers for the channel ordered from oldest to newest.
/// </returns>
public async Task<List<RtcSessionDescription>> GetAnswersAsync(string channelId)
{
var activeCall = await GetActiveCallAsync(channelId);
if (activeCall?.Answer is null)
return [];
return [activeCall.Answer];
}
/// <summary>
/// Gets the most recent answer stored for the specified channel.
/// </summary>
/// <param name="channelId">The channel whose latest answer should be retrieved.</param>
/// <returns>
/// The newest answer for the channel, or null if no answer exists.
/// </returns>
public async Task<RtcSessionDescription?> GetLatestAnswerAsync(string channelId)
{
var activeCall = await GetActiveCallAsync(channelId);
return activeCall?.Answer;
}
/// <summary>
/// Writes a new ICE candidate entry for the specified channel and user.
/// </summary>
/// <param name="channelId">The channel the ICE candidate belongs to.</param>
/// <param name="username">The user who produced the ICE candidate.</param>
/// <param name="candidate">The ICE candidate string.</param>
/// <param name="sdpMid">The SDP media identifier for the candidate, if any.</param>
/// <param name="sdpMLineIndex">The SDP media line index for the candidate, if any.</param>
/// <param name="direction">
/// The signaling direction the candidate belongs to, such as offer or answer.
/// </param>
public async Task WriteIceCandidateAsync(
string channelId,
string username,
string candidate,
string? sdpMid,
int? sdpMLineIndex/*,
string direction*/)
{
await _db.Create("rtc_ice_candidates", new DBIceCandidate
{
ChannelId = channelId,
Username = username,
Candidate = candidate,
SdpMid = sdpMid,
SdpMLineIndex = sdpMLineIndex,
// Direction = direction,
CreatedAt = DateTime.UtcNow
});
}
/// <summary>
/// Gets all ICE candidates stored for the specified channel in creation order.
/// </summary>
/// <param name="channelId">The channel whose ICE candidates should be retrieved.</param>
/// <returns>
/// A list of ICE candidates for the channel ordered from oldest to newest.
/// </returns>
public async Task<List<DBIceCandidate>> GetIceCandidatesAsync(string channelId)
{
var candidates = await _db.Select<DBIceCandidate>("rtc_ice_candidates");
return candidates
.Where(x => x.ChannelId == channelId)
.OrderBy(x => x.CreatedAt)
.ToList();
}
/// <summary>
/// Gets ICE candidates for the specified channel that were created by other users
/// and match the requested signaling direction.
/// </summary>
/// <param name="channelId">The channel whose ICE candidates should be retrieved.</param>
/// <param name="username">The user to exclude from the results.</param>
/// <param name="direction">The signaling direction to match.</param>
/// <returns>
/// A list of matching ICE candidates ordered from oldest to newest.
/// </returns>
public async Task<List<DBIceCandidate>> GetIceCandidatesForOthersAsync(string channelId, string username, string direction)
{
var candidates = await _db.Select<DBIceCandidate>("rtc_ice_candidates");
return candidates
.Where(x => x.ChannelId == channelId && x.Username != username /*&& x.Direction == direction*/)
.OrderBy(x => x.CreatedAt)
.ToList();
}
/// <summary>
/// Leaves the active call for the specified channel. In the current implementation,
/// the call is only marked inactive when the offer user leaves.
/// </summary>
/// <param name="channelId">The channel whose call should be left.</param>
/// <param name="username">The user leaving the call.</param>
public async Task LeaveCallAsync(string channelId, string username)
{
var activeCalls = await _db.Select<DBActiveCall>("rtc_active_calls");
var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive);
if (activeCall is null)
return;
if (activeCall.OfferUser == username)
{
activeCall.IsActive = false;
activeCall.UpdatedAt = DateTime.UtcNow;
await _db.Merge<DBActiveCall, DBActiveCall>(activeCall);
}
}
/// <summary>
/// Gets all active call records that currently contain an offer.
/// </summary>
/// <returns>
/// A list of active calls with offers, ordered from newest to oldest.
/// </returns>
public async Task<List<DBActiveCall>> GetOffersAsync()
{
var activeCalls = await _db.Select<DBActiveCall>("rtc_active_calls");
return activeCalls
.Where(x => x.Offer is not null)
.OrderByDescending(x => x.UpdatedAt)
.ToList();
}
}

View File

@@ -0,0 +1,67 @@
using System.Collections.Concurrent;
namespace RelayServer.Services.Rtc;
public static class RtcChannelPresenceService
{
private static readonly ConcurrentDictionary<string, string> SessionToChannel = new();
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
public static void SetUser(string sessionId, string username)
{
SessionToUsername[sessionId] = username;
}
public static void JoinChannel(string sessionId, string channelId)
{
SessionToChannel[sessionId] = channelId;
}
public static void LeaveChannel(string sessionId)
{
SessionToChannel.TryRemove(sessionId, out _);
}
public static void RemoveSession(string sessionId)
{
SessionToChannel.TryRemove(sessionId, out _);
SessionToUsername.TryRemove(sessionId, out _);
}
public static IReadOnlyList<string> GetSessionsInChannel(string channelId)
{
return SessionToChannel
.Where(x => x.Value == channelId)
.Select(x => x.Key)
.ToList();
}
public static List<string> GetUsernamesInChannel(string channelId)
{
return GetUsersInChannel(channelId).ToList();
}
public static IReadOnlyList<string> GetUsersInChannel(string channelId)
{
var sessionIds = GetSessionsInChannel(channelId);
return sessionIds
.Where(id => SessionToUsername.ContainsKey(id))
.Select(id => SessionToUsername[id])
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static bool IsInChannel(string sessionId, string channelId)
{
return SessionToChannel.TryGetValue(sessionId, out var currentChannel) &&
string.Equals(currentChannel, channelId, StringComparison.Ordinal);
}
public static string? GetUsernameForSession(string sessionId)
{
return SessionToUsername.TryGetValue(sessionId, out var username)
? username
: null;
}
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using RelayShared.Rtc;
using WebSocketSharp.Server;
namespace RelayServer.Services.Rtc;
public static class RtcNotificationService
{
public static WebSocketServer? Server { get; set; }
public static void BroadcastToChannel(RtcNotificationMessage message)
{
if (Server is null)
return;
var host = Server.WebSocketServices["/"];
if (host is null)
return;
var json = JsonSerializer.Serialize(message);
var sessionIds = RtcChannelPresenceService.GetSessionsInChannel(message.ChannelId);
foreach (var sessionId in sessionIds)
{
host.Sessions.SendTo(json, sessionId);
}
}
}

93
RelayShared/.gitignore vendored Normal file
View File

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

9
RelayShared/Class1.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace RelayShared;
public class Class1
{
public Class1()
{
return;
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SurrealDb.Net" Version="0.9.0" />
</ItemGroup>
</Project>

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