on master help sign in
Migrate UI from Fabulous.MauiControls to Fabulous.Avalonia for desktop
commita14779fe0ce84402e771ea7f1f636caa96d2ba5a authoriko <mail@iko.soy> dateMon 13 Apr 2026, 08:40 (6 months ago) parentc2461e4
41 files changed+427−725
client/App.fs+224−173 client/Resources/Fonts/OpenSans-Regular.ttf → client/Assets/Fonts/OpenSans-Regular.ttf client/Resources/Fonts/OpenSans-Semibold.ttf → client/Assets/Fonts/OpenSans-Semibold.ttf client/Resources/Fonts/Unbounded-Black.ttf → client/Assets/Fonts/Unbounded-Black.ttf client/Resources/Fonts/Unbounded-Bold.ttf → client/Assets/Fonts/Unbounded-Bold.ttf client/Resources/Fonts/Unbounded-ExtraBold.ttf → client/Assets/Fonts/Unbounded-ExtraBold.ttf client/Resources/Fonts/Unbounded-ExtraLight.ttf → client/Assets/Fonts/Unbounded-ExtraLight.ttf client/Resources/Fonts/Unbounded-Light.ttf → client/Assets/Fonts/Unbounded-Light.ttf client/Resources/Fonts/Unbounded-Medium.ttf → client/Assets/Fonts/Unbounded-Medium.ttf client/Resources/Fonts/Unbounded-Regular.ttf → client/Assets/Fonts/Unbounded-Regular.ttf client/Resources/Fonts/Unbounded-SemiBold.ttf → client/Assets/Fonts/Unbounded-SemiBold.ttf client/Resources/Images/dotnet_bot.svg → client/Assets/Images/dotnet_bot.svg client/Resources/Images/logo.png → client/Assets/Images/logo.png client/Buttons.fs+33−45 client/Constants.fs+2−3 client/MauiProgram.fs−24 client/Platforms/Android/AndroidManifest.xml−6 client/Platforms/Android/MainActivity.fs−17 client/Platforms/Android/MainApplication.fs−10 client/Platforms/Android/Resources/values/colors.xml−6 client/Platforms/MacCatalyst/AppDelegate.fs−10 client/Platforms/MacCatalyst/Info.plist−35 client/Platforms/MacCatalyst/Program.fs−9 client/Platforms/Tizen/Main.fs−16 client/Platforms/Tizen/tizen-manifest.xml−15 client/Platforms/Windows/App.fs−9 client/Platforms/Windows/Main.fs−9 client/Platforms/Windows/Package.appxmanifest−43 client/Platforms/Windows/app.manifest−15 client/Platforms/iOS/AppDelegate.fs−10 client/Platforms/iOS/Info.plist−39 client/Platforms/iOS/Program.fs−9 client/Program.fs+18 client/Properties/launchSettings.json−8 client/Resources/AppIcon/appicon.svg−4 client/Resources/AppIcon/appiconfg.svg−8 client/Resources/Raw/AboutAssets.txt−15 client/Resources/Splash/splash.svg−8 client/Skrepka.fsproj+9−46 client/Store.fs+20−17 server.knot+121−116
client/App.fs+224−173
@@ -3,11 +3,12 @@ namespace Skrepka
33open System
44open System.IO
55open System.Text.Json
6+open Avalonia.Media
7+open Avalonia.Themes.Fluent
68open Fabulous
7−open Fabulous.Maui
8−open Microsoft.Maui.Graphics
9+open Fabulous.Avalonia
910
10−open type Fabulous.Maui.View
11+open type Fabulous.Avalonia.View
1112
1213module App =
1314
@@ -492,6 +493,28 @@ module App =
492493 with ex ->
493494 Error $"parse: {ex.Message}"
494495
496+ let private getClipboard () =
497+ match Avalonia.Application.Current with
498+ | null -> None
499+ | app ->
500+ match app.ApplicationLifetime with
501+ | :? Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime as desktop ->
502+ desktop.MainWindow
503+ |> Option.ofObj
504+ |> Option.bind (fun w -> w.Clipboard |> Option.ofObj)
505+ | _ -> None
506+
507+ let private getStorageProvider () =
508+ match Avalonia.Application.Current with
509+ | null -> None
510+ | app ->
511+ match app.ApplicationLifetime with
512+ | :? Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime as desktop ->
513+ desktop.MainWindow
514+ |> Option.ofObj
515+ |> Option.map (fun w -> w.StorageProvider)
516+ | _ -> None
517+
495518 let mapCmd cmdMsg =
496519 match cmdMsg with
497520 | CmdConnect(url, identity) ->
@@ -534,7 +557,6 @@ module App =
534557 for e in errors do
535558 log $"decrypt error: {e}"
536559
537− // Fire-and-forget: ack server + send delivery acks
538560 if not ackIds.IsEmpty || not events.IsEmpty then
539561 async {
540562 if not ackIds.IsEmpty then
@@ -590,8 +612,9 @@ module App =
590612
591613 | CmdCopyToClipboard text ->
592614 Cmd.ofEffect (fun _ ->
593− Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.Default.SetTextAsync(text)
594− |> ignore)
615+ match getClipboard () with
616+ | Some clipboard -> clipboard.SetTextAsync(text) |> ignore
617+ | None -> ())
595618
596619 | CmdLoadState ->
597620 Cmd.OfAsync.perform
@@ -610,245 +633,273 @@ module App =
610633
611634 | CmdPickPhoto ->
612635 Cmd.ofEffect (fun dispatch ->
613− Microsoft.Maui.ApplicationModel.MainThread.BeginInvokeOnMainThread(fun () ->
636+ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
614637 task {
615638 try
616− let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync()
617−
618− match results |> Seq.tryHead with
639+ match getStorageProvider () with
640+ | Some sp ->
641+ let opts = Avalonia.Platform.Storage.FilePickerOpenOptions(
642+ AllowMultiple = false,
643+ Title = "Pick Photo",
644+ FileTypeFilter = [
645+ Avalonia.Platform.Storage.FilePickerFileType("Images",
646+ Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ])
647+ ])
648+ let! files = sp.OpenFilePickerAsync(opts)
649+ match files |> Seq.tryHead with
650+ | None -> dispatch (PhotoPicked "")
651+ | Some file ->
652+ use! stream = file.OpenReadAsync()
653+ use ms = new MemoryStream()
654+ do! stream.CopyToAsync(ms)
655+ dispatch (PhotoPicked(Convert.ToBase64String(ms.ToArray())))
619656 | None -> dispatch (PhotoPicked "")
620− | Some file ->
621− use! stream = file.OpenReadAsync()
622− use ms = new MemoryStream()
623− do! stream.CopyToAsync(ms)
624− dispatch (PhotoPicked(Convert.ToBase64String(ms.ToArray())))
625657 with _ ->
626658 dispatch (PhotoPicked "")
627− }
628− |> ignore))
659+ } :> System.Threading.Tasks.Task)
660+ |> ignore)
629661
630662 | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile)
631663
632664 // ── View ──
633665
634666 let private viewErrorBanner (error: string option) =
635− VStack(spacing = 4.) {
667+ VStack(4.) {
636668 match error with
637669 | Some err ->
638− Label(err).textColor(Colors.Red).font (size = 12.)
670+ TextBlock(err).foreground(SolidColorBrush(Colors.Red)).fontSize(12.)
639671 Button("Dismiss", DismissError)
640672 | None -> ()
641673 }
642674
643675 let private viewSetup () =
644− ContentPage(
645− (VStack(spacing = 24.) {
646− Image("logo.png").margin(8, 0).maximumWidth (300)
676+ (VStack(24.) {
677+ Image("avares://Skrepka/Assets/Images/logo.png", Avalonia.Media.Stretch.Uniform)
678+ .margin(8., 0., 8., 0.)
679+ .maxWidth(300.)
647680
648− Label("Skrepka").font(size = 32., fontFamily = "UnboundedBold").centerTextHorizontal ()
649− BoxView(Colors.Transparent).height (50)
681+ TextBlock("Skrepka")
682+ .fontSize(32.)
683+ .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
684+ .fontWeight(FontWeight.Bold)
685+ .centerText()
650686
651− button "Next" GenIdentity
652− })
653− .padding(30.)
654− .centerVertical ()
655− )
687+ TextBlock("").height(50.)
688+
689+ button "Next" GenIdentity
690+ })
691+ .margin(30.)
692+ .centerVertical()
656693
657694 let private viewSettings model =
658− ContentPage(
659− ScrollView(
660− (VStack(spacing = 16.) {
661− Label("Settings").font(size = 24.).centerTextHorizontal ()
695+ ScrollViewer(
696+ (VStack(16.) {
697+ TextBlock("Settings")
698+ .fontSize(24.)
699+ .centerText()
662700
663− match model.Auth with
664− | Identified(id, conn) ->
665− Label("Your Public Key:")
701+ match model.Auth with
702+ | Identified(id, conn) ->
703+ TextBlock("Your Public Key:")
666704
667− Label(truncKey id.PubKeyHex).font (size = 14.)
705+ TextBlock(truncKey id.PubKeyHex).fontSize(14.)
668706
669− Button("Copy Public Key", CopyPubKey)
707+ Button("Copy Public Key", CopyPubKey)
670708
671− Label("Profile:").font (size = 18.)
709+ TextBlock("Profile:").fontSize(18.)
672710
673− let displayName, bio, photo =
674− match model.Profile with
675− | Some p -> p.DisplayName, p.Bio, p.PhotoBase64
676− | None -> "", "", ""
711+ let displayName, bio, photo =
712+ match model.Profile with
713+ | Some p -> p.DisplayName, p.Bio, p.PhotoBase64
714+ | None -> "", "", ""
677715
678− if displayName <> "" then
679− Label($"Name: {displayName}").font (size = 14.)
716+ if displayName <> "" then
717+ TextBlock($"Name: {displayName}").fontSize(14.)
680718
681− if bio <> "" then
682− Label($"Bio: {bio}").font(size = 14.).textColor (Colors.DimGray)
719+ if bio <> "" then
720+ TextBlock($"Bio: {bio}")
721+ .fontSize(14.)
722+ .foreground(SolidColorBrush(Colors.DimGray))
683723
684− Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
724+ Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
685725
686− Label("Server:")
726+ TextBlock("Server:")
687727
688− match conn with
689− | Online _ -> Label(model.ServerUrl).font (size = 14.)
690− | _ -> Entry(model.ServerUrl, SetServerUrl)
728+ match conn with
729+ | Online _ -> TextBlock(model.ServerUrl).fontSize(14.)
730+ | _ -> TextBox(model.ServerUrl, SetServerUrl)
691731
692− viewErrorBanner model.Error
732+ viewErrorBanner model.Error
693733
694− match conn with
695− | Offline -> Button("Connect", DoConnect)
696− | Connecting -> Label("Connecting...").centerTextHorizontal ()
697− | Online _ ->
698− Button("Disconnect", DoDisconnect)
699− Button("Go to Conversations", SetPage Conversations)
700− | NoIdentity -> ()
701− })
702− .padding (20.)
703− )
734+ match conn with
735+ | Offline -> Button("Connect", DoConnect)
736+ | Connecting -> TextBlock("Connecting...").centerText()
737+ | Online _ ->
738+ Button("Disconnect", DoDisconnect)
739+ Button("Go to Conversations", SetPage Conversations)
740+ | NoIdentity -> ()
741+ })
742+ .margin(20.)
704743 )
705744
706745 let private viewConversations model =
707− ContentPage(
708− ScrollView(
709− (VStack(spacing = 8.) {
710− HStack(spacing = 8.) {
711− Label("Conversations").font (size = 24.)
712−
713− Button("+", SetPage(AddContact("", "")))
714− Button("Settings", SetPage Settings)
715− }
716−
717− let pubPrefix =
718− match model.Auth with
719− | Identified(id, _) -> id.PubKeyHex.[..7]
720− | _ -> "?"
721−
722− let pollInfo =
723− if model.PollStatus <> "" then
724− $" | {model.PollStatus}"
725− else
726− ""
746+ ScrollViewer(
747+ (VStack(8.) {
748+ HStack(8.) {
749+ TextBlock("Conversations").fontSize(24.)
727750
728− Label($"{connStatusLabel model.Auth} [{pubPrefix}]{pollInfo}")
729− .font(size = 10.)
730− .textColor (Colors.DimGray)
751+ Button("+", SetPage(AddContact("", "")))
752+ Button("Settings", SetPage Settings)
753+ }
731754
732− viewErrorBanner model.Error
755+ let pubPrefix =
756+ match model.Auth with
757+ | Identified(id, _) -> id.PubKeyHex.[..7]
758+ | _ -> "?"
733759
734− if model.Contacts.IsEmpty then
735− Label("No contacts yet. Tap + to add one.").centerTextHorizontal().textColor (Colors.Gray)
760+ let pollInfo =
761+ if model.PollStatus <> "" then
762+ $" | {model.PollStatus}"
763+ else
764+ ""
736765
737− for c in model.Contacts.Values do
738− let preview =
739− messagesFor c.Pubkey model.Messages
740− |> List.tryLast
741− |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
742− |> Option.defaultValue ""
766+ TextBlock($"{connStatusLabel model.Auth} [{pubPrefix}]{pollInfo}")
767+ .fontSize(10.)
768+ .foreground(SolidColorBrush(Colors.DimGray))
743769
744− Button($"{contactName model.Contacts c.Pubkey}\n{preview}", SetPage(Chat(c.Pubkey, "")))
745− })
746− .padding (20.)
747− )
770+ viewErrorBanner model.Error
771+
772+ if model.Contacts.IsEmpty then
773+ TextBlock("No contacts yet. Tap + to add one.")
774+ .centerText()
775+ .foreground(SolidColorBrush(Colors.Gray))
776+
777+ for c in model.Contacts.Values do
778+ let preview =
779+ messagesFor c.Pubkey model.Messages
780+ |> List.tryLast
781+ |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
782+ |> Option.defaultValue ""
783+
784+ Button($"{contactName model.Contacts c.Pubkey}\n{preview}", SetPage(Chat(c.Pubkey, "")))
785+ })
786+ .margin(20.)
748787 )
749788
750789 let private viewChat model pk compose =
751790 let name = contactName model.Contacts pk
752791 let msgs = messagesFor pk model.Messages
753792
754− ContentPage(
755− (VStack(spacing = 8.) {
756− HStack(spacing = 8.) {
757− Button("< Back", SetPage Conversations)
758−
759− Label(name).font(size = 20.).centerTextHorizontal ()
760− }
793+ (VStack(8.) {
794+ HStack(8.) {
795+ Button("< Back", SetPage Conversations)
761796
762− viewErrorBanner model.Error
797+ TextBlock(name).fontSize(20.).centerVertical()
798+ }
763799
764− if msgs.IsEmpty then
765− Label("No messages yet").padding(8.).font (size = 14.)
766− else
767− for m in msgs do
768− let prefix, tick =
769− match m.Direction with
770− | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
771− | Outgoing DeliveryStatus.Sent -> "You: ", ""
772− | Incoming -> "", ""
800+ viewErrorBanner model.Error
773801
774− Label($"{prefix}{m.Body}{tick}").padding(8.).font (size = 14.)
802+ ScrollViewer(
803+ VStack(4.) {
804+ if msgs.IsEmpty then
805+ TextBlock("No messages yet").padding(8.).fontSize(14.)
806+ else
807+ for m in msgs do
808+ let prefix, tick =
809+ match m.Direction with
810+ | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
811+ | Outgoing DeliveryStatus.Sent -> "You: ", ""
812+ | Incoming -> "", ""
813+
814+ TextBlock($"{prefix}{m.Body}{tick}").padding(8.).fontSize(14.)
815+ }
816+ )
775817
776− HStack(spacing = 8.) {
777− Entry(compose, fun text -> SetPage(Chat(pk, text))).placeholder ("Message...")
818+ HStack(8.) {
819+ TextBox(compose, fun text -> SetPage(Chat(pk, text)))
820+ .watermark("Message...")
821+ .horizontalAlignment(Avalonia.Layout.HorizontalAlignment.Stretch)
778822
779− Button("Send", DoSend)
780− }
781− })
782− .padding (12.)
783− )
823+ Button("Send", DoSend)
824+ }
825+ })
826+ .margin(12.)
784827
785828 let private viewAddContact model cpk cnn =
786− ContentPage(
787− (VStack(spacing = 16.) {
788− HStack(spacing = 8.) {
789− Button("< Back", SetPage Conversations)
829+ (VStack(16.) {
830+ HStack(8.) {
831+ Button("< Back", SetPage Conversations)
790832
791− Label("Add Contact").font (size = 20.)
792− }
833+ TextBlock("Add Contact").fontSize(20.)
834+ }
793835
794− viewErrorBanner model.Error
836+ viewErrorBanner model.Error
795837
796− Label("Public Key:")
797− Entry(cpk, fun text -> SetPage(AddContact(text, cnn))).placeholder ("sampel-palnet-...")
838+ TextBlock("Public Key:")
839+ TextBox(cpk, fun text -> SetPage(AddContact(text, cnn))).watermark("sampel-palnet-...")
798840
799− Label("Nickname:")
800− Entry(cnn, fun text -> SetPage(AddContact(cpk, text)))
841+ TextBlock("Nickname:")
842+ TextBox(cnn, fun text -> SetPage(AddContact(cpk, text)))
801843
802− Button("Save Contact", DoSaveContact).centerHorizontal ()
803− })
804− .padding (20.)
805− )
844+ Button("Save Contact", DoSaveContact).centerHorizontal()
845+ })
846+ .margin(20.)
806847
807848 let private viewEditProfile model displayName bio photo =
808− ContentPage(
809− ScrollView(
810− (VStack(spacing = 16.) {
811− HStack(spacing = 8.) {
812− Button("< Back", SetPage Settings)
813− Label("Edit Profile").font (size = 20.)
814− }
849+ ScrollViewer(
850+ (VStack(16.) {
851+ HStack(8.) {
852+ Button("< Back", SetPage Settings)
853+ TextBlock("Edit Profile").fontSize(20.)
854+ }
815855
816− viewErrorBanner model.Error
856+ viewErrorBanner model.Error
817857
818− if photo <> "" then
819− Image(new MemoryStream(Convert.FromBase64String(photo)) :> Stream)
820− .width(120.)
821− .height(120.)
822− .centerHorizontal ()
823− else
824− Label("No Photo").font(size = 16.).centerTextHorizontal().textColor (Colors.Gray)
858+ if photo <> "" then
859+ Image(
860+ new Avalonia.Media.Imaging.Bitmap(new MemoryStream(Convert.FromBase64String(photo))),
861+ Avalonia.Media.Stretch.Uniform)
862+ .width(120.)
863+ .height(120.)
864+ .centerHorizontal()
865+ else
866+ TextBlock("No Photo")
867+ .fontSize(16.)
868+ .centerText()
869+ .foreground(SolidColorBrush(Colors.Gray))
825870
826− Button("Change Photo", DoPickPhoto).centerHorizontal ()
871+ Button("Change Photo", DoPickPhoto).centerHorizontal()
827872
828− Label("Display Name:")
829− Entry(displayName, fun text -> SetPage(EditProfile(text, bio, photo))).placeholder ("Your name")
873+ TextBlock("Display Name:")
874+ TextBox(displayName, fun text -> SetPage(EditProfile(text, bio, photo))).watermark("Your name")
830875
831− Label("Bio:")
876+ TextBlock("Bio:")
832877
833− Entry(bio, fun text -> SetPage(EditProfile(displayName, text, photo)))
834− .placeholder ("Tell something about yourself")
878+ TextBox(bio, fun text -> SetPage(EditProfile(displayName, text, photo)))
879+ .watermark("Tell something about yourself")
835880
836− Button("Save Profile", DoSaveProfile).centerHorizontal ()
837− })
838− .padding (20.)
839− )
881+ Button("Save Profile", DoSaveProfile).centerHorizontal()
882+ })
883+ .margin(20.)
840884 )
841885
842886 let view model =
843− let page =
887+ let content =
844888 match model.Page with
845− | Setup -> viewSetup ()
846− | Settings -> viewSettings model
847− | Conversations -> viewConversations model
848− | Chat(pk, compose) -> viewChat model pk compose
849− | AddContact(pk, nn) -> viewAddContact model pk nn
850− | EditProfile(displayName, bio, photo) -> viewEditProfile model displayName bio photo
851−
852− Application() { Window(page) }
889+ | Setup -> AnyView(viewSetup ())
890+ | Settings -> AnyView(viewSettings model)
891+ | Conversations -> AnyView(viewConversations model)
892+ | Chat(pk, compose) -> AnyView(viewChat model pk compose)
893+ | AddContact(pk, nn) -> AnyView(viewAddContact model pk nn)
894+ | EditProfile(displayName, bio, photo) -> AnyView(viewEditProfile model displayName bio photo)
895+
896+ DesktopApplication() {
897+ Window(content)
898+ .title("Skrepka")
899+ .width(450.)
900+ .height(750.)
901+ }
853902
854903 let program = Program.statefulWithCmdMsg init update mapCmd |> Program.withView view
904+
905+ let create () = FabulousAppBuilder.Configure(FluentTheme, program)
client/Resources/Fonts/OpenSans-Regular.ttf → client/Assets/Fonts/OpenSans-Regular.ttfrenamed

Binary file not shown.

client/Resources/Fonts/OpenSans-Semibold.ttf → client/Assets/Fonts/OpenSans-Semibold.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-Black.ttf → client/Assets/Fonts/Unbounded-Black.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-Bold.ttf → client/Assets/Fonts/Unbounded-Bold.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-ExtraBold.ttf → client/Assets/Fonts/Unbounded-ExtraBold.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-ExtraLight.ttf → client/Assets/Fonts/Unbounded-ExtraLight.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-Light.ttf → client/Assets/Fonts/Unbounded-Light.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-Medium.ttf → client/Assets/Fonts/Unbounded-Medium.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-Regular.ttf → client/Assets/Fonts/Unbounded-Regular.ttfrenamed

Binary file not shown.

client/Resources/Fonts/Unbounded-SemiBold.ttf → client/Assets/Fonts/Unbounded-SemiBold.ttfrenamed

Binary file not shown.

client/Resources/Images/dotnet_bot.svg → client/Assets/Images/dotnet_bot.svgrenamed

The content is the same.

client/Resources/Images/logo.png → client/Assets/Images/logo.pngrenamed

Binary file not shown.

client/Buttons.fs+33−45
@@ -1,64 +1,52 @@
11namespace Skrepka
22
3−
4−open System
5−open System.IO
6−open System.Text.Json
7−open Fabulous
8−open Fabulous.Maui
9−open Microsoft.Maui.Graphics
3+open Avalonia.Media
104open Fabulous
11−open Fabulous.Maui
12−open Microsoft.Maui.Graphics
5+open Fabulous.Avalonia
136
14−open type Fabulous.Maui.View
15−open type Fabulous.Maui.VisualElementAnimationModifiers
16−open type Microsoft.Maui.Controls.ViewExtensions
7+open type Fabulous.Avalonia.View
178
189module Buttons =
19− open Microsoft.Maui
20−
21− let private animationDuration = 100u
22− let private animationEasing = Easing.CubicOut
2310
2411 let button (text: string) (msg: 'msg) =
25−
26− let shadowRef = ViewRef<Microsoft.Maui.Controls.Shapes.Rectangle>()
12+ let shadowRef = ViewRef<Avalonia.Controls.Shapes.Rectangle>()
2713
2814 let animate pressed =
2915 match shadowRef.TryValue with
3016 | None -> ()
3117 | Some c ->
32− if pressed then
33− c.TranslateToAsync(8., 8., animationDuration, animationEasing) |> ignore
34− else
35− c.TranslateToAsync(12., 12., animationDuration, animationEasing) |> ignore
18+ let offset = if pressed then 8. else 12.
19+ c.RenderTransform <- Avalonia.Media.TranslateTransform(offset, offset)
3620
3721 Component(text) {
38−
3922 Grid() {
40−
41− Rectangle().reference(shadowRef).background(Colors.Black).translationX(12.).translationY (12.)
42− Rectangle().background (Constants.accentColor)
43−
44−
45− Label(text).textColor(Colors.White).font(fontFamily = "UnboundedBold", size = 24.).center().margin (8)
46−
47−
4823 Rectangle()
49− .strokeThickness(4.)
50− .stroke(Colors.Black)
51− .margin(-3)
52− .fillHorizontal()
53− .fillVertical()
54− .gestureRecognizers () {
55− PointerGestureRecognizer()
56− .onPointerPressed(fun _ -> animate true)
57− .onPointerReleased(fun _ -> animate false)
58− .onPointerExited (fun _ -> animate false)
59−
60− TapGestureRecognizer(msg)
61− }
62−
24+ .reference(shadowRef)
25+ .fill(SolidColorBrush(Colors.Black))
26+ .renderTransform(TranslateTransform(12., 12.))
27+
28+ Rectangle().fill(SolidColorBrush(Constants.accentColor))
29+
30+ TextBlock(text)
31+ .foreground(SolidColorBrush(Colors.White))
32+ .fontSize(24.)
33+ .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
34+ .fontWeight(FontWeight.Bold)
35+ .center()
36+ .margin(8.)
37+
38+ Button(msg,
39+ Rectangle()
40+ .strokeThickness(4.)
41+ .stroke(SolidColorBrush(Colors.Black))
42+ .margin(-3.))
43+ .onPointerPressed(fun _ -> animate true)
44+ .onPointerReleased(fun _ -> animate false)
45+ .onPointerExited(fun _ -> animate false)
46+ .background(Brushes.Transparent)
47+ .borderThickness(0.)
48+ .padding(0.)
49+ .horizontalAlignment(Avalonia.Layout.HorizontalAlignment.Stretch)
50+ .verticalAlignment(Avalonia.Layout.VerticalAlignment.Stretch)
6351 }
6452 }
client/Constants.fs+2−3
@@ -1,7 +1,6 @@
11namespace Skrepka
22
3−open Fabulous.Maui
4−open Microsoft.Maui.Graphics
3+open Avalonia.Media
54
65module Constants =
7− let accentColor = Color.FromRgb(98, 54, 255)
6+ let accentColor = Color.FromRgb(98uy, 54uy, 255uy)
client/MauiProgram.fsdeleted−24
@@ -1,24 +0,0 @@
1−namespace Skrepka
2−
3−open Microsoft.Maui.Hosting
4−open Fabulous.Maui
5−
6−type MauiProgram =
7− static member CreateMauiApp() =
8− MauiApp
9− .CreateBuilder()
10− .UseFabulousApp(App.program)
11− .ConfigureFonts(fun fonts ->
12− fonts
13− .AddFont("OpenSans-Regular.ttf", "OpenSansRegular")
14− .AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold")
15− .AddFont("Unbounded-SemiBold.ttf", "UnboundedSemiBold")
16− .AddFont("Unbounded-Black.ttf", "UnboundedBlack")
17− .AddFont("Unbounded-Bold.ttf", "UnboundedBold")
18− .AddFont("Unbounded-ExtraBold.ttf", "UnboundedExtraBold")
19− .AddFont("Unbounded-ExtraLight.ttf", "UnboundedExtraLight")
20− .AddFont("Unbounded-Light.ttf", "UnboundedLight")
21− .AddFont("Unbounded-Medium.ttf", "UnboundedMedium")
22− .AddFont("Unbounded-Regular.ttf", "UnboundedRegular")
23− |> ignore)
24− .Build()
client/Platforms/Android/AndroidManifest.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3− <application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
4− <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5− <uses-permission android:name="android.permission.INTERNET" />
6−</manifest>
client/Platforms/Android/MainActivity.fsdeleted−17
@@ -1,17 +0,0 @@
1−namespace Skrepka
2−
3−open Android.App
4−open Android.Content.PM
5−open Microsoft.Maui
6−
7−[<Activity(Theme = "@style/Maui.SplashTheme",
8− MainLauncher = true,
9− ConfigurationChanges =
10− (ConfigChanges.ScreenSize
11− ||| ConfigChanges.Orientation
12− ||| ConfigChanges.UiMode
13− ||| ConfigChanges.ScreenLayout
14− ||| ConfigChanges.SmallestScreenSize
15− ||| ConfigChanges.Density))>]
16−type MainActivity() =
17− inherit MauiAppCompatActivity()
client/Platforms/Android/MainApplication.fsdeleted−10
@@ -1,10 +0,0 @@
1−namespace Skrepka
2−
3−open Android.App
4−open Microsoft.Maui
5−
6−[<Application>]
7−type MainApplication(handle, ownership) =
8− inherit MauiApplication(handle, ownership)
9−
10− override _.CreateMauiApp() = MauiProgram.CreateMauiApp()
client/Platforms/Android/Resources/values/colors.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<resources>
3− <color name="colorPrimary">#512BD4</color>
4− <color name="colorPrimaryDark">#2B0B98</color>
5− <color name="colorAccent">#2B0B98</color>
6−</resources>
client/Platforms/MacCatalyst/AppDelegate.fsdeleted−10
@@ -1,10 +0,0 @@
1−namespace Skrepka
2−
3−open Foundation
4−open Microsoft.Maui
5−
6−[<Register("AppDelegate")>]
7−type AppDelegate() =
8− inherit MauiUIApplicationDelegate()
9−
10− override this.CreateMauiApp() = MauiProgram.CreateMauiApp()
client/Platforms/MacCatalyst/Info.plistdeleted−35
@@ -1,35 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3−<plist version="1.0">
4−<dict>
5− <key>UIDeviceFamily</key>
6− <array>
7− <integer>1</integer>
8− <integer>2</integer>
9− </array>
10− <key>UIRequiredDeviceCapabilities</key>
11− <array>
12− <string>arm64</string>
13− </array>
14− <key>UISupportedInterfaceOrientations</key>
15− <array>
16− <string>UIInterfaceOrientationPortrait</string>
17− <string>UIInterfaceOrientationLandscapeLeft</string>
18− <string>UIInterfaceOrientationLandscapeRight</string>
19− </array>
20− <key>UISupportedInterfaceOrientations~ipad</key>
21− <array>
22− <string>UIInterfaceOrientationPortrait</string>
23− <string>UIInterfaceOrientationPortraitUpsideDown</string>
24− <string>UIInterfaceOrientationLandscapeLeft</string>
25− <string>UIInterfaceOrientationLandscapeRight</string>
26− </array>
27− <key>XSAppIconAssets</key>
28− <string>Assets.xcassets/appicon.appiconset</string>
29− <key>NSAppTransportSecurity</key>
30− <dict>
31− <key>NSAllowsLocalNetworking</key>
32− <true/>
33− </dict>
34−</dict>
35−</plist>
client/Platforms/MacCatalyst/Program.fsdeleted−9
@@ -1,9 +0,0 @@
1−namespace Skrepka
2−
3−open UIKit
4−
5−module Program =
6− [<EntryPoint>]
7− let main args =
8− UIApplication.Main(args, null, typeof<AppDelegate>)
9− 0
client/Platforms/Tizen/Main.fsdeleted−16
@@ -1,16 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open Microsoft.Maui
5−open Microsoft.Maui.Hosting
6−
7−type Program() =
8− inherit MauiApplication()
9−
10− override this.CreateMauiApp() = MauiProgram.CreateMauiApp()
11−
12−module Program =
13− [<EntryPoint>]
14− let main args =
15− let app = Program()
16− app.Run(args)
client/Platforms/Tizen/tizen-manifest.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<manifest package="com.companyname.HelloWorld" version="1.0.0" api-version="7" xmlns="http://tizen.org/ns/packages">
3− <profile name="common" />
4− <ui-application appid="com.companyname.HelloWorld" exec="HelloWorld.dll" multiple="false" nodisplay="false" taskmanage="true" type="dotnet" launch_mode="single">
5− <label>HelloWorld</label>
6− <icon>appicon.xhigh.png</icon>
7− <metadata key="http://tizen.org/metadata/prefer_dotnet_aot" value="true" />
8− </ui-application>
9− <shortcut-list />
10− <privileges>
11− <privilege>http://tizen.org/privilege/internet</privilege>
12− </privileges>
13− <dependencies />
14− <provides-appdefined-privileges />
15−</manifest>
client/Platforms/Windows/App.fsdeleted−9
@@ -1,9 +0,0 @@
1−namespace Skrepka.WinUI
2−
3−/// <summary>
4−/// Provides application-specific behavior to supplement the default Application class.
5−/// </summary>
6−type App() =
7− inherit FSharp.Maui.WinUICompat.App()
8−
9− override this.CreateMauiApp() = Skrepka.MauiProgram.CreateMauiApp()
client/Platforms/Windows/Main.fsdeleted−9
@@ -1,9 +0,0 @@
1−namespace Skrepka.WinUI
2−
3−open System
4−
5−module Program =
6− [<EntryPoint; STAThread>]
7− let main args =
8− do FSharp.Maui.WinUICompat.Program.Main(args, typeof<Skrepka.WinUI.App>)
9− 0
client/Platforms/Windows/Package.appxmanifestdeleted−43
@@ -1,43 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<Package
3− xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
4− xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
5− xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
6− IgnorableNamespaces="uap rescap">
7−
8− <Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
9−
10− <Properties>
11− <DisplayName>$placeholder$</DisplayName>
12− <PublisherDisplayName>User Name</PublisherDisplayName>
13− <Logo>$placeholder$.png</Logo>
14− </Properties>
15−
16− <Dependencies>
17− <TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
18− <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
19− </Dependencies>
20−
21− <Resources>
22− <Resource Language="x-generate" />
23− </Resources>
24−
25− <Applications>
26− <Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
27− <uap:VisualElements
28− DisplayName="$placeholder$"
29− Description="$placeholder$"
30− Square150x150Logo="$placeholder$.png"
31− Square44x44Logo="$placeholder$.png"
32− BackgroundColor="transparent">
33− <uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
34− <uap:SplashScreen Image="$placeholder$.png" />
35− </uap:VisualElements>
36− </Application>
37− </Applications>
38−
39− <Capabilities>
40− <rescap:Capability Name="runFullTrust" />
41− </Capabilities>
42−
43−</Package>
client/Platforms/Windows/app.manifestdeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
3− <assemblyIdentity version="1.0.0.0" name="HelloWorld.WinUI.app"/>
4−
5− <application xmlns="urn:schemas-microsoft-com:asm.v3">
6− <windowsSettings>
7− <!-- The combination of below two tags have the following effect:
8− 1) Per-Monitor for >= Windows 10 Anniversary Update
9− 2) System < Windows 10 Anniversary Update
10− -->
11− <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
12− <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
13− </windowsSettings>
14− </application>
15−</assembly>
client/Platforms/iOS/AppDelegate.fsdeleted−10
@@ -1,10 +0,0 @@
1−namespace Skrepka
2−
3−open Foundation
4−open Microsoft.Maui
5−
6−[<Register("AppDelegate")>]
7−type AppDelegate() =
8− inherit MauiUIApplicationDelegate()
9−
10− override _.CreateMauiApp() = MauiProgram.CreateMauiApp()
client/Platforms/iOS/Info.plistdeleted−39
@@ -1,39 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3−<plist version="1.0">
4−<dict>
5− <key>LSRequiresIPhoneOS</key>
6− <true/>
7− <key>UIDeviceFamily</key>
8− <array>
9− <integer>1</integer>
10− <integer>2</integer>
11− </array>
12− <key>UIRequiredDeviceCapabilities</key>
13− <array>
14− <string>arm64</string>
15− </array>
16− <key>UISupportedInterfaceOrientations</key>
17− <array>
18− <string>UIInterfaceOrientationPortrait</string>
19− <string>UIInterfaceOrientationLandscapeLeft</string>
20− <string>UIInterfaceOrientationLandscapeRight</string>
21− </array>
22− <key>UISupportedInterfaceOrientations~ipad</key>
23− <array>
24− <string>UIInterfaceOrientationPortrait</string>
25− <string>UIInterfaceOrientationPortraitUpsideDown</string>
26− <string>UIInterfaceOrientationLandscapeLeft</string>
27− <string>UIInterfaceOrientationLandscapeRight</string>
28− </array>
29− <key>NSPhotoLibraryUsageDescription</key>
30− <string>This app needs access to your photo library to set a profile photo.</string>
31− <key>XSAppIconAssets</key>
32− <string>Assets.xcassets/appicon.appiconset</string>
33− <key>NSAppTransportSecurity</key>
34− <dict>
35− <key>NSAllowsLocalNetworking</key>
36− <true/>
37− </dict>
38−</dict>
39−</plist>
client/Platforms/iOS/Program.fsdeleted−9
@@ -1,9 +0,0 @@
1−namespace Skrepka
2−
3−open UIKit
4−
5−module Program =
6− [<EntryPoint>]
7− let main args =
8− UIApplication.Main(args, null, typeof<AppDelegate>)
9− 0
client/Program.fsnew file+18
@@ -0,0 +1,18 @@
1+namespace Skrepka
2+
3+open System
4+open Avalonia
5+open Fabulous.Avalonia
6+
7+module Program =
8+
9+ [<CompiledName "BuildAvaloniaApp">]
10+ let buildAvaloniaApp () =
11+ App
12+ .create()
13+ .UsePlatformDetect()
14+ .LogToTrace(areas = Array.empty)
15+
16+ [<EntryPoint; STAThread>]
17+ let main argv =
18+ buildAvaloniaApp().StartWithClassicDesktopLifetime(argv)
client/Properties/launchSettings.jsondeleted−8
@@ -1,8 +0,0 @@
1−{
2− "profiles": {
3− "Windows Machine": {
4− "commandName": "MsixPackage",
5− "nativeDebugging": false
6− }
7− }
8−}
client/Resources/AppIcon/appicon.svgdeleted−4
@@ -1,4 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2−<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
3− <rect x="0" y="0" width="456" height="456" fill="#512BD4" />
4−</svg>
client/Resources/AppIcon/appiconfg.svgdeleted−8
@@ -1,8 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2−<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3−<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;">
4− <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" />
5− <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" />
6− <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" />
7− <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" />
8−</svg>
client/Resources/Raw/AboutAssets.txtdeleted−15
@@ -1,15 +0,0 @@
1−Any raw assets you want to be deployed with your application can be placed in
2−this directory (and child directories). Deployment of the asset to your application
3−is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
4−
5− <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
6−
7−These files will be deployed with you package and will be accessible using Essentials:
8−
9− async Task LoadMauiAsset()
10− {
11− using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
12− using var reader = new StreamReader(stream);
13−
14− var contents = reader.ReadToEnd();
15− }
client/Resources/Splash/splash.svgdeleted−8
@@ -1,8 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2−<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3−<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;">
4− <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" />
5− <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" />
6− <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" />
7− <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" />
8−</svg>
client/Skrepka.fsproj+9−46
@@ -1,23 +1,10 @@
11<Project Sdk="Microsoft.NET.Sdk">
22
33 <PropertyGroup>
4− <TargetFrameworks>net10.0-maccatalyst;net10.0-ios</TargetFrameworks>
54 <OutputType>Exe</OutputType>
5+ <TargetFramework>net10.0</TargetFramework>
66 <RootNamespace>Skrepka</RootNamespace>
7− <UseMaui>true</UseMaui>
8− <SingleProject>true</SingleProject>
9− <EnableDefaultItems>false</EnableDefaultItems>
107 <DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
11−
12− <ApplicationTitle>Skrepka</ApplicationTitle>
13− <ApplicationId>com.skrepka.client</ApplicationId>
14− <ApplicationIdGuid>387564D6-02F6-4861-887C-CB527912117B</ApplicationIdGuid>
15− <ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
16− <ApplicationVersion>1</ApplicationVersion>
17−
18− <SupportedOSPlatformVersion Condition="$(TargetPlatformIdentifier) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
19− <SupportedOSPlatformVersion Condition="$(TargetPlatformIdentifier) == 'ios'">15.0</SupportedOSPlatformVersion>
20− <ValidateXcodeVersion>false</ValidateXcodeVersion>
218 </PropertyGroup>
229
2310 <ItemGroup>
@@ -28,46 +15,22 @@
2815 <Compile Include="Store.fs" />
2916 <Compile Include="Buttons.fs" />
3017 <Compile Include="App.fs" />
31− <Compile Include="MauiProgram.fs" />
18+ <Compile Include="Program.fs" />
3219 </ItemGroup>
3320
3421 <ItemGroup>
35− <MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
36− <MauiFont Include="Resources\Fonts\*" />
37− <MauiImage Include="Resources\Images\*" />
38− <MauiAsset Include="Resources\Raw\*" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
39− <MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
40− </ItemGroup>
41−
42− <ItemGroup Condition="$(TargetPlatformIdentifier) == 'ios'">
43− <None Include="$(iOSProjectFolder)Info.plist" LogicalName="Info.plist" />
44− <Compile Include="$(iOSProjectFolder)AppDelegate.fs" />
45− <Compile Include="$(iOSProjectFolder)Program.fs" />
46− </ItemGroup>
47−
48− <ItemGroup Condition="$(TargetPlatformIdentifier) == 'ios' And $(RuntimeIdentifier.StartsWith('iossimulator'))">
49− <NativeReference Include="native/iossimulator-arm64/libsodium.a">
50− <Kind>Static</Kind>
51− <ForceLoad>true</ForceLoad>
52− </NativeReference>
53− </ItemGroup>
54−
55− <ItemGroup Condition="$(TargetPlatformIdentifier) == 'maccatalyst'">
56− <None Include="$(MacCatalystProjectFolder)Info.plist" LogicalName="Info.plist" />
57− <Compile Include="$(MacCatalystProjectFolder)AppDelegate.fs" />
58− <Compile Include="$(MacCatalystProjectFolder)Program.fs" />
59− <NativeReference Include="native/maccatalyst-arm64/libsodium.a">
60− <Kind>Static</Kind>
61− <ForceLoad>true</ForceLoad>
62− </NativeReference>
22+ <AvaloniaResource Include="Assets\Fonts\*" />
23+ <AvaloniaResource Include="Assets\Images\*" />
6324 </ItemGroup>
6425
6526 <ItemGroup>
66− <PackageReference Include="Fabulous.MauiControls" Version="9.0.0-pre9" />
67− <PackageReference Include="Microsoft.Maui.Controls" Version="10.0.0" />
27+ <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
28+ <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
29+ <PackageReference Include="FSharp.Core" Version="9.0.100" />
30+ <PackageReference Include="Fabulous" Version="3.0.0-pre22" />
31+ <PackageReference Include="Fabulous.Avalonia" Version="3.0.0-pre13" />
6832 <PackageReference Include="LiteDB" Version="5.0.21" />
6933 <PackageReference Include="Sodium.Core" Version="1.4.0" />
70− <PackageReference Include="FSharp.Core" Version="9.0.100" />
7134 </ItemGroup>
7235
7336</Project>
client/Store.fs+20−17
@@ -3,7 +3,6 @@ namespace Skrepka
33open System
44open System.IO
55open LiteDB
6−open Microsoft.Maui.Storage
76
87module Store =
98
@@ -74,32 +73,36 @@ module Store =
7473 { Id = m.Id; ConversationId = convId; Body = m.Body; TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
7574 IsOutgoing = isOutgoing; Status = status }
7675
76+ let private appDataDir =
77+ let dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Skrepka")
78+ Directory.CreateDirectory(dir) |> ignore
79+ dir
80+
7781 let private openDb () =
78− new LiteDatabase(Path.Combine(FileSystem.AppDataDirectory, "skrepka.db"))
82+ new LiteDatabase(Path.Combine(appDataDir, "skrepka.db"))
83+
84+ let private identityPath = Path.Combine(appDataDir, "identity.key")
7985
8086 let loadIdentity () =
8187 async {
82− let! privKeyB64 =
83− async {
84− try
85− return! SecureStorage.Default.GetAsync("identity_privkey") |> Async.AwaitTask
86− with _ ->
87− return Preferences.Default.Get<string>("identity_privkey", null)
88− }
89−
9088 return
91− privKeyB64
92− |> Option.ofObj
93− |> Option.map (Convert.FromBase64String >> Crypto.identityFromPrivKey)
89+ try
90+ if File.Exists(identityPath) then
91+ let b64 = File.ReadAllText(identityPath)
92+ b64
93+ |> Option.ofObj
94+ |> Option.filter (fun s -> s <> "")
95+ |> Option.map (Convert.FromBase64String >> Crypto.identityFromPrivKey)
96+ else
97+ None
98+ with _ ->
99+ None
94100 }
95101
96102 let saveIdentity (identity: Crypto.Identity) =
97103 async {
98104 let b64 = Convert.ToBase64String identity.PrivKey
99− try
100− do! SecureStorage.Default.SetAsync("identity_privkey", b64) |> Async.AwaitTask
101− with _ ->
102− Preferences.Default.Set("identity_privkey", b64)
105+ File.WriteAllText(identityPath, b64)
103106 }
104107
105108 let loadProfile () : Profile option =
server.knot+121−116
@@ -110,10 +110,11 @@ type PresenceEntry = {pubkey: PubkeyHex, server: AnyServerName, expiresAt: Times
110110type ForwardEntry = {messageId: MessageIdHex, toServer: ServerName, createdAt: Timestamp, toKey: PubkeyHex, fromKey: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp}
111111type AckedEntry = {messageId: MessageIdHex, toKey: PubkeyHex, ackedAt: Timestamp, originServer: ServerName}
112112data AuthResult = Authorized {pubkey: PubkeyHex} | Unauthorized {}
113−data SendResult = SendFailed {status: MessageStatus} | SendDone {delivery: DeliveryStatus, remoteServers: [ServerName], fromKey: PubkeyHex}
113+data SendResult = SendFailed {status: MessageStatus} | SendDone {delivery: DeliveryStatus, remoteServers: [ServerName], forwardServers: [ServerName], fromKey: PubkeyHex}
114114data VerifyResult = VerifyFailed {} | VerifyOk {peerServers: [ServerName]}
115115data AckResult = AckUnauthorized {} | AckDone {pendingAcks: [AckedEntry], evictedEntries: [AckedEntry]}
116−type PollResponse = {events: [PollEvent], cursor: Timestamp, authorized: Bool}
116+data PollResult = PollUnauthorized {} | PollOk {events: [PollEvent], cursor: Timestamp}
117+data ForwardResult = ForwardDuplicate {} | ForwardRejected {} | ForwardStored {}
117118
118119
119120-- ============================================
@@ -200,6 +201,10 @@ headOf = \items -> fold (\acc x -> case acc of
200201 Nothing {} -> Just {value: x}
201202 _ -> acc) Nothing {} items
202203
204+maxBy = \items getKey -> fold (\acc x -> case acc of
205+ Nothing {} -> Just {value: x}
206+ Just {value: v} -> if getKey x > getKey v then Just {value: x} else acc) Nothing {} items
207+
203208hexDigit = \n -> take 1 (drop (clamp 0 15 n) "0123456789abcdef")
204209
205210byteToHex = \n -> hexDigit (n / 16) ++ hexDigit (n - (n / 16) * 16)
@@ -220,7 +225,7 @@ genToken = genTokenN 8
220225
221226tooLong = \s maxLen -> drop maxLen s != ""
222227
223−isHexChar = \c -> contains c "0123456789abcdefABCDEF"
228+isHexChar = \c -> contains c "0123456789abcdef"
224229
225230isValidHexPairs = \s ->
226231 if s == "" then True {}
@@ -352,10 +357,6 @@ authenticateSession = \sessions token t ->
352357 Nothing {} -> Unauthorized {}
353358 Just {value: s} -> Authorized {pubkey: s.pubkey}
354359
355−authenticateRequest = \authorization t -> do
356− sessions <- *sessions
357− yield (authenticateSession sessions (extractToken authorization) t)
358−
359360extractToken = \authHeader ->
360361 let token = drop 7 authHeader in
361362 if toLower (take 7 authHeader) == "bearer " && not (tooLong token maxTokenLen)
@@ -621,10 +622,27 @@ effectiveGossipEvents = \entries appliedKeys eventType -> do
621622 where any appliedKeys (\a -> samePubkeyServer a e)
622623 yield {eventType: eventType, pubkey: e.pubkey, server: e.server, timestamp: e.timestamp, ttl: e.ttl}
623624
625+mergeGossipPresence = \presence deduped trustedOffline fromServer t ->
626+ let trustedDeduped = do e <- deduped; where e.server == fromServer || any presence (\p -> p.server == e.server && p.expiresAt > t); yield {expiresAt: e.expiresAt, pubkey: e.pubkey, server: e.server, timestamp: e.timestamp} in
627+ let appliedOnline = do e <- trustedDeduped; where none presence (\p -> samePubkeyServer p e && p.expiresAt >= e.expiresAt); yield (pubkeyServer e) in
628+ let merged = mergeByPubkeyServer presence trustedDeduped in
629+ let afterOffline = removeOffline merged trustedOffline t in
630+ let appliedOffline = do p <- diff merged afterOffline; yield (pubkeyServer p) in
631+ let capResult = capGossipPresence {afterOffline: afterOffline, appliedOnline: appliedOnline, trustedDeduped: trustedDeduped, presence: presence, trustedOffline: trustedOffline, t: t} in
632+ {finalPresence: capResult.finalPresence,
633+ appliedKeys: union capResult.appliedOnline appliedOffline,
634+ gossipPeerServers: collectPeerServers capResult.finalPresence (\p -> p.server),
635+ withinCapacity: capResult.withinCapacity}
636+
624637rejectFederation = \t respond -> do
625638 maybePruneAndBroadcast t
626639 yield (respond {ok: False {}})
627640
641+withFederationAuth = \authorization t respond extraCheck action ->
642+ if not (verifyFederationAuth authorization) || extraCheck
643+ then rejectFederation t respond
644+ else action
645+
628646maybePruneAndBroadcast = \t -> do
629647 pruneResult <- atomic do maybePrune t
630648 forkBroadcastOffline t pruneResult
@@ -658,13 +676,16 @@ retryPendingForwards = \pending -> do
658676 retryForwardsForServer (do x <- pending; where x.toServer == server; yield x) maxForwardRetriesPerServer)
659677 yield {}
660678
679+fetchFederationAck = \server messageIds ->
680+ fetch (federationUrl server) (FederationAck {
681+ authorization: federationAuth,
682+ messageIds: messageIds,
683+ originServer: server, fromServer: serverHostN})
684+
661685sendBatchedAcksToServer : [{messageId: MessageIdHex, toKey: PubkeyHex, originServer: ServerName, | a}] -> ServerName -> IO {network} {}
662686sendBatchedAcksToServer = \serverAcks server ->
663687 forEachBatch serverAcks maxMessageIds (\batch -> do
664− result <- fetch (federationUrl server) (FederationAck {
665− authorization: federationAuth,
666− messageIds: (do a <- batch; yield a.messageId),
667− originServer: server, fromServer: serverHostN})
688+ result <- fetchFederationAck server (do a <- batch; yield a.messageId)
668689 whenOk result (atomic do
669690 ackedIds <- *ackedIds
670691 set *ackedIds = do
@@ -686,10 +707,7 @@ retryEvictedAcks = \evictedEntries pendingAcks ->
686707 in do
687708 let evictedOriginServers = collectPeerServers evictedToRetry (\a -> a.originServer)
688709 sendToEach evictedOriginServers (\server -> do
689− fetchResult <- fetch (federationUrl server) (FederationAck {
690− authorization: federationAuth,
691− messageIds: (do a <- evictedToRetry; where a.originServer == server; yield a.messageId),
692− originServer: server, fromServer: serverHostN})
710+ fetchResult <- fetchFederationAck server (do a <- evictedToRetry; where a.originServer == server; yield a.messageId)
693711 case fetchResult of
694712 Err _ -> do
695713 dropped <- atomic do
@@ -725,7 +743,7 @@ pollOnce = \token cursor -> do
725743 let auth = authenticateSession sessions token t
726744 case auth of
727745 Unauthorized {} ->
728− yield {events: [], cursor: 0, authorized: False {}}
746+ yield (PollUnauthorized {})
729747 Authorized {pubkey} -> do
730748 seqCounter <- *seqCounter
731749 messages <- *messages
@@ -742,19 +760,20 @@ pollOnce = \token cursor -> do
742760 encryptedBlob: m.encryptedBlob,
743761 timestamp: m.timestamp}}
744762 let lastReceivedAt = fold (\acc m -> max acc m.receivedAt) clampedCursor limitedMsgs
745− yield {events: evts, cursor: lastReceivedAt, authorized: True {}}
763+ yield (PollOk {events: evts, cursor: lastReceivedAt})
746764
747−pollLoop : Text -> Int -> Int<Ms> -> IO {clock} PollResponse
765+pollLoop : Text -> Int -> Int<Ms> -> IO {clock} PollResult
748766pollLoop = \token cursor deadline -> do
749767 result <- pollOnce token cursor
750− if not result.authorized then yield result
751− else do
752− t <- now
753− if count result.events == 0 && t < deadline
754− then do
755− sleep (clamp 1 500 (deadline - t))
756− pollLoop token cursor deadline
757− else yield result
768+ case result of
769+ PollUnauthorized {} -> yield result
770+ PollOk {events} -> do
771+ t <- now
772+ if count events == 0 && t < deadline
773+ then do
774+ sleep (clamp 1 500 (deadline - t))
775+ pollLoop token cursor deadline
776+ else yield result
758777
759778-- ============================================
760779-- Request Handler
@@ -827,7 +846,9 @@ serve = \req -> case req of
827846 result <- pollLoop token cursor deadline
828847 tAfterPoll <- now
829848 maybePruneAndBroadcast tAfterPoll
830− yield (respond result)
849+ yield (respond (case result of
850+ PollUnauthorized {} -> {events: [], cursor: 0, authorized: False {}}
851+ PollOk {events: evts, cursor: c} -> {events: evts, cursor: c, authorized: True {}}))
831852
832853 SendMessage {to, encryptedBlob, timestamp, authorization, respond} -> do
833854 t <- now
@@ -835,7 +856,8 @@ serve = \req -> case req of
835856 let clampedTs = clampTimestamp timestamp t
836857 result <- atomic do
837858 pr <- maybePrune t
838− auth <- authenticateRequest authorization t
859+ sessions <- *sessions
860+ let auth = authenticateSession sessions (extractToken authorization) t
839861 case auth of
840862 Unauthorized {} ->
841863 yield {pr: pr, send: SendFailed {status: MsUnauthorized {}}}
@@ -863,9 +885,9 @@ serve = \req -> case req of
863885 set *forwards = if shouldForward
864886 then union forwards (do s <- forwardServers; yield {messageId: msgId, toServer: s, createdAt: t, toKey: to, fromKey: fromKey, encryptedBlob: encryptedBlob, timestamp: clampedTs})
865887 else forwards
866− yield {pr: pr, send: SendDone {delivery: delivery, remoteServers: remoteServers, fromKey: fromKey}}
888+ yield {pr: pr, send: SendDone {delivery: delivery, remoteServers: remoteServers, forwardServers: forwardServers, fromKey: fromKey}}
867889 case result.send of
868− SendDone {delivery, remoteServers, fromKey} ->
890+ SendDone {delivery, remoteServers, forwardServers, fromKey} ->
869891 fork (do
870892 when (count remoteServers > 0 && federationEnabled) (do
871893 sendToEach remoteServers (\server -> do
@@ -873,7 +895,8 @@ serve = \req -> case req of
873895 toKey: to, fromKey: fromKey, encryptedBlob: encryptedBlob,
874896 timestamp: clampedTs, originServer: serverHostN, messageId: msgId,
875897 authorization: federationAuth})
876− whenOk fwdResult (removeForward msgId server))
898+ when (any forwardServers (\s -> s == server))
899+ (whenOk fwdResult (removeForward msgId server)))
877900 case delivery of
878901 DsQueued {} -> println ("sendMessage: forwards at capacity (" ++ show maxForwardsEntries ++ "), skipping forward tracking")
879902 _ -> yield {})
@@ -888,7 +911,8 @@ serve = \req -> case req of
888911 let dedupedIds = dedupMessageIds messageIds
889912 result <- atomic do
890913 pr <- maybePrune t
891− auth <- authenticateRequest authorization t
914+ sessions <- *sessions
915+ let auth = authenticateSession sessions (extractToken authorization) t
892916 case auth of
893917 Unauthorized {} ->
894918 yield {pr: pr, ack: AckUnauthorized {}}
@@ -903,7 +927,7 @@ serve = \req -> case req of
903927 m <- ackedMessages
904928 case m.origin of
905929 FederatedOrigin {server} -> yield {messageId: m.messageId, toKey: m.toKey, ackedAt: t, originServer: server}
906− _ -> []
930+ LocalOrigin {} -> []
907931 ackedIds <- *ackedIds
908932 let combinedAckedIds = union ackedIds ackedFederated
909933 let evictedEntries = let excessCount = count combinedAckedIds - maxAckedIds in
@@ -924,7 +948,8 @@ serve = \req -> case req of
924948 t <- now
925949 result <- atomic do
926950 pr <- maybePrune t
927− auth <- authenticateRequest authorization t
951+ sessions <- *sessions
952+ let auth = authenticateSession sessions (extractToken authorization) t
928953 case auth of
929954 Unauthorized {} ->
930955 yield {pr: pr, server: "", authorized: False {}}
@@ -936,9 +961,7 @@ serve = \req -> case req of
936961 yield p
937962 let localOnline = any valid (\p -> isLocalServer p.server)
938963 let remoteValid = do p <- valid; where not (isLocalServer p.server); yield p
939− let bestRemote = fold (\acc p -> case acc of
940− Nothing {} -> Just {value: p}
941− Just {value: v} -> if p.expiresAt > v.expiresAt then Just {value: p} else acc) Nothing {} remoteValid
964+ let bestRemote = maxBy remoteValid (\p -> p.expiresAt)
942965 let foundServer = if localOnline then serverHostN
943966 else case bestRemote of Just {value: p} -> p.server; Nothing {} -> ""
944967 yield {pr: pr, server: foundServer, authorized: True {}}
@@ -947,91 +970,73 @@ serve = \req -> case req of
947970
948971 RecvGossip {events, hopsRemaining, fromServer, authorization, respond} -> do
949972 t <- now
950− let authorized = verifyFederationAuth authorization
951973 let cappedHops = clamp 0 maxGossipHops hopsRemaining
952− if not authorized || cappedHops < 1 || isLocalServer fromServer || count events > maxGossipEvents
953− then rejectFederation t respond
954− else do
955− let validated = validGossipEvents events t
956− let onlineEntries = validated.online
957− let trustedOffline = do e <- validated.offline; where e.server == fromServer; yield e
958− let filteredOnline = resolveByTimestamp onlineEntries trustedOffline
959− let filteredOffline = resolveByTimestamp trustedOffline onlineEntries
960− let deduped = mergeByPubkeyServer [] filteredOnline
961− gossipResult <- atomic do
962− pr <- maybePrune t
963− presence <- *presence
964− let trustedDeduped = do
965− e <- deduped
966− where e.server == fromServer || any presence (\p -> p.server == e.server && p.expiresAt > t)
967− yield {expiresAt: e.expiresAt, pubkey: e.pubkey, server: e.server, timestamp: e.timestamp}
968− let appliedOnline = do
969− e <- trustedDeduped
970− where none presence (\p -> samePubkeyServer p e && p.expiresAt >= e.expiresAt)
971− yield (pubkeyServer e)
972− let merged = mergeByPubkeyServer presence trustedDeduped
973− let afterOffline = removeOffline merged trustedOffline t
974− let appliedOffline = do p <- diff merged afterOffline; yield (pubkeyServer p)
975− let capResult = capGossipPresence {afterOffline: afterOffline, appliedOnline: appliedOnline, trustedDeduped: trustedDeduped, presence: presence, trustedOffline: trustedOffline, t: t}
976− set *presence = capResult.finalPresence
977− let gossipPeerServers = collectPeerServers capResult.finalPresence (\p -> p.server)
978− yield {appliedKeys: union capResult.appliedOnline appliedOffline, pr: pr, gossipPeerServers: gossipPeerServers, withinCapacity: capResult.withinCapacity}
979− let effectiveEvents = union (effectiveGossipEvents filteredOnline gossipResult.appliedKeys (GossipOnline {})) (effectiveGossipEvents filteredOffline gossipResult.appliedKeys (GossipOffline {}))
980− when (not gossipResult.withinCapacity)
981− (println ("gossip: presence at capacity (" ++ show maxPresenceEntries ++ "), limiting new entries from " ++ fromServer))
982− fork (do
983− when (cappedHops > 1 && federationEnabled && count effectiveEvents > 0) (
984− let targets = do
985− s <- gossipResult.gossipPeerServers
986− where s != fromServer
987− yield s
988− in sendBatchedGossip targets effectiveEvents (cappedHops - 1))
989− broadcastOfflineGossip t gossipResult.pr.peerServers gossipResult.pr.expiredLocal)
990− yield (respond {ok: True {}})
974+ withFederationAuth authorization t respond (cappedHops < 1 || isLocalServer fromServer || count events > maxGossipEvents) (do
975+ let validated = validGossipEvents events t
976+ let onlineEntries = validated.online
977+ let trustedOffline = do e <- validated.offline; where e.server == fromServer; yield e
978+ let filteredOnline = resolveByTimestamp onlineEntries trustedOffline
979+ let filteredOffline = resolveByTimestamp trustedOffline onlineEntries
980+ let deduped = mergeByPubkeyServer [] filteredOnline
981+ gossipResult <- atomic do
982+ pr <- maybePrune t
983+ presence <- *presence
984+ let gm = mergeGossipPresence presence deduped trustedOffline fromServer t
985+ set *presence = gm.finalPresence
986+ yield {gm: gm, pr: pr}
987+ let effectiveEvents = union (effectiveGossipEvents filteredOnline gossipResult.gm.appliedKeys (GossipOnline {})) (effectiveGossipEvents filteredOffline gossipResult.gm.appliedKeys (GossipOffline {}))
988+ when (not gossipResult.gm.withinCapacity)
989+ (println ("gossip: presence at capacity (" ++ show maxPresenceEntries ++ "), limiting new entries from " ++ fromServer))
990+ fork (do
991+ when (cappedHops > 1 && federationEnabled && count effectiveEvents > 0) (
992+ let targets = do
993+ s <- gossipResult.gm.gossipPeerServers
994+ where s != fromServer
995+ yield s
996+ in sendBatchedGossip targets effectiveEvents (cappedHops - 1))
997+ broadcastOfflineGossip t gossipResult.pr.peerServers gossipResult.pr.expiredLocal)
998+ yield (respond {ok: True {}}))
991999
9921000 ForwardMessage {toKey, fromKey, encryptedBlob, timestamp, originServer, messageId, authorization, respond} -> do
9931001 t <- now
994− let authorized = verifyFederationAuth authorization
995− if not authorized || isLocalServer originServer
996− then rejectFederation t respond
997− else do
998− let clampedTs = clampTimestamp timestamp t
999− result <- atomic do
1000− pr <- maybePrune t
1001− messages <- *messages
1002− ackedIds <- *ackedIds
1003− if hasMessage messageId toKey messages || hasMessage messageId toKey ackedIds
1004− then yield {pr: pr, ok: True {}}
1005− else if countMessagesFor messages toKey t >= maxMessagesPerRecipient
1006− then yield {pr: pr, ok: False {}}
1007− else do
1008− seqCounter <- *seqCounter
1009− let seqTs = nextSeqTs t seqCounter
1010− set *seqCounter = seqTs
1011− set *messages = union messages
1012− [{messageId: messageId, toKey: toKey, fromKey: fromKey,
1013− encryptedBlob: encryptedBlob, timestamp: clampedTs, origin: FederatedOrigin {server: originServer}, receivedAt: seqTs}]
1014− yield {pr: pr, ok: True {}}
1015− forkBroadcastOffline t result.pr
1016− yield (respond {ok: result.ok})
1002+ withFederationAuth authorization t respond (isLocalServer originServer) (do
1003+ let clampedTs = clampTimestamp timestamp t
1004+ result <- atomic do
1005+ pr <- maybePrune t
1006+ messages <- *messages
1007+ ackedIds <- *ackedIds
1008+ if hasMessage messageId toKey messages || hasMessage messageId toKey ackedIds
1009+ then yield {pr: pr, forward: ForwardDuplicate {}}
1010+ else if countMessagesFor messages toKey t >= maxMessagesPerRecipient
1011+ then yield {pr: pr, forward: ForwardRejected {}}
1012+ else do
1013+ seqCounter <- *seqCounter
1014+ let seqTs = nextSeqTs t seqCounter
1015+ set *seqCounter = seqTs
1016+ set *messages = union messages
1017+ [{messageId: messageId, toKey: toKey, fromKey: fromKey,
1018+ encryptedBlob: encryptedBlob, timestamp: clampedTs, origin: FederatedOrigin {server: originServer}, receivedAt: seqTs}]
1019+ yield {pr: pr, forward: ForwardStored {}}
1020+ forkBroadcastOffline t result.pr
1021+ yield (respond {ok: case result.forward of
1022+ ForwardRejected {} -> False {}
1023+ ForwardDuplicate {} -> True {}
1024+ ForwardStored {} -> True {}}))
10171025
10181026 FederationAck {messageIds, originServer, fromServer, authorization, respond} -> do
10191027 t <- now
1020− let authorized = verifyFederationAuth authorization
1021− if not authorized || not (isLocalServer originServer) || isLocalServer fromServer
1022− then rejectFederation t respond
1023− else do
1024− let dedupedIds = dedupMessageIds messageIds
1025− pruneResult <- atomic do
1026− pr <- maybePrune t
1027− forwards <- *forwards
1028− set *forwards = do
1029− f <- forwards
1030− where not (f.toServer == fromServer && any dedupedIds (\mid -> f.messageId == mid))
1031− yield f
1032− yield pr
1033− forkBroadcastOffline t pruneResult
1034− yield (respond {ok: True {}})
1028+ withFederationAuth authorization t respond (not (isLocalServer originServer) || isLocalServer fromServer) (do
1029+ let dedupedIds = dedupMessageIds messageIds
1030+ pruneResult <- atomic do
1031+ pr <- maybePrune t
1032+ forwards <- *forwards
1033+ set *forwards = do
1034+ f <- forwards
1035+ where not (f.toServer == fromServer && any dedupedIds (\mid -> f.messageId == mid))
1036+ yield f
1037+ yield pr
1038+ forkBroadcastOffline t pruneResult
1039+ yield (respond {ok: True {}}))
10351040
10361041 Prune {authorization, respond} -> do
10371042 t <- now
@@ -1047,7 +1052,7 @@ backgroundPrune = do
10471052 sleep backgroundPruneInterval
10481053 t <- now
10491054 pruneAndRetry t
1050− fork backgroundPrune
1055+ backgroundPrune
10511056
10521057main = do
10531058 println "Skrepka relay server starting on port 8080..."