on master help sign in
Remove client/ directory
commit3934b220f3cf594a991db16249f4cbb4e5279980 authoriko <mail@iko.soy> dateThu 25 Jun 2026, 05:04 (3 months ago) parentec51d84
67 files changed−3328
client/ApiClient.fs−104 client/App.fs−732 client/AppTypes.fs−149 client/Assets/Fonts/OpenSans-Regular.ttf client/Assets/Fonts/OpenSans-Semibold.ttf client/Assets/Fonts/Unbounded-Black.ttf client/Assets/Fonts/Unbounded-Bold.ttf client/Assets/Fonts/Unbounded-ExtraBold.ttf client/Assets/Fonts/Unbounded-ExtraLight.ttf client/Assets/Fonts/Unbounded-Light.ttf client/Assets/Fonts/Unbounded-Medium.ttf client/Assets/Fonts/Unbounded-Regular.ttf client/Assets/Fonts/Unbounded-SemiBold.ttf client/Assets/Images/left.png client/Assets/Images/logo.png client/Assets/Images/send.png client/Assets/Images/user.png client/Buttons.fs−209 client/Constants.fs−16 client/Crypto.fs−249 client/Keyboard.fs−11 client/Labels.fs−27 client/Phonemic.fs−143 client/Platform/Desktop/AppIcon.icns client/Platform/Desktop/Info.plist−24 client/Platform/Desktop/Program.fs−18 client/Platform/iOS/AppDelegate.fs−61 client/Platform/iOS/Entitlements.plist−6 client/Platform/iOS/Info.plist−43 client/Platform/iOS/PhotoPicker.fs−50 client/Platform/iOS/QRScanner.fs−113 client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json−23 client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.png client/Platform/iOS/Resources/Assets.xcassets/Contents.json−3 client/Platform/iOS/Resources/LaunchScreen.storyboard−25 client/Protocol.fs−110 client/Skrepka.app/Contents/Info.plist−24 client/Skrepka.app/Contents/MacOS/Skrepka−1 client/Skrepka.app/Contents/Resources/AppIcon.icns client/Skrepka.fsproj−138 client/Skrepka.sln−28 client/Store.fs−369 client/Styles.fs−96 client/TextFields.fs−48 client/ViewAddContact.fs−70 client/ViewChat.fs−172 client/ViewConversations.fs−101 client/ViewEditProfile.fs−41 client/ViewSettings.fs−57 client/ViewSetup.fs−30 client/msbuild.binlog client/native/iossimulator-arm64/libsodium.a client/native/maccatalyst-arm64/libsodium.a client/run-ios.sh−31 client/run-mac.sh−6
client/ApiClient.fsdeleted−104
@@ -1,104 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Net.Http
5−open System.Text
6−open System.Text.Json
7−open System.Text.Json.Serialization
8−open System.Threading.Tasks
9−
10−module ApiClient =
11−
12− exception ApiError of string
13− exception ServerRejected of string
14− exception Unauthorized
15−
16− [<CLIMutable>]
17− type PollEvent = { EncryptedBlob: string }
18−
19− [<CLIMutable>]
20− type PollResponse = { Cursor: int64; Events: PollEvent array }
21−
22− /// Awaits a Task without converting TaskCanceledException to F# async
23− /// cancellation (which bypasses try...with). Re-raises it as a regular exception.
24− let private awaitTask (t: Task<'T>) : Async<'T> =
25− Async.FromContinuations(fun (ok, err, _cancel) ->
26− t.ContinueWith(fun (t: Task<'T>) ->
27− if t.IsFaulted then err t.Exception.InnerException
28− elif t.IsCanceled then err (TimeoutException("Request timed out"))
29− else ok t.Result)
30− |> ignore)
31−
32− let private jsonOpts =
33− JsonSerializerOptions(PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString)
34−
35− let private client =
36− let c = new HttpClient()
37− c.Timeout <- TimeSpan.FromSeconds(60.)
38− c
39−
40− let private pollClient =
41− let c = new HttpClient()
42− c.Timeout <- TimeSpan.FromSeconds(30.)
43− c
44−
45− let private sendRequest (httpClient: HttpClient) (url: string) (body: string) (token: string option) =
46− async {
47− use request = new HttpRequestMessage(HttpMethod.Post, url)
48− request.Content <- new StringContent(body, Encoding.UTF8, "application/json")
49− token |> Option.iter (fun t -> request.Headers.Add("Authorization", $"Bearer {t}"))
50− use! response = httpClient.SendAsync(request) |> awaitTask
51− let! text = response.Content.ReadAsStringAsync() |> awaitTask
52− if response.IsSuccessStatusCode then
53− return JsonDocument.Parse(text)
54− else
55− if response.StatusCode = System.Net.HttpStatusCode.Unauthorized then
56− return raise Unauthorized
57− let code =
58− try
59− use d = JsonDocument.Parse(text)
60− match d.RootElement.TryGetProperty("error") with
61− | true, e -> e.GetString()
62− | _ -> string (int response.StatusCode)
63− with _ -> string (int response.StatusCode)
64− return raise (ApiError $"{url}: {code}")
65− }
66−
67− let private postJson<'T> httpClient url body token =
68− async {
69− use! doc = sendRequest httpClient url body token
70− return doc.RootElement.Deserialize<'T>(jsonOpts)
71− }
72−
73− let authenticate (serverUrl: string) (identity: Crypto.Identity) =
74− async {
75− let body = JsonSerializer.Serialize({| pubkey = identity.PubKeyHex |})
76− use! challengeDoc = sendRequest client $"{serverUrl}/auth/challenge" body None
77− let challenge = challengeDoc.RootElement.GetProperty("challenge").GetString()
78− // Bind the signature to the host we actually dialed, so a relay server
79− // cannot replay it to a different server (see PROTOCOL.md §6, §10).
80− let serverHost = Uri(serverUrl).Host.ToLowerInvariant().TrimEnd('.')
81− let sigHex = Crypto.signChallenge identity.PrivKey serverHost challenge
82− let body = JsonSerializer.Serialize({| pubkey = identity.PubKeyHex; challenge = challenge; signature = sigHex |})
83− use! tokenDoc = sendRequest client $"{serverUrl}/auth/verify" body None
84− let token = tokenDoc.RootElement.GetProperty("token").GetString()
85− if String.IsNullOrEmpty(token) then raise (ApiError "Authentication rejected by server")
86− return token
87− }
88−
89− let sendMessage (serverUrl: string) (token: string) (toHex: string) (blobHex: string) =
90− let body =
91− JsonSerializer.Serialize(
92− {| messages = [| {| ``to`` = toHex; encryptedBlob = blobHex |} |] |})
93− async {
94− try
95− use! _doc = sendRequest client $"{serverUrl}/messages" body (Some token)
96− ()
97− with ApiError msg when msg.EndsWith(": self_send") ->
98− return raise (ServerRejected "Message rejected by server")
99− }
100−
101− let poll (serverUrl: string) (token: string) (cursor: int64) =
102− let body = JsonSerializer.Serialize({| cursor = cursor |})
103− postJson<PollResponse> pollClient $"{serverUrl}/poll" body (Some token)
104−
client/App.fsdeleted−732
@@ -1,732 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.IO
5−open Avalonia.Media
6−open Avalonia.Themes.Fluent
7−open Fabulous
8−open Fabulous.Avalonia
9−
10−open type Fabulous.Avalonia.View
11−
12−module App =
13−
14− open Store
15− open Crypto
16− open ApiClient
17− open AppTypes
18− open ViewSetup
19− open ViewSettings
20− open ViewConversations
21− open ViewChat
22− open ViewAddContact
23− open ViewEditProfile
24− open Protocol
25−
26− // ── Helpers ──
27−
28− let private tryParsePubkey (input: string) =
29− let input = input.Trim()
30−
31− Phonemic.fromOb input
32− |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
33− |> Option.filter (fun bytes -> bytes.Length = 32)
34−
35− let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
36− let existing = messagesFor pk messages
37−
38− if existing |> List.exists (fun m -> m.Id = msg.Id) then
39− messages
40− else
41− Map.add pk (existing @ [ msg ]) messages
42−
43− let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
44− let ackSet = Set.ofList ackIds
45−
46− messages
47− |> Map.change
48− pk
49− (Option.map (
50− List.map (fun m ->
51− match m.Direction with
52− | Outgoing DeliveryStatus.Sent when Set.contains m.Id ackSet ->
53− { m with
54− Direction = Outgoing DeliveryStatus.Delivered }
55− | _ -> m)
56− ))
57−
58− let private newContact pubkey nickname : Contact =
59− { Pubkey = pubkey
60− Nickname = nickname
61− DisplayName = truncKey pubkey
62− Bio = ""
63− Photo = None
64− AddedDate = System.DateTimeOffset.UtcNow }
65−
66− let private withProfile (p: Profile) (c: Contact) =
67− { c with
68− DisplayName = p.DisplayName
69− Bio = p.Bio
70− Photo = p.Photo }
71−
72− let private applyEvent (m: Model) (evt: IncomingEvent) =
73− match evt.Envelope with
74− | Envelope.TextMessage(id, body) ->
75− let message =
76− { Id = id
77− Body = body
78− Timestamp = evt.Timestamp
79− Direction = Incoming }
80−
81− { m with
82− Contacts =
83− if Map.containsKey evt.Sender m.Contacts then
84− m.Contacts
85− else
86− Map.add evt.Sender (newContact evt.Sender "") m.Contacts
87− Messages = m.Messages |> appendMessage evt.Sender message }
88− | Envelope.DeliveryAck ackIds ->
89− { m with
90− Messages = m.Messages |> markDelivered evt.Sender ackIds }
91− | Envelope.ProfileMessage profile ->
92− { m with
93− Contacts =
94− m.Contacts
95− |> Map.change evt.Sender (fun c ->
96− let contact = c |> Option.defaultWith (fun () -> newContact evt.Sender "")
97− Some(withProfile profile contact)) }
98−
99− let private trySession model =
100− match model.Auth with
101− | Identified(id, Online token) ->
102− Some
103− { Url = model.ServerUrl
104− Token = token
105− Identity = id }
106− | _ -> None
107−
108− let private setConn status model =
109− match model.Auth with
110− | Identified(id, _) ->
111− { model with
112− Auth = Identified(id, status) }
113− | NoIdentity -> model
114−
115− let private saveCmdMsg model : CmdMsg =
116− CmdSaveData(
117− { Contacts = model.Contacts
118− Messages = model.Messages
119− ServerUrl = model.ServerUrl
120− PollCursor = model.PollCursor }
121− : Data
122− )
123−
124− // ── Init ──
125−
126− let init () =
127− { Page = Setup
128− Auth = NoIdentity
129− ServerUrl = "http://localhost:8080"
130− PollStatus = ""
131− Contacts = Map.empty
132− Messages = Map.empty
133− Error = None
134− PollCursor = 0L
135− Profile = None
136− FlushingOutbox = false
137− PollRetries = 0
138− KeyboardHeight = 0.
139− SafeAreaTop = 0.
140− SafeAreaBottom = 0. },
141− [ CmdLoadState; CmdSubscribeKeyboard; CmdSubscribeSafeArea ]
142−
143− // ── Update ──
144−
145− let update msg model =
146− match msg with
147− | SetPage page -> { model with Page = page }, []
148−
149− | GenIdentity ->
150− let identity = Crypto.generateIdentity ()
151−
152− { model with
153− Auth = Identified(identity, Offline)
154− Page = EditProfile("", "", None) },
155− [ CmdSaveIdentity identity ]
156−
157− | SetServerUrl url -> { model with ServerUrl = url }, []
158−
159− | DoConnect ->
160− match model.Auth with
161− | Identified(id, _) -> model |> setConn Connecting, [ CmdConnect(model.ServerUrl, id) ]
162− | NoIdentity ->
163− { model with
164− Error = Some "No identity generated" },
165− []
166−
167− | AuthOk token ->
168− match model.Auth with
169− | Identified(id, Connecting) ->
170− let session =
171− { Url = model.ServerUrl
172− Token = token
173− Identity = id }
174−
175− { model with
176− Auth = Identified(id, Online token)
177− Page =
178− match model.Page with
179− | Setup -> Conversations
180− | p -> p
181− Error = None
182− PollStatus = "polling..."
183− FlushingOutbox = true },
184− [ CmdPoll(session, model.PollCursor, 0); CmdFlushOutbox session ]
185− | _ -> model, []
186−
187− | AuthErr err ->
188− { setConn Offline model with
189− Error = Some err
190− FlushingOutbox = false
191− PollRetries = 0 },
192− []
193−
194− | TokenExpired ->
195− match model.Auth with
196− | Identified(id, Online _) ->
197− let m = setConn Connecting model
198−
199− { m with
200− FlushingOutbox = false
201− PollRetries = 0 },
202− [ CmdConnect(model.ServerUrl, id) ]
203− | _ -> model, []
204−
205− | DoDisconnect ->
206− { setConn Offline model with
207− PollStatus = ""
208− FlushingOutbox = false
209− PollRetries = 0 },
210− []
211−
212− | DoSend ->
213− match model.Page with
214− | Chat(pk, compose) when compose.Trim() <> "" ->
215− let body = compose.Trim()
216− let id = Guid.NewGuid().ToString()
217−
218− let outMsg =
219− { Id = id
220− Body = body
221− Timestamp = DateTimeOffset.UtcNow
222− Direction = Outgoing DeliveryStatus.Sent }
223−
224− let neverSentTo =
225− model.Messages
226− |> messagesFor pk
227− |> List.exists (fun m -> match m.Direction with Outgoing _ -> true | _ -> false)
228− |> not
229−
230− let model' =
231− { model with
232− Page = Chat(pk, "")
233− Messages = model.Messages |> appendMessage pk outMsg }
234−
235− model',
236− [ CmdEnqueue(pk, Envelope.TextMessage(id, body))
237− match model.Profile with
238− | Some profile when neverSentTo -> CmdEnqueue(pk, Envelope.ProfileMessage profile)
239− | _ -> ()
240− saveCmdMsg model' ]
241− | _ -> model, []
242−
243− | StartFlush ->
244− if model.FlushingOutbox then
245− eprintfn "[skrepka] StartFlush: already flushing, skipping"
246− model, []
247− else
248− match trySession model with
249− | Some session ->
250− eprintfn "[skrepka] StartFlush: dispatching flush"
251− { model with FlushingOutbox = true }, [ CmdFlushOutbox session ]
252− | None ->
253− eprintfn "[skrepka] StartFlush: no session"
254− model, []
255−
256− | FlushResult sent ->
257− if sent then
258− match trySession model with
259− | Some session -> model, [ CmdFlushOutbox session ]
260− | None -> { model with FlushingOutbox = false }, []
261− else
262− { model with FlushingOutbox = false }, []
263−
264− | PollResult(events, status, newCursor) ->
265− let retries =
266− if events.IsEmpty && status.StartsWith("poll error") then
267− model.PollRetries + 1
268− else
269− 0
270−
271− let model' =
272− events
273− |> List.fold
274− applyEvent
275− { model with
276− PollStatus = status
277− PollCursor = newCursor
278− PollRetries = retries }
279−
280− model',
281− [ match trySession model' with
282− | Some session -> CmdPoll(session, model'.PollCursor, model'.PollRetries)
283− | None -> ()
284− if not events.IsEmpty then
285− saveCmdMsg model' ]
286−
287− | DoSaveContact ->
288− match model.Page with
289− | AddContact(cpk) when cpk <> "" ->
290− match tryParsePubkey cpk with
291− | Some bytes ->
292− let hex = Crypto.toHex bytes
293−
294− match Map.tryFind hex model.Contacts with
295− | Some e -> model, []
296− | None ->
297− let contact = newContact hex cpk
298−
299− let model' =
300− { model with
301− Contacts = Map.add hex contact model.Contacts
302− Page = Conversations }
303−
304− model', [ saveCmdMsg model' ]
305− | None ->
306− { model with
307− Error = Some "Invalid public key" },
308− []
309− | _ -> model, []
310−
311− | CopyPubKey ->
312− match model.Auth with
313− | Identified(id, _) -> model, [ CmdCopyToClipboard(hexToOb id.PubKeyHex) ]
314− | NoIdentity -> model, []
315−
316− | DismissError -> { model with Error = None }, []
317−
318− | DoPickPhoto -> model, [ CmdPickPhoto ]
319−
320− | PhotoPicked(Some _ as photo) ->
321− match model.Page with
322− | EditProfile(name, bio, _) ->
323− { model with
324− Page = EditProfile(name, bio, photo) },
325− []
326− | _ -> model, []
327− | PhotoPicked None -> model, []
328−
329− | DoSaveProfile ->
330− match model.Page with
331− | EditProfile(name, bio, photo) ->
332− let profile: Profile =
333− { DisplayName = name
334− Bio = bio
335− Photo = photo }
336−
337− { model with
338− Profile = Some profile
339− Page = Settings },
340− [ CmdSaveProfile profile
341− for pk in model.Contacts.Keys do
342− CmdEnqueue(pk, Envelope.ProfileMessage profile) ]
343− | _ -> model, []
344−
345− | StateLoaded(Some identity, Some data, profile) ->
346− let model' =
347− { model with
348− Auth = Identified(identity, Connecting)
349− Contacts = data.Contacts
350− Messages = data.Messages
351− ServerUrl =
352− if data.ServerUrl <> "" then
353− data.ServerUrl
354− else
355− model.ServerUrl
356− PollCursor = data.PollCursor
357− Profile = profile
358− Page = Conversations }
359−
360− model', [ CmdConnect(model'.ServerUrl, identity) ]
361− | StateLoaded(Some identity, None, profile) ->
362− { model with
363− Auth = Identified(identity, Offline)
364− Profile = profile
365− Page = Settings },
366− []
367− | StateLoaded(None, _, _) -> model, []
368−
369− | Search _ -> model, []
370−
371− | KeyboardHeightChanged h -> { model with KeyboardHeight = h }, []
372− | SafeAreaChanged(top, bottom) -> { model with SafeAreaTop = top; SafeAreaBottom = bottom }, []
373−
374− | DoScanQR -> model, [ CmdScanQR ]
375−
376− | QRScanned(Some text) ->
377− match model.Page with
378− | AddContact _ -> { model with Page = AddContact(text) }, []
379− | _ -> model, []
380−
381− | QRScanned None -> model, []
382−
383− // ── mapCmd ──
384−
385− let private log msg = eprintfn $"[skrepka] {msg}"
386−
387− let private getClipboard () =
388− match Avalonia.Application.Current with
389− | null -> None
390− | app ->
391− match app.ApplicationLifetime with
392− | :? Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime as desktop ->
393− desktop.MainWindow
394− |> Option.ofObj
395− |> Option.bind (fun w -> w.Clipboard |> Option.ofObj)
396− | :? Avalonia.Controls.ApplicationLifetimes.ISingleViewApplicationLifetime as singleView ->
397− singleView.MainView
398− |> Option.ofObj
399− |> Option.bind (fun v -> Avalonia.Controls.TopLevel.GetTopLevel(v) |> Option.ofObj)
400− |> Option.bind (fun tl -> tl.Clipboard |> Option.ofObj)
401− | _ -> None
402−
403− let private getStorageProvider () =
404− match Avalonia.Application.Current with
405− | null -> None
406− | app ->
407− match app.ApplicationLifetime with
408− | :? Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime as desktop ->
409− desktop.MainWindow |> Option.ofObj |> Option.map (fun w -> w.StorageProvider)
410− | :? Avalonia.Controls.ApplicationLifetimes.ISingleViewApplicationLifetime as singleView ->
411− singleView.MainView
412− |> Option.ofObj
413− |> Option.bind (fun v -> Avalonia.Controls.TopLevel.GetTopLevel(v) |> Option.ofObj)
414− |> Option.map (fun tl -> tl.StorageProvider)
415− | _ -> None
416−
417− let private cropToSquarePng (imageBytes: byte[]) =
418− use skBmp = SkiaSharp.SKBitmap.Decode(imageBytes)
419− let size = min skBmp.Width skBmp.Height
420− let x = (skBmp.Width - size) / 2
421− let y = (skBmp.Height - size) / 2
422− use cropped = new SkiaSharp.SKBitmap(size, size)
423−
424− skBmp.ExtractSubset(cropped, SkiaSharp.SKRectI(x, y, x + size, y + size))
425− |> ignore
426−
427− use img = SkiaSharp.SKImage.FromBitmap(cropped)
428− use data = img.Encode(SkiaSharp.SKEncodedImageFormat.Png, 90)
429− data.ToArray()
430−
431−#if MOBILE
432− let private scanQRCmd =
433− Cmd.ofEffect (fun dispatch ->
434− task {
435− try
436− let! result = Skrepka.iOS.QRScanner.scan ()
437− dispatch (QRScanned result)
438− with ex ->
439− log $"QR scan error: {ex.Message}"
440− dispatch (QRScanned None)
441− }
442− |> ignore)
443−
444− let private pickPhotoCmd =
445− Cmd.ofEffect (fun dispatch ->
446− task {
447− try
448− let! bytes = Skrepka.iOS.PhotoPicker.pick ()
449−
450− match bytes with
451− | Some b -> dispatch (PhotoPicked(Some(cropToSquarePng b)))
452− | None -> dispatch (PhotoPicked None)
453− with ex ->
454− log $"photo pick error: {ex.Message}"
455− dispatch (PhotoPicked None)
456− }
457− |> ignore)
458−#else
459− let private scanQRCmd =
460− Cmd.ofEffect (fun dispatch ->
461− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
462− task {
463− try
464− match getStorageProvider () with
465− | Some sp ->
466− let opts =
467− Avalonia.Platform.Storage.FilePickerOpenOptions(
468− AllowMultiple = false,
469− Title = "Select QR Code Image",
470− FileTypeFilter =
471− [ Avalonia.Platform.Storage.FilePickerFileType(
472− "Images",
473− Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ]
474− ) ]
475− )
476−
477− let! files = sp.OpenFilePickerAsync(opts)
478−
479− match files |> Seq.tryHead with
480− | None -> dispatch (QRScanned None)
481− | Some file ->
482− use! stream = file.OpenReadAsync()
483− use ms = new MemoryStream()
484− do! stream.CopyToAsync(ms)
485−
486− use bitmap = SkiaSharp.SKBitmap.Decode(ms.ToArray())
487− let reader = ZXing.SkiaSharp.BarcodeReader()
488− let result = reader.Decode(bitmap)
489−
490− if isNull result then
491− dispatch (QRScanned None)
492− else
493− dispatch (QRScanned(Some result.Text))
494− | None -> dispatch (QRScanned None)
495− with ex ->
496− log $"QR scan error: {ex.Message}"
497− dispatch (QRScanned None)
498− }
499− :> System.Threading.Tasks.Task)
500− |> ignore)
501−
502− let private pickPhotoCmd =
503− Cmd.ofEffect (fun dispatch ->
504− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
505− task {
506− try
507− match getStorageProvider () with
508− | Some sp ->
509− let opts =
510− Avalonia.Platform.Storage.FilePickerOpenOptions(
511− AllowMultiple = false,
512− Title = "Pick Photo",
513− FileTypeFilter =
514− [ Avalonia.Platform.Storage.FilePickerFileType(
515− "Images",
516− Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ]
517− ) ]
518− )
519−
520− let! files = sp.OpenFilePickerAsync(opts)
521−
522− match files |> Seq.tryHead with
523− | None -> dispatch (PhotoPicked None)
524− | Some file ->
525− use! stream = file.OpenReadAsync()
526− use ms = new MemoryStream()
527− do! stream.CopyToAsync(ms)
528− dispatch (PhotoPicked(Some(cropToSquarePng (ms.ToArray()))))
529− | None -> dispatch (PhotoPicked None)
530− with ex ->
531− log $"photo pick error: {ex.Message}"
532− dispatch (PhotoPicked None)
533− }
534− :> System.Threading.Tasks.Task)
535− |> ignore)
536−#endif
537−
538− let mapCmd cmdMsg =
539− match cmdMsg with
540− | CmdConnect(url, identity) ->
541− Cmd.OfAsync.either (fun (url, id) -> authenticate url id) (url, identity) AuthOk (fun ex ->
542− AuthErr ex.Message)
543−
544− | CmdEnqueue(recipientHex, envelope) ->
545− Cmd.ofEffect (fun dispatch ->
546− async {
547− let ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
548− let json = serializeEnvelope ts envelope
549− do! Store.enqueueOutbox recipientHex json
550− dispatch StartFlush
551− }
552− |> Async.Start)
553−
554− | CmdFlushOutbox session ->
555− Cmd.ofEffect (fun dispatch ->
556− async {
557− match! Store.peekOutbox () with
558− | Some item ->
559− log $"flush: sending to {item.RecipientHex.[..7]}..."
560− try
561− let blobOpt =
562− Crypto.encrypt
563− session.Identity.PrivKey
564− (Crypto.fromHex item.RecipientHex)
565− item.EnvelopeJson
566−
567− match blobOpt with
568− | Some blob ->
569− log $"flush: encrypted, posting to {session.Url}/messages"
570− do! sendMessage session.Url session.Token item.RecipientHex (Crypto.toHex blob)
571− log "flush: sent OK"
572− do! Store.dequeueOutbox item.Id
573− dispatch (FlushResult true)
574− | None ->
575− log $"outbox encrypt failed, dropping message to {item.RecipientHex}"
576− do! Store.dequeueOutbox item.Id
577− dispatch (FlushResult true)
578− with
579− | Unauthorized ->
580− log "outbox unauthorized, reauthenticating"
581− dispatch TokenExpired
582− | ServerRejected msg ->
583− log $"outbox rejected, dropping: {msg}"
584− do! Store.dequeueOutbox item.Id
585− dispatch (FlushResult true)
586− | ex ->
587− log $"outbox send error: {ex.Message}"
588− do! Async.Sleep Constants.outboxRetryDelayMs
589− dispatch (FlushResult true)
590− | None -> dispatch (FlushResult false)
591− }
592− |> Async.Start)
593−
594− | CmdPoll(session, cursor, retries) ->
595− Cmd.ofEffect (fun dispatch ->
596− async {
597− log $"poll start cursor={cursor}"
598−
599− try
600− let! response = poll session.Url session.Token cursor
601− log $"poll response: {response.Events.Length} events, cursor={response.Cursor}"
602−
603− let results =
604− response.Events
605− |> Array.map (fun evt -> decryptEvent session.Identity.PrivKey evt)
606−
607− let events = results |> Array.choose Result.toOption |> Array.toList
608−
609− let errors =
610− results
611− |> Array.choose (function
612− | Error e -> Some e
613− | _ -> None)
614− |> Array.toList
615−
616− for e in errors do
617− log $"decrypt error: {e}"
618−
619− if not events.IsEmpty then
620− let acksBySender =
621− events
622− |> List.choose (fun evt ->
623− match evt.Envelope with
624− | Envelope.TextMessage(id, _) -> Some(evt.Sender, id)
625− | _ -> None)
626− |> List.groupBy fst
627− |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
628−
629− for sender, msgIds in acksBySender do
630− try
631− do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
632− with ex ->
633− log $"delivery ack error: {ex.Message}"
634−
635− let chatCount =
636− events
637− |> List.sumBy (fun e ->
638− match e.Envelope with
639− | Envelope.TextMessage _ -> 1
640− | _ -> 0)
641−
642− let status =
643− $"evts:{response.Events.Length} msgs:{chatCount} errs:{errors.Length}"
644− + (match List.tryHead errors with
645− | Some e -> $" [{e}]"
646− | None -> "")
647−
648− dispatch (PollResult(events, status, response.Cursor))
649− with
650− | :? TimeoutException ->
651− log "poll timeout"
652− dispatch (PollResult([], "polling...", cursor))
653− | Unauthorized ->
654− log "poll unauthorized, reauthenticating"
655− dispatch TokenExpired
656− | ex ->
657− log $"poll error: {ex.Message}"
658− let delay = min (Constants.pollRetryBaseMs * (pown 2 retries)) 30000
659− do! Async.Sleep delay
660− dispatch (PollResult([], $"poll error: {ex.Message}", cursor))
661− }
662− |> Async.Start)
663−
664− | CmdCopyToClipboard text ->
665− Cmd.ofEffect (fun _ ->
666− match getClipboard () with
667− | Some clipboard -> clipboard.SetTextAsync(text) |> ignore
668− | None -> ())
669−
670− | CmdLoadState ->
671− Cmd.OfAsync.perform
672− (fun () ->
673− async {
674− match! Store.loadIdentity () with
675− | Some id ->
676− let! data = Store.loadData ()
677− let! profile = Store.loadProfile ()
678− return (Some id, data, profile)
679− | None -> return (None, None, None)
680− })
681− ()
682− StateLoaded
683−
684− | CmdSaveIdentity identity -> Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
685−
686− | CmdSaveData data -> Cmd.ofEffect (fun _ -> Store.saveData data |> Async.Start)
687−
688− | CmdPickPhoto -> pickPhotoCmd
689−
690− | CmdScanQR -> scanQRCmd
691−
692− | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile |> Async.Start)
693−
694− | CmdSubscribeKeyboard ->
695− Cmd.ofEffect (fun dispatch -> Keyboard.heightChanged.Add(fun h -> dispatch (KeyboardHeightChanged h)))
696−
697− | CmdSubscribeSafeArea ->
698− Cmd.ofEffect (fun dispatch -> SafeArea.insetsChanged.Add(fun (t, b) -> dispatch (SafeAreaChanged(t, b))))
699−
700− // ── View ──
701−
702− let private pageContent model =
703− let page =
704− match model.Page with
705− | Setup -> AnyView(viewSetup ())
706− | Settings -> AnyView(viewSettings model)
707− | Conversations -> AnyView(viewConversations model)
708− | Chat(pk, compose) -> AnyView(viewChat model pk compose)
709− | AddContact(pk) -> AnyView(viewAddContact model pk)
710− | EditProfile(displayName, bio, photo) -> AnyView(viewEditProfile model displayName bio photo)
711−
712− Border(page)
713− .background(SolidColorBrush(Colors.White))
714− .padding (Avalonia.Thickness(0., model.SafeAreaTop, 0., model.KeyboardHeight))
715−
716− let view model =
717−#if MOBILE
718− SingleViewApplication(pageContent model)
719−#else
720− DesktopApplication() {
721− Window(pageContent model)
722− .title("Skrepka")
723− .icon("avares://Skrepka/Assets/Images/logo.png")
724− .width(450.)
725− .height (750.)
726− }
727−#endif
728−
729− let program = Program.statefulWithCmdMsg init update mapCmd |> Program.withView view
730−
731− let create () =
732− FabulousAppBuilder.Configure(FluentTheme, program)
client/AppTypes.fsdeleted−149
@@ -1,149 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open Avalonia.Media
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module AppTypes =
10−
11− open Store
12− open Crypto
13−
14− // ── Domain Types ──
15−
16− type Page =
17− | Setup
18− | Conversations
19− | Chat of pubkey: string * compose: string
20− | AddContact of pubkey: string
21− | Settings
22− | EditProfile of displayName: string * bio: string * photo: byte[] option
23−
24− type Session =
25− { Url: string
26− Token: string
27− Identity: Identity }
28−
29− type ConnStatus =
30− | Offline
31− | Connecting
32− | Online of token: string
33−
34− type AuthState =
35− | NoIdentity
36− | Identified of identity: Identity * conn: ConnStatus
37−
38− [<RequireQualifiedAccess>]
39− type Envelope =
40− | TextMessage of id: string * body: string
41− | DeliveryAck of ackIds: string list
42− | ProfileMessage of profile: Profile
43−
44− type IncomingEvent =
45− { Sender: string
46− Timestamp: DateTimeOffset
47− Envelope: Envelope }
48−
49− // ── Model ──
50−
51− type Model =
52− { Page: Page
53− Auth: AuthState
54− ServerUrl: string
55− Contacts: Map<string, Contact>
56− PollStatus: string
57− Messages: Map<string, ChatMessage list>
58− Error: string option
59− PollCursor: int64
60− Profile: Profile option
61− FlushingOutbox: bool
62− PollRetries: int
63− KeyboardHeight: float
64− SafeAreaTop: float
65− SafeAreaBottom: float }
66−
67− // ── Msg ──
68−
69− type Msg =
70− | SetPage of Page
71− | GenIdentity
72− | SetServerUrl of string
73− | DoConnect
74− | AuthOk of string
75− | AuthErr of string
76− | DoDisconnect
77− | DoSend
78− | DoSaveContact
79− | PollResult of events: IncomingEvent list * status: string * cursor: int64
80− | CopyPubKey
81− | DismissError
82− | StateLoaded of Identity option * Data option * Profile option
83− | DoPickPhoto
84− | PhotoPicked of byte[] option
85− | DoSaveProfile
86− | StartFlush
87− | FlushResult of sent: bool
88− | Search of string
89− | TokenExpired
90− | KeyboardHeightChanged of float
91− | SafeAreaChanged of top: float * bottom: float
92− | DoScanQR
93− | QRScanned of string option
94−
95− // ── CmdMsg ──
96−
97− type CmdMsg =
98− | CmdConnect of url: string * identity: Identity
99− | CmdEnqueue of recipientHex: string * envelope: Envelope
100− | CmdFlushOutbox of session: Session
101− | CmdPoll of session: Session * cursor: int64 * retries: int
102− | CmdCopyToClipboard of string
103− | CmdLoadState
104− | CmdSaveIdentity of Identity
105− | CmdSaveData of Data
106− | CmdPickPhoto
107− | CmdSaveProfile of Profile
108− | CmdSubscribeKeyboard
109− | CmdSubscribeSafeArea
110− | CmdScanQR
111−
112− // ── Shared Helpers ──
113−
114− let hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
115−
116− let truncKey (hex: string) =
117− let ob = hexToOb hex
118− let parts = ob.Split('-')
119−
120− if parts.Length <= 4 then
121− ob
122− else
123− $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
124−
125− let contactName (contacts: Map<string, Contact>) (pk: string) =
126− match Map.tryFind pk contacts with
127− | Some c -> c.DisplayName
128− | _ -> pk
129−
130− let messagesFor (pk: string) (messages: Map<string, ChatMessage list>) =
131− messages |> Map.tryFind pk |> Option.defaultValue []
132−
133− let connStatusLabel =
134− function
135− | NoIdentity
136− | Identified(_, Offline) -> "OFFLINE"
137− | Identified(_, Connecting) -> "CONNECTING"
138− | Identified(_, Online _) -> "ONLINE"
139−
140− // ── Shared View ──
141−
142− let viewErrorBanner (error: string option) =
143− VStack(4.) {
144− match error with
145− | Some err ->
146− TextBlock(err).foreground(SolidColorBrush(Colors.Red)).fontSize (12.)
147− Button("Dismiss", DismissError)
148− | None -> ()
149− }
client/Assets/Fonts/OpenSans-Regular.ttfdeleted

Binary file not shown.

client/Assets/Fonts/OpenSans-Semibold.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Black.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Bold.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-ExtraBold.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-ExtraLight.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Light.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Medium.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Regular.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-SemiBold.ttfdeleted

Binary file not shown.

client/Assets/Images/left.pngdeleted

Binary file not shown.

client/Assets/Images/logo.pngdeleted

Binary file not shown.

client/Assets/Images/send.pngdeleted

Binary file not shown.

client/Assets/Images/user.pngdeleted

Binary file not shown.

client/Buttons.fsdeleted−209
@@ -1,209 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module Buttons =
10− open Avalonia.Media.Transformation
11− open System
12−
13− type ButtonPriority =
14− | Primary
15− | Secondary
16−
17− let private translate x y =
18− let b = TransformOperations.CreateBuilder 1
19− b.AppendTranslate(x, y)
20− b.Build() :> ITransform
21−
22− let private shadowRect offset =
23− Rectangle()
24− .fill(SolidColorBrush(Colors.Black))
25− .renderTransform(translate offset offset)
26− .transition (
27− TransformOperationsTransition(
28− Avalonia.Visual.RenderTransformProperty,
29− TimeSpan.FromMilliseconds Constants.animationDuration
30− )
31− .easing (Constants.easing)
32− )
33−
34− let private borderRect () =
35− Rectangle().stroke(Colors.Black).strokeThickness (4.)
36−
37− let private textButton fontSize pressOffset restOffset margin priority (text: string) (msg: 'msg) disabled =
38− Component($"{text}-{disabled}") {
39− let! pressed = Context.State(false)
40− let! animatedIn = Context.State(false)
41−
42− let offset =
43− if disabled then 0.
44− elif not animatedIn.Current then
45− Avalonia.Threading.Dispatcher.UIThread.Post(fun () -> animatedIn.Set true)
46− 0.
47− elif pressed.Current then pressOffset
48− else restOffset
49−
50− (Grid() {
51− shadowRect offset
52−
53− Rectangle()
54− .fill (
55− match priority with
56− | Primary -> SolidColorBrush(Constants.accentColor)
57− | Secondary -> SolidColorBrush(Colors.White)
58− )
59−
60− TextBlock(text)
61− .foreground(
62− match priority with
63− | Primary -> SolidColorBrush(Colors.White)
64− | Secondary -> SolidColorBrush(Colors.Black)
65−
66− )
67− .fontSize(fontSize)
68− .fontFamily(FontFamily(Constants.fontFamily))
69− .fontWeight(FontWeight.Bold)
70− .center()
71− .margin (8.)
72−
73− borderRect ()
74− })
75− .background(Brushes.Transparent)
76− .onPointerPressed(fun _ -> if not disabled then pressed.Set true)
77− .onPointerReleased(fun _ -> pressed.Set false)
78− .onPointerExited(fun _ -> pressed.Set false)
79− .margin(margin)
80− .onTapped (fun _ -> msg)
81− }
82−
83− let button priority text msg = textButton 24. 4. 8. 16. priority text msg false
84− let smallButton priority text msg = textButton 16. 2. 4. 8. priority text msg false
85− let disabledButton priority text msg = textButton 24. 4. 8. 16. priority text msg true
86− let disabledSmallButton priority text msg = textButton 16. 2. 4. 8. priority text msg true
87−
88− let imageButton (img: byte[] option) (msg: 'msg) =
89− let key =
90− match img with
91− | None -> "img-button"
92− | Some i -> $"img-button-{hash i}"
93−
94− Component(key) {
95− let! pressed = Context.State(false)
96− let offset = if pressed.Current then 4. else 8.
97−
98− (Grid() {
99− shadowRect offset
100−
101− Rectangle().fill (SolidColorBrush(Colors.White))
102−
103− match img with
104− | None ->
105− ViewBox(
106− (VStack(32) {
107− Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
108− .width(80.)
109− .margin (0., 20., 0., 0.)
110−
111− Label("Upload")
112− .fontFamily(FontFamily(Constants.fontFamily))
113− .fontWeight(FontWeight.Bold)
114− .fontSize(24.)
115− .centerContentHorizontal ()
116− })
117− )
118− .margin (24.)
119−
120− | Some i -> Image(Styles.cachedBitmap i, Stretch.UniformToFill).clipToBounds (true)
121−
122− borderRect ()
123− })
124− .background(Brushes.Transparent)
125− .onPointerPressed(fun _ -> pressed.Set true)
126− .onPointerReleased(fun _ -> pressed.Set false)
127− .onPointerExited(fun _ -> pressed.Set false)
128− .width(200.)
129− .height(200.)
130− .margin(16.)
131− .onTapped (fun _ -> msg)
132− }
133−
134− let smallImageButton (asset: string) (msg: 'msg) =
135− Component($"img-button-{asset}") {
136− let! pressed = Context.State(false)
137− let offset = if pressed.Current then 2. else 4.
138−
139− (Grid() {
140− shadowRect offset
141−
142− Rectangle().fill (SolidColorBrush(Colors.White))
143−
144− Image(asset, Stretch.Uniform).width(24.).height(24.).center ()
145−
146− borderRect ()
147− })
148− .width(48.)
149− .height(48.)
150− .background(Brushes.Transparent)
151− .onPointerPressed(fun _ -> pressed.Set true)
152− .onPointerReleased(fun _ -> pressed.Set false)
153− .onPointerExited(fun _ -> pressed.Set false)
154− .margin(8.)
155− .onTapped (fun _ -> msg)
156− }
157−
158− let backButton (msg: 'msg) =
159− Component("back-button") {
160− let! pressed = Context.State(false)
161− let offset = if pressed.Current then 2. else 4.
162−
163− (Grid() {
164− shadowRect offset
165−
166− Rectangle().fill (SolidColorBrush(Colors.White))
167−
168− Image("avares://Skrepka/Assets/Images/left.png", Stretch.Uniform).width(24.).height(24.).center ()
169−
170− borderRect ()
171− })
172− .width(48.)
173− .height(48.)
174− .background(Brushes.Transparent)
175− .onPointerPressed(fun _ -> pressed.Set true)
176− .onPointerReleased(fun _ -> pressed.Set false)
177− .onPointerExited(fun _ -> pressed.Set false)
178− .margin(8.)
179− .onTapped (fun _ -> msg)
180− }
181−
182− let smallTextButton (text: string) (msg: 'msg) =
183− Component($"small-button-{text}") {
184− let! pressed = Context.State(false)
185− let offset = if pressed.Current then 2. else 4.
186−
187− (Grid() {
188− shadowRect offset
189−
190− Rectangle().fill (SolidColorBrush(Colors.White))
191−
192− TextBlock(text)
193− .fontFamily(Constants.fontFamily)
194− .fontWeight(FontWeight.Bold)
195− .fontSize(28.)
196− .textAlignment(TextAlignment.Center)
197− .center ()
198−
199− borderRect ()
200− })
201− .width(48.)
202− .height(48.)
203− .background(Brushes.Transparent)
204− .onPointerPressed(fun _ -> pressed.Set true)
205− .onPointerReleased(fun _ -> pressed.Set false)
206− .onPointerExited(fun _ -> pressed.Set false)
207− .margin(8.)
208− .onTapped (fun _ -> msg)
209− }
client/Constants.fsdeleted−16
@@ -1,16 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−
5−module Constants =
6− let accentColor = Color.FromRgb(98uy, 54uy, 255uy)
7− let animationDuration: int64 = 60
8− let easing = Avalonia.Animation.Easings.CubicEaseOut()
9− let fontFamily = "avares://Skrepka/Assets/Fonts#Unbounded"
10− let hkdfInfo = "skrepka-v1"
11− let profileId = "me"
12− let settingsId = "settings"
13− let maxBitmapCacheSize = 128
14− let pollRetryBaseMs = 3000
15− let outboxRetryDelayMs = 3000
16− let maxMessages = 200
client/Crypto.fsdeleted−249
@@ -1,249 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Text
5−open System.Security.Cryptography
6−open K4os.Compression.LZ4
7−
8−module Crypto =
9−
10− type Identity = { PrivKey: byte[]; PubKeyHex: string }
11−
12− let toHex (bytes: byte[]) : string =
13− Convert.ToHexString(bytes).ToLowerInvariant()
14−
15− let fromHex (hex: string) : byte[] =
16− Convert.FromHexString(hex)
17−
18− let tryFromHex (hex: string) : byte[] option =
19− try Some(Convert.FromHexString(hex))
20− with :? FormatException -> None
21−
22− // ── Platform-specific primitives ──
23−
24−#if MOBILE
25− open System.Runtime.InteropServices
26−
27− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
28− extern int private sodium_init()
29−
30− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
31− extern void private randombytes_buf(byte[] buf, unativeint size)
32−
33− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
34− extern int private crypto_sign_keypair(byte[] pk, byte[] sk)
35−
36− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
37− extern int private crypto_sign_detached(byte[] out, nativeint& siglen, byte[] m, uint64 mlen, byte[] sk)
38−
39− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
40− extern int private crypto_sign_verify_detached(byte[] s, byte[] m, uint64 mlen, byte[] pk)
41−
42− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
43− extern int private crypto_sign_ed25519_pk_to_curve25519(byte[] x25519_pk, byte[] ed25519_pk)
44−
45− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
46− extern int private crypto_sign_ed25519_sk_to_curve25519(byte[] x25519_sk, byte[] ed25519_sk)
47−
48− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
49− extern int private crypto_box_keypair(byte[] pk, byte[] sk)
50−
51− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
52− extern int private crypto_scalarmult(byte[] q, byte[] n, byte[] p)
53−
54− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
55− extern int private crypto_aead_xchacha20poly1305_ietf_encrypt(byte[] c, nativeint& clen, byte[] m, uint64 mlen, byte[] ad, uint64 adlen, nativeint nsec, byte[] npub, byte[] k)
56−
57− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
58− extern int private crypto_aead_xchacha20poly1305_ietf_decrypt(byte[] m, nativeint& mlen, nativeint nsec, byte[] c, uint64 clen, byte[] ad, uint64 adlen, byte[] npub, byte[] k)
59−
60− do
61− if sodium_init() < 0 then
62− failwith "sodium_init() failed"
63−
64− let private getRandomBytes n =
65− let buf = Array.zeroCreate n
66− randombytes_buf(buf, unativeint n)
67− buf
68−
69− let private generateSignKeyPair () =
70− let pk = Array.zeroCreate 32
71− let sk = Array.zeroCreate 64
72− if crypto_sign_keypair(pk, sk) <> 0 then failwith "crypto_sign_keypair failed"
73− pk, sk
74−
75− let private signDetached (message: byte[]) (sk: byte[]) =
76− let out = Array.zeroCreate 64
77− let mutable siglen = 0n
78− if crypto_sign_detached(out, &siglen, message, uint64 message.Length, sk) <> 0 then
79− failwith "crypto_sign_detached failed"
80− out
81−
82− let private verifyDetached (signature: byte[]) (message: byte[]) (pk: byte[]) =
83− crypto_sign_verify_detached(signature, message, uint64 message.Length, pk) = 0
84−
85− let private ed25519PkToCurve25519 (ed: byte[]) =
86− let x = Array.zeroCreate 32
87− if crypto_sign_ed25519_pk_to_curve25519(x, ed) <> 0 then failwith "pk_to_curve25519 failed"
88− x
89−
90− let private ed25519SkToCurve25519 (ed: byte[]) =
91− let x = Array.zeroCreate 32
92− if crypto_sign_ed25519_sk_to_curve25519(x, ed) <> 0 then failwith "sk_to_curve25519 failed"
93− x
94−
95− let private generateBoxKeyPair () =
96− let pk = Array.zeroCreate 32
97− let sk = Array.zeroCreate 32
98− if crypto_box_keypair(pk, sk) <> 0 then failwith "crypto_box_keypair failed"
99− pk, sk
100−
101− let private scalarMult (n: byte[]) (p: byte[]) =
102− let q = Array.zeroCreate 32
103− if crypto_scalarmult(q, n, p) <> 0 then failwith "crypto_scalarmult failed"
104− q
105−
106− let private aeadEncrypt (pt: byte[]) (nonce: byte[]) (key: byte[]) =
107− let ct = Array.zeroCreate (pt.Length + 16)
108− let mutable ctlen = 0n
109− if crypto_aead_xchacha20poly1305_ietf_encrypt(ct, &ctlen, pt, uint64 pt.Length, null, 0UL, 0n, nonce, key) <> 0 then
110− failwith "aead encrypt failed"
111− ct.[.. int ctlen - 1]
112−
113− let private aeadDecrypt (ct: byte[]) (nonce: byte[]) (key: byte[]) =
114− let pt = Array.zeroCreate ct.Length
115− let mutable ptlen = 0n
116− if crypto_aead_xchacha20poly1305_ietf_decrypt(pt, &ptlen, 0n, ct, uint64 ct.Length, null, 0UL, nonce, key) <> 0 then
117− failwith "aead decrypt failed"
118− pt.[.. int ptlen - 1]
119−
120−#else
121−
122− let private getRandomBytes n =
123− Sodium.SodiumCore.GetRandomBytes(n)
124−
125− let private generateSignKeyPair () =
126− let kp = Sodium.PublicKeyAuth.GenerateKeyPair()
127− kp.PublicKey, kp.PrivateKey
128−
129− let private signDetached (message: byte[]) (sk: byte[]) =
130− Sodium.PublicKeyAuth.SignDetached(message, sk)
131−
132− let private verifyDetached (signature: byte[]) (message: byte[]) (pk: byte[]) =
133− Sodium.PublicKeyAuth.VerifyDetached(signature, message, pk)
134−
135− let private ed25519PkToCurve25519 (pk: byte[]) =
136− Sodium.PublicKeyAuth.ConvertEd25519PublicKeyToCurve25519PublicKey(pk)
137−
138− let private ed25519SkToCurve25519 (sk: byte[]) =
139− Sodium.PublicKeyAuth.ConvertEd25519SecretKeyToCurve25519SecretKey(sk)
140−
141− let private generateBoxKeyPair () =
142− let kp = Sodium.PublicKeyBox.GenerateKeyPair()
143− kp.PublicKey, kp.PrivateKey
144−
145− let private scalarMult (n: byte[]) (p: byte[]) =
146− Sodium.ScalarMult.Mult(n, p)
147−
148− let private aeadEncrypt (pt: byte[]) (nonce: byte[]) (key: byte[]) =
149− Sodium.SecretAeadXChaCha20Poly1305.Encrypt(pt, nonce, key)
150−
151− let private aeadDecrypt (ct: byte[]) (nonce: byte[]) (key: byte[]) =
152− Sodium.SecretAeadXChaCha20Poly1305.Decrypt(ct, nonce, key)
153−
154−#endif
155−
156− // ── Shared algorithm (platform-independent) ──
157−
158− let private log msg = eprintfn $"[skrepka] {msg}"
159−
160− let identityFromPrivKey (privKey: byte[]) : Identity option =
161− if privKey.Length <> 64 then
162− None
163− else
164− Some
165− { PrivKey = privKey
166− PubKeyHex = toHex privKey.[32..63] }
167−
168− let generateIdentity () : Identity =
169− let _, sk = generateSignKeyPair ()
170− sk |> identityFromPrivKey |> Option.get
171−
172− // Domain-separation tag binding an auth signature to (target server, challenge).
173− // Distinguishes auth signatures from message signatures (recipientPub ++ compressed)
174− // and prevents a relay server from replaying the signature to a *different* server.
175− // `serverHost` is the bare lowercased hostname of the server being authenticated to.
176− let signChallenge (privKey: byte[]) (serverHost: string) (challenge: string) : string =
177− let message = Encoding.UTF8.GetBytes($"skrepka-auth-v1:{serverHost}:{challenge}")
178− signDetached message privKey |> toHex
179−
180− let private deriveKey ephPub recipientX25519Pub rawSecret =
181− let salt = Array.append ephPub recipientX25519Pub
182− let info = Encoding.UTF8.GetBytes(Constants.hkdfInfo)
183− HKDF.DeriveKey(HashAlgorithmName.SHA256, rawSecret, 32, salt, info)
184−
185− // Blob layout: [ephPub(32) | nonce(24) | ciphertext]
186− // encrypted payload: [senderPub(32) | signature(64) | compressed]
187− // signature signs (recipientPub ++ compressed) — prevents forwarding
188− let private ephPubLen = 32
189− let private nonceLen = 24
190− let private pubLen = 32
191− let private sigLen = 64
192− let private headerLen = ephPubLen + nonceLen
193− let private minBlobLen = headerLen + pubLen + sigLen + 16 // inner fields + AEAD tag
194−
195− let private deriveSymKey recipientPrivKey ephPub =
196− let recipientX25519Priv = ed25519SkToCurve25519 recipientPrivKey
197− let rawSecret = scalarMult recipientX25519Priv ephPub
198− let recipientEd25519Pub = recipientPrivKey.[32..63]
199− let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
200− deriveKey ephPub recipientX25519Pub rawSecret
201−
202− let encrypt (senderPrivKey: byte[]) (recipientEd25519Pub: byte[]) (plaintext: string) : byte[] option =
203− if senderPrivKey.Length <> 64 || recipientEd25519Pub.Length <> 32 then
204− None
205− else
206− try
207− let senderPubKey = senderPrivKey.[32..63]
208− let ephPub, ephPriv = generateBoxKeyPair ()
209− let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
210− let rawSecret = scalarMult ephPriv recipientX25519Pub
211− let key = deriveKey ephPub recipientX25519Pub rawSecret
212− let nonce = getRandomBytes 24
213− let ptBytes = Encoding.UTF8.GetBytes(plaintext)
214− let compressed = LZ4Pickler.Pickle(ptBytes)
215− let signedData = Array.append recipientEd25519Pub compressed
216− let signature = signDetached signedData senderPrivKey
217− let inner = Array.concat [| senderPubKey; signature; compressed |]
218− let ciphertext = aeadEncrypt inner nonce key
219−
220− Some(Array.concat [| ephPub; nonce; ciphertext |])
221− with ex ->
222− log $"encrypt error: {ex.Message}"
223− None
224−
225− let decrypt (recipientPrivKey: byte[]) (blob: byte[]) : (string * string) option =
226− if recipientPrivKey.Length <> 64 || blob.Length < minBlobLen then
227− None
228− else
229− let ephPub = blob.[.. ephPubLen - 1]
230− let nonce = blob.[ephPubLen .. headerLen - 1]
231− let ciphertext = blob.[headerLen ..]
232− let key = deriveSymKey recipientPrivKey ephPub
233− let decrypted = aeadDecrypt ciphertext nonce key
234−
235− if decrypted.Length < pubLen + sigLen then
236− None
237− else
238− let senderPub = decrypted.[.. pubLen - 1]
239− let signature = decrypted.[pubLen .. pubLen + sigLen - 1]
240− let compressed = decrypted.[pubLen + sigLen ..]
241−
242− let recipientPub = recipientPrivKey.[32..63]
243− let signedData = Array.append recipientPub compressed
244−
245− if verifyDetached signature signedData senderPub then
246− let plaintext = LZ4Pickler.Unpickle(compressed)
247− Some(Encoding.UTF8.GetString(plaintext), toHex senderPub)
248− else
249− None
client/Keyboard.fsdeleted−11
@@ -1,11 +0,0 @@
1−namespace Skrepka
2−
3−module Keyboard =
4− let private evt = Event<float>()
5− let heightChanged = evt.Publish
6− let triggerHeightChanged h = evt.Trigger h
7−
8−module SafeArea =
9− let private evt = Event<float * float>()
10− let insetsChanged = evt.Publish
11− let triggerInsetsChanged (top, bottom) = evt.Trigger(top, bottom)
client/Labels.fsdeleted−27
@@ -1,27 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Avalonia.Media.TextFormatting
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module Labels =
10− let private label size text =
11− TextBlock(text)
12− .fontFamily(FontFamily(Constants.fontFamily))
13− .fontWeight(FontWeight.Bold)
14− .fontSize(size)
15− .textWrapping (TextWrapping.Wrap)
16−
17− let h1 text = label 32. text
18− let h2 text = label 24. text
19− let h3 text = label 20. text
20− let h4 text = label 16. text
21− let h5 text = label 14. text
22−
23− let body text =
24− label 14. text |> _.fontWeight(FontWeight.Normal)
25−
26− let caption text =
27− label 12. text |> _.fontWeight(FontWeight.Normal)
client/Phonemic.fsdeleted−143
@@ -1,143 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−
5−module Phonemic =
6−
7− // Urbit @p syllable tables (256 prefixes, 256 suffixes)
8− // Each byte pair encodes as prefix[hi] + suffix[lo]
9−
10− let private prefixes =
11− [| "doz"; "mar"; "bin"; "wan"; "sam"; "lit"; "sig"; "hid"
12− "fid"; "lis"; "sog"; "dir"; "wac"; "sab"; "wis"; "sib"
13− "rig"; "sol"; "dop"; "mod"; "fog"; "lid"; "hop"; "dar"
14− "dor"; "lor"; "hod"; "fol"; "rin"; "tog"; "sil"; "mir"
15− "hol"; "pas"; "lac"; "rov"; "liv"; "dal"; "sat"; "lib"
16− "tab"; "han"; "tic"; "pid"; "tor"; "bol"; "fos"; "dot"
17− "los"; "dil"; "for"; "pil"; "ram"; "tir"; "win"; "tad"
18− "bic"; "dif"; "roc"; "wid"; "bis"; "das"; "mid"; "lop"
19− "ril"; "nar"; "dap"; "mol"; "san"; "loc"; "nov"; "sit"
20− "nid"; "tip"; "sic"; "rop"; "wit"; "nat"; "pan"; "min"
21− "rit"; "pod"; "mot"; "tam"; "tol"; "sav"; "pos"; "nap"
22− "nop"; "som"; "fin"; "fon"; "ban"; "mor"; "wor"; "sip"
23− "ron"; "nor"; "bot"; "wic"; "soc"; "wat"; "dol"; "mag"
24− "pic"; "dav"; "bid"; "bal"; "tim"; "tas"; "mal"; "lig"
25− "siv"; "tag"; "pad"; "sal"; "div"; "dac"; "tan"; "sid"
26− "fab"; "tar"; "mon"; "ran"; "nis"; "wol"; "mis"; "pal"
27− "las"; "dis"; "map"; "rab"; "tob"; "rol"; "lat"; "lon"
28− "nod"; "nav"; "fig"; "nom"; "nib"; "pag"; "sop"; "ral"
29− "bil"; "had"; "doc"; "rid"; "moc"; "pac"; "rav"; "rip"
30− "fal"; "tod"; "til"; "tin"; "hap"; "mic"; "fan"; "pat"
31− "tac"; "lab"; "mog"; "sim"; "son"; "pin"; "lom"; "ric"
32− "tap"; "fir"; "has"; "bos"; "bat"; "poc"; "hac"; "tid"
33− "hav"; "sap"; "lin"; "dib"; "hos"; "dab"; "bit"; "bar"
34− "rac"; "par"; "lod"; "dos"; "bor"; "toc"; "hil"; "mac"
35− "tom"; "dig"; "fil"; "fas"; "mit"; "hob"; "har"; "mig"
36− "hin"; "rad"; "mas"; "hal"; "rag"; "lag"; "fad"; "top"
37− "mop"; "hab"; "nil"; "nos"; "mil"; "fop"; "fam"; "dat"
38− "nol"; "din"; "hat"; "nac"; "ris"; "fot"; "rib"; "hoc"
39− "nim"; "lar"; "fit"; "wal"; "rap"; "sar"; "nal"; "mos"
40− "lan"; "don"; "dan"; "lad"; "dov"; "riv"; "bac"; "pol"
41− "lap"; "tal"; "pit"; "nam"; "bon"; "ros"; "ton"; "fod"
42− "pon"; "sov"; "noc"; "sor"; "lav"; "mat"; "mip"; "fip" |]
43−
44− let private suffixes =
45− [| "zod"; "nec"; "bud"; "wes"; "sev"; "per"; "sut"; "let"
46− "ful"; "pen"; "syt"; "dur"; "wep"; "ser"; "wyl"; "sun"
47− "ryp"; "syx"; "dyr"; "nup"; "heb"; "peg"; "lup"; "dep"
48− "dys"; "put"; "lug"; "hec"; "ryt"; "tyv"; "syd"; "nex"
49− "lun"; "mep"; "lut"; "sep"; "pes"; "del"; "sul"; "ped"
50− "tem"; "led"; "tul"; "met"; "wen"; "byn"; "hex"; "feb"
51− "pyl"; "dul"; "het"; "mev"; "rut"; "tyl"; "wyd"; "tep"
52− "bes"; "dex"; "sef"; "wyc"; "bur"; "der"; "nep"; "pur"
53− "rys"; "reb"; "den"; "nut"; "sub"; "pet"; "rul"; "syn"
54− "reg"; "tyd"; "sup"; "sem"; "wyn"; "rec"; "meg"; "net"
55− "sec"; "mul"; "nym"; "tev"; "web"; "sum"; "mut"; "nyx"
56− "rex"; "teb"; "fus"; "hep"; "ben"; "mus"; "wyx"; "sym"
57− "sel"; "ruc"; "dec"; "wex"; "syr"; "wet"; "dyl"; "myn"
58− "mes"; "det"; "bet"; "bel"; "tux"; "tug"; "myr"; "pel"
59− "syp"; "ter"; "meb"; "set"; "dut"; "deg"; "tex"; "sur"
60− "fel"; "tud"; "nux"; "rux"; "ren"; "wyt"; "nub"; "med"
61− "lyt"; "dus"; "neb"; "rum"; "tyn"; "seg"; "lyx"; "pun"
62− "res"; "red"; "fun"; "rev"; "ref"; "mec"; "ted"; "rus"
63− "bex"; "leb"; "dux"; "ryn"; "num"; "pyx"; "ryg"; "ryx"
64− "fep"; "tyr"; "tus"; "tyc"; "leg"; "nem"; "fer"; "mer"
65− "ten"; "lus"; "nus"; "syl"; "tec"; "mex"; "pub"; "rym"
66− "tuc"; "fyl"; "lep"; "deb"; "ber"; "mug"; "hut"; "tun"
67− "byl"; "sud"; "pem"; "dev"; "lur"; "def"; "bus"; "bep"
68− "run"; "mel"; "pex"; "dyt"; "byt"; "typ"; "lev"; "myl"
69− "wed"; "duc"; "fur"; "fex"; "nul"; "luc"; "len"; "ner"
70− "lex"; "rup"; "ned"; "lec"; "ryd"; "lyd"; "fen"; "wel"
71− "nyd"; "hus"; "rel"; "rud"; "nes"; "hes"; "fet"; "des"
72− "ret"; "dun"; "ler"; "nyr"; "seb"; "hul"; "ryl"; "lud"
73− "rem"; "lys"; "fyn"; "wer"; "ryc"; "sug"; "nys"; "nyl"
74− "lyn"; "dyn"; "dem"; "lux"; "fed"; "sed"; "bec"; "mun"
75− "lyr"; "tes"; "mud"; "nyt"; "byr"; "sen"; "weg"; "fyr"
76− "mur"; "tel"; "rep"; "teg"; "pec"; "nel"; "nev"; "fes" |]
77−
78− let private prefixMap =
79− prefixes |> Array.mapi (fun i s -> s, byte i) |> Map.ofArray
80−
81− let private suffixMap =
82− suffixes |> Array.mapi (fun i s -> s, byte i) |> Map.ofArray
83−
84− let toOb (bytes: byte[]) : string =
85− bytes
86− |> Array.chunkBySize 2
87− |> Array.map (fun pair -> $"{prefixes.[int pair.[0]]}{suffixes.[int pair.[1]]}")
88− |> String.concat "-"
89−
90− type ObValidation =
91− | Empty
92− | Partial of validPairs: int
93− | InvalidSyllable of syllable: string
94− | Valid
95−
96− let validateOb (s: string) : ObValidation =
97− let s = s.Trim()
98−
99− if s = "" then
100− Empty
101− else
102− let parts = s.Split('-', StringSplitOptions.RemoveEmptyEntries)
103−
104− // Don't flag the trailing part if still being typed
105− let completeParts =
106− if parts.Length > 0 && parts.[parts.Length - 1].Length < 6 then
107− parts.[.. parts.Length - 2]
108− else
109− parts
110−
111− let firstInvalid =
112− completeParts
113− |> Array.tryFind (fun sp ->
114− sp.Length <> 6
115− || not (Map.containsKey sp.[0..2] prefixMap)
116− || not (Map.containsKey sp.[3..5] suffixMap))
117−
118− match firstInvalid with
119− | Some s -> InvalidSyllable s
120− | None when completeParts.Length = 16 -> Valid
121− | None -> Partial completeParts.Length
122−
123− let fromOb (s: string) : byte[] option =
124− let pairs = s.Split('-', StringSplitOptions.RemoveEmptyEntries)
125−
126− if pairs.Length = 0 then
127− None
128− else
129−
130− let decoded =
131− pairs
132− |> Array.choose (fun sp ->
133− if sp.Length = 6 then
134− match Map.tryFind sp.[0..2] prefixMap, Map.tryFind sp.[3..5] suffixMap with
135− | Some hi, Some lo -> Some(hi, lo)
136− | _ -> None
137− else
138− None)
139−
140− if decoded.Length = pairs.Length then
141− decoded |> Array.collect (fun (hi, lo) -> [| hi; lo |]) |> Some
142− else
143− None
client/Platform/Desktop/AppIcon.icnsdeleted

Binary file not shown.

client/Platform/Desktop/Info.plistdeleted−24
@@ -1,24 +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>CFBundleName</key>
6− <string>Skrepka</string>
7− <key>CFBundleDisplayName</key>
8− <string>Skrepka</string>
9− <key>CFBundleIdentifier</key>
10− <string>com.skrepka.client</string>
11− <key>CFBundleVersion</key>
12− <string>1.0</string>
13− <key>CFBundleShortVersionString</key>
14− <string>1.0</string>
15− <key>CFBundlePackageType</key>
16− <string>APPL</string>
17− <key>CFBundleExecutable</key>
18− <string>Skrepka</string>
19− <key>CFBundleIconFile</key>
20− <string>AppIcon</string>
21− <key>NSHighResolutionCapable</key>
22− <true/>
23−</dict>
24−</plist>
client/Platform/Desktop/Program.fsdeleted−18
@@ -1,18 +0,0 @@
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/Platform/iOS/AppDelegate.fsdeleted−61
@@ -1,61 +0,0 @@
1−namespace Skrepka.iOS
2−
3−open System
4−open Foundation
5−open UIKit
6−open Avalonia
7−open Avalonia.iOS
8−open Fabulous.Avalonia
9−open Skrepka
10−
11−[<Register(nameof AppDelegate)>]
12−type AppDelegate() =
13− inherit AvaloniaAppDelegate<FabApplication>()
14−
15− let readSafeArea () =
16− let w = UIApplication.SharedApplication.KeyWindow
17− if not (isNull w) then
18− SafeArea.triggerInsetsChanged(float w.SafeAreaInsets.Top, float w.SafeAreaInsets.Bottom)
19−
20− override this.CreateAppBuilder() =
21− NSTimer.CreateScheduledTimer(0.1, false, fun _ ->
22− if this.Window <> null then
23− this.Window.BackgroundColor <- UIColor.White
24− readSafeArea())
25− |> ignore
26−
27− UIDevice.CurrentDevice.BeginGeneratingDeviceOrientationNotifications()
28− NSNotificationCenter.DefaultCenter.AddObserver(
29− UIDevice.OrientationDidChangeNotification,
30− Action<NSNotification>(fun _ ->
31− NSTimer.CreateScheduledTimer(0.1, false, fun _ -> readSafeArea()) |> ignore))
32− |> ignore
33−
34− NSNotificationCenter.DefaultCenter.AddObserver(
35− UIKeyboard.WillShowNotification,
36− Action<NSNotification>(fun n ->
37− let frame =
38− (n.UserInfo.ObjectForKey(UIKeyboard.FrameEndUserInfoKey) :?> NSValue)
39− .CGRectValue
40−
41− let h = float frame.Height
42−
43− let safeBottom =
44− let w = UIApplication.SharedApplication.KeyWindow
45− if isNull w then 0. else float w.SafeAreaInsets.Bottom
46−
47− Keyboard.triggerHeightChanged (max 0. (h - safeBottom))))
48− |> ignore
49−
50− NSNotificationCenter.DefaultCenter.AddObserver(
51− UIKeyboard.WillHideNotification,
52− Action<NSNotification>(fun _ -> Keyboard.triggerHeightChanged 0.))
53− |> ignore
54−
55− App.create().UseiOS()
56−
57−module Main =
58− [<EntryPoint>]
59− let main (args: string array) =
60− UIApplication.Main(args, null, typeof<AppDelegate>)
61− 0
client/Platform/iOS/Entitlements.plistdeleted−6
@@ -1,6 +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−</dict>
6−</plist>
client/Platform/iOS/Info.plistdeleted−43
@@ -1,43 +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>CFBundleDisplayName</key>
6− <string>Skrepka</string>
7− <key>CFBundleIdentifier</key>
8− <string>com.skrepka.client</string>
9− <key>CFBundleShortVersionString</key>
10− <string>1.0</string>
11− <key>CFBundleVersion</key>
12− <string>1.0</string>
13− <key>LSRequiresIPhoneOS</key>
14− <true/>
15− <key>MinimumOSVersion</key>
16− <string>15.0</string>
17− <key>UIDeviceFamily</key>
18− <array>
19− <integer>1</integer>
20− <integer>2</integer>
21− </array>
22− <key>UILaunchScreen</key>
23− <dict/>
24− <key>UIRequiredDeviceCapabilities</key>
25− <array>
26− <string>armv7</string>
27− </array>
28− <key>UISupportedInterfaceOrientations</key>
29− <array>
30− <string>UIInterfaceOrientationPortrait</string>
31− <string>UIInterfaceOrientationLandscapeLeft</string>
32− <string>UIInterfaceOrientationLandscapeRight</string>
33− </array>
34− <key>XSAppIconAssets</key>
35− <string>Assets.xcassets/AppIcon.appiconset</string>
36− <key>NSPhotoLibraryUsageDescription</key>
37− <string>Skrepka needs access to your photo library to set a profile photo.</string>
38− <key>UIStatusBarHidden</key>
39− <true/>
40− <key>UIViewControllerBasedStatusBarAppearance</key>
41− <false/>
42−</dict>
43−</plist>
client/Platform/iOS/PhotoPicker.fsdeleted−50
@@ -1,50 +0,0 @@
1−namespace Skrepka.iOS
2−
3−open System
4−open System.Threading.Tasks
5−open Foundation
6−open UIKit
7−open PhotosUI
8−
9−type private PickerDelegate(tcs: TaskCompletionSource<byte[] option>) =
10− inherit PHPickerViewControllerDelegate()
11−
12− override _.DidFinishPicking(controller, results) =
13− controller.DismissViewController(true, null)
14−
15− if results.Length = 0 then
16− tcs.TrySetResult None |> ignore
17− else
18− results[0].ItemProvider.LoadDataRepresentation(
19− "public.image",
20− Action<NSData, NSError>(fun data error ->
21− if isNull error && not (isNull data) then
22− tcs.TrySetResult(Some(data.ToArray())) |> ignore
23− else
24− tcs.TrySetResult None |> ignore))
25− |> ignore
26−
27−module PhotoPicker =
28− let mutable private prevent_gc: obj = null
29−
30− let pick () : Task<byte[] option> =
31− let tcs = TaskCompletionSource<byte[] option>()
32−
33− Avalonia.Threading.Dispatcher.UIThread.Post(fun () ->
34− let config = new PHPickerConfiguration()
35− config.Filter <- PHPickerFilter.ImagesFilter
36− config.SelectionLimit <- 1n
37−
38− let picker = new PHPickerViewController(config)
39− let d = new PickerDelegate(tcs)
40− prevent_gc <- box (picker, d)
41− tcs.Task.ContinueWith(fun (_: Task<byte[] option>) -> prevent_gc <- null) |> ignore
42− picker.Delegate <- d
43−
44− let window = UIApplication.SharedApplication.KeyWindow
45− if not (isNull window) && not (isNull window.RootViewController) then
46− window.RootViewController.PresentViewController(picker, true, null)
47− else
48− tcs.TrySetResult None |> ignore)
49−
50− tcs.Task
client/Platform/iOS/QRScanner.fsdeleted−113
@@ -1,113 +0,0 @@
1−namespace Skrepka.iOS
2−
3−open System
4−open System.Threading.Tasks
5−open Foundation
6−open UIKit
7−open AVFoundation
8−open CoreFoundation
9−
10−type private ScanDelegate(tcs: TaskCompletionSource<string option>, session: AVCaptureSession) =
11− inherit AVCaptureMetadataOutputObjectsDelegate()
12−
13− let mutable handled = false
14−
15− override _.DidOutputMetadataObjects(output, metadataObjects, connection) =
16− if not handled && metadataObjects.Length > 0 then
17− match metadataObjects.[0] with
18− | :? AVMetadataMachineReadableCodeObject as code when code.Type = AVMetadataObjectType.QRCode ->
19− handled <- true
20− session.StopRunning()
21−
22− DispatchQueue.MainQueue.DispatchAsync(fun () ->
23− let window = UIApplication.SharedApplication.KeyWindow
24−
25− if not (isNull window) && not (isNull window.RootViewController) then
26− window.RootViewController.DismissViewController(true, fun () ->
27− tcs.TrySetResult(Some code.StringValue) |> ignore)
28− else
29− tcs.TrySetResult(Some code.StringValue) |> ignore)
30− | _ -> ()
31−
32−module QRScanner =
33− let mutable private prevent_gc: obj = null
34−
35− let private presentScanner (tcs: TaskCompletionSource<string option>) =
36− let session = new AVCaptureSession()
37−
38− match AVCaptureDevice.GetDefaultDevice(AVMediaTypes.Video) with
39− | null -> tcs.TrySetResult None |> ignore
40− | device ->
41− let mutable error: NSError = null
42− let input = AVCaptureDeviceInput.FromDevice(device, &error)
43−
44− if isNull input then
45− tcs.TrySetResult None |> ignore
46− else
47− session.AddInput(input)
48−
49− let output = new AVCaptureMetadataOutput()
50− session.AddOutput(output)
51−
52− let del = new ScanDelegate(tcs, session)
53− output.SetDelegate(del, DispatchQueue.MainQueue)
54− output.MetadataObjectTypes <- AVMetadataObjectType.QRCode
55−
56− let preview = AVCaptureVideoPreviewLayer.FromSession(session)
57− preview.VideoGravity <- AVLayerVideoGravity.ResizeAspectFill
58− preview.Frame <- UIScreen.MainScreen.Bounds
59−
60− let vc = new UIViewController()
61− vc.View.BackgroundColor <- UIColor.Black
62− vc.View.Layer.AddSublayer(preview)
63−
64− let closeBtn = UIButton.FromType(UIButtonType.System)
65− closeBtn.SetTitle("Cancel", UIControlState.Normal)
66− closeBtn.SetTitleColor(UIColor.White, UIControlState.Normal)
67− closeBtn.Frame <- CoreGraphics.CGRect(20., 60., 100., 44.)
68−
69− closeBtn.TouchUpInside.Add(fun _ ->
70− session.StopRunning()
71−
72− vc.DismissViewController(true, fun () ->
73− tcs.TrySetResult None |> ignore))
74−
75− vc.View.AddSubview(closeBtn)
76−
77− prevent_gc <- box (session, del, preview, vc)
78−
79− tcs.Task.ContinueWith(fun (_: Task<string option>) -> prevent_gc <- null)
80− |> ignore
81−
82− session.StartRunning()
83−
84− let window = UIApplication.SharedApplication.KeyWindow
85−
86− if not (isNull window) && not (isNull window.RootViewController) then
87− vc.ModalPresentationStyle <- UIModalPresentationStyle.FullScreen
88− window.RootViewController.PresentViewController(vc, true, null)
89− else
90− session.StopRunning()
91− tcs.TrySetResult None |> ignore
92−
93− let scan () : Task<string option> =
94− let tcs = TaskCompletionSource<string option>()
95−
96− Avalonia.Threading.Dispatcher.UIThread.Post(fun () ->
97− let status = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Video)
98−
99− match status with
100− | AVAuthorizationStatus.Denied
101− | AVAuthorizationStatus.Restricted -> tcs.TrySetResult None |> ignore
102− | AVAuthorizationStatus.NotDetermined ->
103− AVCaptureDevice.RequestAccessForMediaType(
104− AVAuthorizationMediaType.Video,
105− fun granted ->
106− if granted then
107− DispatchQueue.MainQueue.DispatchAsync(fun () -> presentScanner tcs)
108− else
109− tcs.TrySetResult None |> ignore
110− )
111− | _ -> presentScanner tcs)
112−
113− tcs.Task
client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.jsondeleted−23
@@ -1,23 +0,0 @@
1−{
2− "images": [
3− { "size": "20x20", "idiom": "iphone", "filename": "icon-40.png", "scale": "2x" },
4− { "size": "20x20", "idiom": "iphone", "filename": "icon-60.png", "scale": "3x" },
5− { "size": "29x29", "idiom": "iphone", "filename": "icon-58.png", "scale": "2x" },
6− { "size": "29x29", "idiom": "iphone", "filename": "icon-87.png", "scale": "3x" },
7− { "size": "40x40", "idiom": "iphone", "filename": "icon-80.png", "scale": "2x" },
8− { "size": "40x40", "idiom": "iphone", "filename": "icon-120.png", "scale": "3x" },
9− { "size": "60x60", "idiom": "iphone", "filename": "icon-120.png", "scale": "2x" },
10− { "size": "60x60", "idiom": "iphone", "filename": "icon-180.png", "scale": "3x" },
11− { "size": "20x20", "idiom": "ipad", "filename": "icon-20.png", "scale": "1x" },
12− { "size": "20x20", "idiom": "ipad", "filename": "icon-40.png", "scale": "2x" },
13− { "size": "29x29", "idiom": "ipad", "filename": "icon-29.png", "scale": "1x" },
14− { "size": "29x29", "idiom": "ipad", "filename": "icon-58.png", "scale": "2x" },
15− { "size": "40x40", "idiom": "ipad", "filename": "icon-40.png", "scale": "1x" },
16− { "size": "40x40", "idiom": "ipad", "filename": "icon-80.png", "scale": "2x" },
17− { "size": "76x76", "idiom": "ipad", "filename": "icon-76.png", "scale": "1x" },
18− { "size": "76x76", "idiom": "ipad", "filename": "icon-152.png", "scale": "2x" },
19− { "size": "83.5x83.5", "idiom": "ipad", "filename": "icon-167.png", "scale": "2x" },
20− { "size": "1024x1024", "idiom": "ios-marketing", "filename": "icon-1024.png", "scale": "1x" }
21− ],
22− "info": { "version": 1, "author": "xcode" }
23−}
client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/Contents.jsondeleted−3
@@ -1,3 +0,0 @@
1−{
2− "info": { "version": 1, "author": "xcode" }
3−}
client/Platform/iOS/Resources/LaunchScreen.storyboarddeleted−25
@@ -1,25 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2−<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
3− <dependencies>
4− <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
5− <capability name="Safe area layout guides" minToolsVersion="9.0"/>
6− <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
7− </dependencies>
8− <scenes>
9− <!--View Controller-->
10− <scene sceneID="EHf-IW-A2E">
11− <objects>
12− <viewController id="01J-lp-oVM" sceneMemberID="viewController">
13− <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
14− <rect key="frame" x="0.0" y="0.0" width="375" height="812"/>
15− <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
16− <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
17− <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
18− </view>
19− </viewController>
20− <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
21− </objects>
22− <point key="canvasLocation" x="53" y="375"/>
23− </scene>
24− </scenes>
25−</document>
client/Protocol.fsdeleted−110
@@ -1,110 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Text.Json
5−
6−module Protocol =
7−
8− open Store
9− open Crypto
10− open ApiClient
11− open AppTypes
12−
13− let private log msg = eprintfn $"[skrepka] {msg}"
14−
15− let serializeEnvelope (ts: int64) =
16− function
17− | Envelope.TextMessage(id, body) ->
18− JsonSerializer.Serialize(
19− {| ``type`` = "text"
20− id = id
21− body = body
22− ts = ts |}
23− )
24− | Envelope.DeliveryAck ackIds ->
25− JsonSerializer.Serialize(
26− {| ``type`` = "delivery.ack"
27− ack_ids = ackIds
28− ts = ts |}
29− )
30− | Envelope.ProfileMessage profile ->
31− JsonSerializer.Serialize(
32− {| ``type`` = "profile"
33− display_name = profile.DisplayName
34− bio = profile.Bio
35− photo = profile.Photo
36− ts = ts |}
37− )
38−
39− let private tryGetJsonString (el: JsonElement) (name: string) =
40− match el.TryGetProperty(name) with
41− | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetString())
42− | _ -> None
43−
44− let private tryGetJsonBytes (el: JsonElement) (name: string) =
45− match el.TryGetProperty(name) with
46− | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetBytesFromBase64())
47− | _ -> None
48−
49− let private parseEnvelope (json: string) =
50− use doc = JsonDocument.Parse(json)
51− let root = doc.RootElement
52−
53− let ts =
54− match root.TryGetProperty("ts") with
55− | true, v when v.ValueKind = JsonValueKind.Number -> v.GetInt64()
56− | _ -> DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
57−
58− let envelope =
59− match tryGetJsonString root "type" with
60− | Some "text" ->
61− match tryGetJsonString root "id", tryGetJsonString root "body" with
62− | Some id, Some body -> Some(Envelope.TextMessage(id, body))
63− | _ -> None
64− | Some "delivery.ack" ->
65− match root.TryGetProperty("ack_ids") with
66− | true, v ->
67− let ackIds = v.EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
68− Some(Envelope.DeliveryAck ackIds)
69− | _ -> None
70− | Some "profile" ->
71− let displayName = tryGetJsonString root "display_name" |> Option.defaultValue ""
72− let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
73− let photo = tryGetJsonBytes root "photo"
74−
75− Some(
76− Envelope.ProfileMessage
77− { DisplayName = displayName
78− Bio = bio
79− Photo = photo }
80− )
81− | _ -> None
82−
83− envelope |> Option.map (fun e -> ts, e)
84−
85− let decryptEvent (privKey: byte[]) (event: PollEvent) =
86− try
87− let blob = Crypto.fromHex event.EncryptedBlob
88−
89− match Crypto.decrypt privKey blob with
90− | Some(plaintext, senderHex) ->
91− match parseEnvelope plaintext with
92− | Some(ts, envelope) ->
93− Ok
94− { Sender = senderHex
95− Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(ts)
96− Envelope = envelope }
97− | None -> Error "unknown envelope type"
98− | None -> Error "decrypt failed"
99− with ex ->
100− Error $"parse: {ex.Message}"
101−
102− let sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
103− async {
104− let ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
105− let payload = serializeEnvelope ts envelope
106−
107− match Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload with
108− | Some blob -> do! sendMessage session.Url session.Token recipientHex (Crypto.toHex blob)
109− | None -> log $"encrypt failed for {recipientHex}"
110− }
client/Skrepka.app/Contents/Info.plistdeleted−24
@@ -1,24 +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>CFBundleName</key>
6− <string>Skrepka</string>
7− <key>CFBundleDisplayName</key>
8− <string>Skrepka</string>
9− <key>CFBundleIdentifier</key>
10− <string>com.skrepka.client</string>
11− <key>CFBundleVersion</key>
12− <string>1.0</string>
13− <key>CFBundleShortVersionString</key>
14− <string>1.0</string>
15− <key>CFBundlePackageType</key>
16− <string>APPL</string>
17− <key>CFBundleExecutable</key>
18− <string>Skrepka</string>
19− <key>CFBundleIconFile</key>
20− <string>AppIcon</string>
21− <key>NSHighResolutionCapable</key>
22− <true/>
23−</dict>
24−</plist>
client/Skrepka.app/Contents/MacOS/Skrepkadeleted−1
@@ -1 +0,0 @@
1−../../../Skrepka
client/Skrepka.app/Contents/Resources/AppIcon.icnsdeleted

Binary file not shown.

client/Skrepka.fsprojdeleted−138
@@ -1,138 +0,0 @@
1−<Project Sdk="Microsoft.NET.Sdk">
2−
3− <PropertyGroup>
4− <OutputType>Exe</OutputType>
5− <TargetFrameworks>net10.0</TargetFrameworks>
6− <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios</TargetFrameworks>
7− <RootNamespace>Skrepka</RootNamespace>
8− <DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
9− <NoWarn>MT7091;NETSDK1206</NoWarn>
10− </PropertyGroup>
11−
12− <PropertyGroup>
13− <AvaloniaPlatform>$([MSBuild]::GetTargetPlatformIdentifier($(TargetFramework)))</AvaloniaPlatform>
14− <AvaloniaPlatform Condition="'$(AvaloniaPlatform)' == ''">Desktop</AvaloniaPlatform>
15− <AvaloniaPlatform Condition="'$(AvaloniaPlatform)' == 'ios'">iOS</AvaloniaPlatform>
16−
17− <iOSPlatformFolder>Platform/iOS/</iOSPlatformFolder>
18− <DesktopPlatformFolder>Platform/Desktop/</DesktopPlatformFolder>
19− </PropertyGroup>
20−
21− <PropertyGroup Condition="'$(AvaloniaPlatform)' == 'iOS'">
22− <DefineConstants>$(DefineConstants);MOBILE</DefineConstants>
23− <SupportedOSPlatformVersion>15.0</SupportedOSPlatformVersion>
24− <RuntimeIdentifier>iossimulator-arm64</RuntimeIdentifier>
25− <ValidateXcodeVersion>false</ValidateXcodeVersion>
26− <UseInterpreter>true</UseInterpreter>
27− <UseNativeHttpHandler>true</UseNativeHttpHandler>
28− </PropertyGroup>
29−
30− <ItemGroup>
31− <Compile Include="Constants.fs" />
32− <Compile Include="Styles.fs" />
33− <Compile Include="Crypto.fs" />
34− <Compile Include="ApiClient.fs" />
35− <Compile Include="Phonemic.fs" />
36− <Compile Include="Store.fs" />
37− <Compile Include="TextFields.fs" />
38− <Compile Include="Labels.fs" />
39− <Compile Include="Buttons.fs" />
40− <Compile Include="$(iOSPlatformFolder)PhotoPicker.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
41− <Compile Include="$(iOSPlatformFolder)QRScanner.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
42− <Compile Include="Keyboard.fs" />
43− <Compile Include="AppTypes.fs" />
44− <Compile Include="Protocol.fs" />
45− <Compile Include="ViewSetup.fs" />
46− <Compile Include="ViewSettings.fs" />
47− <Compile Include="ViewConversations.fs" />
48− <Compile Include="ViewChat.fs" />
49− <Compile Include="ViewAddContact.fs" />
50− <Compile Include="ViewEditProfile.fs" />
51− <Compile Include="App.fs" />
52− </ItemGroup>
53−
54− <ItemGroup>
55− <AvaloniaResource Include="Assets\Fonts\*" />
56− <AvaloniaResource Include="Assets\Images\*" />
57− </ItemGroup>
58−
59− <ItemGroup Condition="$(AvaloniaPlatform) == 'Desktop'">
60− <Compile Include="$(DesktopPlatformFolder)Program.fs" />
61− <None Include="$(DesktopPlatformFolder)AppIcon.icns" />
62− <None Include="$(DesktopPlatformFolder)Info.plist" />
63− <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
64− </ItemGroup>
65−
66− <Target Name="CreateMacAppBundle" AfterTargets="Build" Condition="$(AvaloniaPlatform) == 'Desktop' And $([MSBuild]::IsOSPlatform('osx'))">
67− <PropertyGroup>
68− <_AppBundle>$(TargetDir)Skrepka.app</_AppBundle>
69− </PropertyGroup>
70− <MakeDir Directories="$(_AppBundle)/Contents/MacOS" />
71− <MakeDir Directories="$(_AppBundle)/Contents/Resources" />
72− <Copy SourceFiles="$(DesktopPlatformFolder)Info.plist" DestinationFolder="$(_AppBundle)/Contents" />
73− <Copy SourceFiles="$(DesktopPlatformFolder)AppIcon.icns" DestinationFiles="$(_AppBundle)/Contents/Resources/AppIcon.icns" />
74− <Exec Command="ln -sf '../../../$(AssemblyName)' '$(_AppBundle)/Contents/MacOS/Skrepka'" />
75− </Target>
76−
77− <ItemGroup Condition="$(AvaloniaPlatform) == 'iOS'">
78− <None Include="$(iOSPlatformFolder)Entitlements.plist" />
79− <None Include="$(iOSPlatformFolder)Info.plist" LogicalName="Info.plist" />
80− <ImageAsset Include="$(iOSPlatformFolder)Resources\Assets.xcassets\Contents.json" />
81− <ImageAsset Include="$(iOSPlatformFolder)Resources\Assets.xcassets\AppIcon.appiconset\Contents.json" />
82− <ImageAsset Include="$(iOSPlatformFolder)Resources\Assets.xcassets\AppIcon.appiconset\*.png" />
83− <Compile Include="$(iOSPlatformFolder)AppDelegate.fs" />
84− <PackageReference Include="Avalonia.iOS" Version="11.3.0" />
85− </ItemGroup>
86−
87− <Target Name="FixBundlePlist" AfterTargets="Build" Condition="$(AvaloniaPlatform) == 'iOS'">
88− <PropertyGroup>
89− <_Plist>$(AppBundleDir)/Info.plist</_Plist>
90− <_PB>/usr/libexec/PlistBuddy</_PB>
91− </PropertyGroup>
92− <!-- Launch screen (full-screen, no letterboxing) -->
93− <Exec Command="$(_PB) -c 'Delete :UILaunchStoryboardName' '$(_Plist)'" ContinueOnError="true" />
94− <Exec Command="$(_PB) -c 'Add :UILaunchScreen dict' '$(_Plist)'" ContinueOnError="true" />
95− <!-- Privacy descriptions -->
96− <Exec Command="$(_PB) -c 'Add :NSCameraUsageDescription string Skrepka needs access to your camera to scan QR codes.' '$(_Plist)'" ContinueOnError="true" />
97− <Exec Command="$(_PB) -c 'Add :NSPhotoLibraryUsageDescription string Skrepka needs access to your photo library to set a profile photo.' '$(_Plist)'" ContinueOnError="true" />
98− <!-- App icon (fallback when actool produces empty output) -->
99− <Exec Command="$(_PB) -c 'Add :CFBundleIcons dict' '$(_Plist)'" ContinueOnError="true" />
100− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon dict' '$(_Plist)'" ContinueOnError="true" />
101− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles array' '$(_Plist)'" ContinueOnError="true" />
102− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles:0 string AppIcon60x60' '$(_Plist)'" ContinueOnError="true" />
103− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName string AppIcon' '$(_Plist)'" ContinueOnError="true" />
104− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad dict' '$(_Plist)'" ContinueOnError="true" />
105− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon dict' '$(_Plist)'" ContinueOnError="true" />
106− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles array' '$(_Plist)'" ContinueOnError="true" />
107− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles:0 string AppIcon60x60' '$(_Plist)'" ContinueOnError="true" />
108− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles:1 string AppIcon76x76' '$(_Plist)'" ContinueOnError="true" />
109− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconName string AppIcon' '$(_Plist)'" ContinueOnError="true" />
110− <!-- Copy icon PNGs to bundle (fallback when actool doesn't produce Assets.car) -->
111− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png" DestinationFiles="$(AppBundleDir)/AppIcon60x60@2x.png" SkipUnchangedFiles="true" />
112− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png" DestinationFiles="$(AppBundleDir)/AppIcon60x60@3x.png" SkipUnchangedFiles="true" />
113− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png" DestinationFiles="$(AppBundleDir)/AppIcon76x76@2x~ipad.png" SkipUnchangedFiles="true" />
114− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png" DestinationFiles="$(AppBundleDir)/AppIcon83.5x83.5@2x~ipad.png" SkipUnchangedFiles="true" />
115− </Target>
116−
117− <ItemGroup Condition="$(AvaloniaPlatform) == 'iOS' And $(RuntimeIdentifier.StartsWith('iossimulator'))">
118− <NativeReference Include="native/iossimulator-arm64/libsodium.a">
119− <Kind>Static</Kind>
120− <ForceLoad>true</ForceLoad>
121− </NativeReference>
122− </ItemGroup>
123−
124− <ItemGroup>
125− <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
126− <PackageReference Include="FSharp.Core" Version="11.0.100-preview3.26159.112" />
127− <PackageReference Include="Fabulous" Version="3.0.0-pre22" />
128− <PackageReference Include="Fabulous.Avalonia" Version="3.0.0-pre13" />
129− <PackageReference Include="Fabulous.Avalonia.ItemsRepeater" Version="3.0.0-pre13" />
130− <PackageReference Include="Sodium.Core" Version="1.4.0" />
131− <PackageReference Include="Donald" Version="10.0.2" />
132− <PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.2.26159.112" />
133− <PackageReference Include="K4os.Compression.LZ4" Version="1.3.8" />
134− <PackageReference Include="QRCoder" Version="1.6.0" />
135− <PackageReference Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" Condition="$(AvaloniaPlatform) == 'Desktop'" />
136− </ItemGroup>
137−
138−</Project>
client/Skrepka.slndeleted−28
@@ -1,28 +0,0 @@
1−
2−Microsoft Visual Studio Solution File, Format Version 12.00
3−# Visual Studio Version 17
4−VisualStudioVersion = 17.0.31611.283
5−MinimumVisualStudioVersion = 10.0.40219.1
6−EndProject
7−Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Skrepka", "Skrepka.fsproj", "{7242255B-8844-46E3-8185-AA02876BD3B5}"
8−EndProject
9−Global
10− GlobalSection(SolutionConfigurationPlatforms) = preSolution
11− Debug|Any CPU = Debug|Any CPU
12− Release|Any CPU = Release|Any CPU
13− EndGlobalSection
14− GlobalSection(ProjectConfigurationPlatforms) = postSolution
15− {7242255B-8844-46E3-8185-AA02876BD3B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
16− {7242255B-8844-46E3-8185-AA02876BD3B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
17− {7242255B-8844-46E3-8185-AA02876BD3B5}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
18− {7242255B-8844-46E3-8185-AA02876BD3B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
19− {7242255B-8844-46E3-8185-AA02876BD3B5}.Release|Any CPU.Build.0 = Release|Any CPU
20− {7242255B-8844-46E3-8185-AA02876BD3B5}.Release|Any CPU.Deploy.0 = Release|Any CPU
21− EndGlobalSection
22− GlobalSection(SolutionProperties) = preSolution
23− HideSolutionNode = FALSE
24− EndGlobalSection
25− GlobalSection(ExtensibilityGlobals) = postSolution
26− SolutionGuid = {61F7FB11-1E47-470C-91E2-47F8143E1572}
27− EndGlobalSection
28−EndGlobal
client/Store.fsdeleted−369
@@ -1,369 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Data
5−open System.IO
6−open Donald
7−open Microsoft.Data.Sqlite
8−
9−module Store =
10−
11− type Contact =
12− { Pubkey: string
13− Nickname: string
14− DisplayName: string
15− Bio: string
16− Photo: byte[] option
17− AddedDate: DateTimeOffset }
18−
19− type Profile =
20− { DisplayName: string
21− Bio: string
22− Photo: byte[] option }
23−
24− [<RequireQualifiedAccess>]
25− type DeliveryStatus =
26− | Sent
27− | Delivered
28−
29− type MessageDirection =
30− | Incoming
31− | Outgoing of DeliveryStatus
32−
33− type ChatMessage =
34− { Id: string
35− Body: string
36− Timestamp: DateTimeOffset
37− Direction: MessageDirection }
38−
39− type Data =
40− { Contacts: Map<string, Contact>
41− Messages: Map<string, ChatMessage list>
42− ServerUrl: string
43− PollCursor: int64 }
44−
45− let private log msg = eprintfn $"[skrepka] {msg}"
46−
47− let private appDataDir =
48− let baseDir =
49− let appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
50−
51− if String.IsNullOrEmpty(appData) then
52− let personal = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
53−
54− if String.IsNullOrEmpty(personal) then
55− Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Skrepka")
56− else
57− Path.Combine(personal, "Skrepka")
58− else
59− Path.Combine(appData, "Skrepka")
60−
61− Directory.CreateDirectory(baseDir) |> ignore
62− baseDir
63−
64− let private dbPath = Path.Combine(appDataDir, "skrepka.db")
65−
66− let private openConn () =
67− let conn = new SqliteConnection($"Data Source={dbPath}")
68− conn.Open()
69− conn |> Db.newCommand "PRAGMA busy_timeout = 5000" |> Db.exec
70− conn
71−
72− // ── Readers ──
73−
74− let private readContact (rd: IDataReader) : Contact =
75− { Pubkey = rd.ReadString "Pubkey"
76− Nickname = rd.ReadString "Nickname"
77− DisplayName = rd.ReadString "DisplayName"
78− Bio = rd.ReadString "Bio"
79− Photo = rd.ReadBytesOption "Photo"
80− AddedDate = rd.ReadInt64 "AddedDateUnix" |> DateTimeOffset.FromUnixTimeMilliseconds }
81−
82− let private readMessage (rd: IDataReader) : ChatMessage =
83− let isOutgoing = rd.ReadBoolean "IsOutgoing"
84− let status = rd.ReadInt32 "Status"
85−
86− { Id = rd.ReadString "Id"
87− Body = rd.ReadString "Body"
88− Timestamp = rd.ReadInt64 "TimestampUnix" |> DateTimeOffset.FromUnixTimeMilliseconds
89− Direction =
90− if not isOutgoing then
91− Incoming
92− else
93− Outgoing(
94− if status = 1 then
95− DeliveryStatus.Delivered
96− else
97− DeliveryStatus.Sent
98− ) }
99−
100− // ── Schema ──
101−
102− do
103− use conn = openConn ()
104− conn |> Db.newCommand "PRAGMA journal_mode=WAL" |> Db.exec
105−
106− conn
107− |> Db.newCommand
108− "CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
109− |> Db.exec
110−
111− conn
112− |> Db.newCommand
113− "CREATE TABLE IF NOT EXISTS contacts (Pubkey TEXT PRIMARY KEY, Nickname TEXT NOT NULL DEFAULT '', DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB, AddedDateUnix INTEGER NOT NULL DEFAULT 0)"
114− |> Db.exec
115−
116− // Migration: add AddedDateUnix to existing contacts tables
117− try
118− conn
119− |> Db.newCommand "ALTER TABLE contacts ADD COLUMN AddedDateUnix INTEGER NOT NULL DEFAULT 0"
120− |> Db.exec
121− with _ ->
122− ()
123−
124− conn
125− |> Db.newCommand
126− "CREATE TABLE IF NOT EXISTS messages (Id TEXT PRIMARY KEY, ConversationId TEXT NOT NULL, Body TEXT NOT NULL DEFAULT '', TimestampUnix INTEGER NOT NULL, IsOutgoing INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0)"
127− |> Db.exec
128−
129− conn
130− |> Db.newCommand
131− "CREATE TABLE IF NOT EXISTS settings (Id TEXT PRIMARY KEY, ServerUrl TEXT NOT NULL DEFAULT '', PollCursor INTEGER NOT NULL DEFAULT 0)"
132− |> Db.exec
133−
134− conn
135− |> Db.newCommand
136− "CREATE TABLE IF NOT EXISTS outbox (Id INTEGER PRIMARY KEY AUTOINCREMENT, RecipientHex TEXT NOT NULL, EnvelopeJson TEXT NOT NULL, CreatedAt INTEGER NOT NULL)"
137− |> Db.exec
138−
139− conn
140− |> Db.newCommand "CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(ConversationId)"
141− |> Db.exec
142−
143− // ── Identity ──
144−
145− let private identityPath = Path.Combine(appDataDir, "identity.key")
146−
147− let loadIdentity () =
148− async {
149− return
150− try
151− if File.Exists(identityPath) then
152− File.ReadAllText(identityPath)
153− |> Option.ofObj
154− |> Option.filter (fun s -> s <> "")
155− |> Option.bind (Convert.FromBase64String >> Crypto.identityFromPrivKey)
156− else
157− None
158− with ex ->
159− log $"loadIdentity error: {ex.Message}"
160− None
161− }
162−
163− let saveIdentity (identity: Crypto.Identity) =
164− async {
165− let b64 = Convert.ToBase64String identity.PrivKey
166− File.WriteAllText(identityPath, b64)
167− }
168−
169− // ── Profile ──
170−
171− let loadProfile () =
172− async {
173− try
174− use conn = openConn ()
175−
176− return
177− conn
178− |> Db.newCommand "SELECT DisplayName, Bio, Photo FROM profile WHERE Id = @Id"
179− |> Db.setParams [ "Id", SqlType.String Constants.profileId ]
180− |> Db.querySingle (fun rd ->
181− { DisplayName = rd.ReadString "DisplayName"
182− Bio = rd.ReadString "Bio"
183− Photo = rd.ReadBytesOption "Photo" })
184− with ex ->
185− log $"loadProfile error: {ex.Message}"
186− return None
187− }
188−
189− let saveProfile (profile: Profile) =
190− async {
191− try
192− use conn = openConn ()
193−
194− conn
195− |> Db.newCommand
196− "INSERT OR REPLACE INTO profile (Id, DisplayName, Bio, Photo) VALUES (@Id, @DisplayName, @Bio, @Photo)"
197− |> Db.setParams
198− [ "Id", SqlType.String Constants.profileId
199− "DisplayName", SqlType.String profile.DisplayName
200− "Bio", SqlType.String profile.Bio
201− "Photo", SqlType.sqlBytesOrNull profile.Photo ]
202− |> Db.exec
203− with ex ->
204− log $"saveProfile error: {ex.Message}"
205− }
206−
207− // ── Data ──
208−
209− let loadData () =
210− async {
211− try
212− use conn = openConn ()
213−
214− let settings =
215− conn
216− |> Db.newCommand "SELECT ServerUrl, PollCursor FROM settings WHERE Id = @Id"
217− |> Db.setParams [ "Id", SqlType.String Constants.settingsId ]
218− |> Db.querySingle (fun rd -> rd.ReadString "ServerUrl", rd.ReadInt64 "PollCursor")
219−
220− match settings with
221− | None -> return None
222− | Some(serverUrl, pollCursor) ->
223− let contacts =
224− conn
225− |> Db.newCommand "SELECT Pubkey, Nickname, DisplayName, Bio, Photo, AddedDateUnix FROM contacts"
226− |> Db.query readContact
227− |> List.map (fun c -> c.Pubkey, c)
228− |> Map.ofList
229−
230− let messages =
231− conn
232− |> Db.newCommand
233− $"SELECT Id, Body, TimestampUnix, IsOutgoing, Status, ConversationId FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY ConversationId ORDER BY TimestampUnix DESC) AS rn FROM messages) WHERE rn <= {Constants.maxMessages}"
234− |> Db.query (fun rd -> rd.ReadString "ConversationId", readMessage rd)
235− |> List.groupBy fst
236− |> List.map (fun (convId, pairs) -> convId, pairs |> List.map snd |> List.sortBy _.Timestamp)
237− |> Map.ofList
238−
239− return
240− Some
241− { Contacts = contacts
242− Messages = messages
243− ServerUrl = serverUrl
244− PollCursor = pollCursor }
245− with ex ->
246− log $"loadData error: {ex.Message}"
247− return None
248− }
249−
250− let saveData (data: Data) =
251− async {
252− try
253− use conn = openConn ()
254− use tx = conn.BeginTransaction()
255−
256− for c in data.Contacts.Values do
257− conn
258− |> Db.newCommand
259− "INSERT OR REPLACE INTO contacts (Pubkey, Nickname, DisplayName, Bio, Photo, AddedDateUnix) VALUES (@Pubkey, @Nickname, @DisplayName, @Bio, @Photo, @AddedDateUnix)"
260− |> Db.setParams
261− [ "Pubkey", SqlType.String c.Pubkey
262− "Nickname", SqlType.String c.Nickname
263− "DisplayName", SqlType.String c.DisplayName
264− "Bio", SqlType.String c.Bio
265− "Photo",
266− (match c.Photo with
267− | Some bytes -> SqlType.Bytes bytes
268− | None -> SqlType.Null)
269− "AddedDateUnix", SqlType.Int64(c.AddedDate.ToUnixTimeMilliseconds()) ]
270− |> Db.exec
271−
272− for KeyValue(convId, msgs) in data.Messages do
273− for m in msgs do
274− let isOutgoing, status =
275− match m.Direction with
276− | Incoming -> false, 0
277− | Outgoing DeliveryStatus.Delivered -> true, 1
278− | Outgoing DeliveryStatus.Sent -> true, 0
279−
280− conn
281− |> Db.newCommand
282− "INSERT OR REPLACE INTO messages (Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status) VALUES (@Id, @ConversationId, @Body, @TimestampUnix, @IsOutgoing, @Status)"
283− |> Db.setParams
284− [ "Id", SqlType.String m.Id
285− "ConversationId", SqlType.String convId
286− "Body", SqlType.String m.Body
287− "TimestampUnix", SqlType.Int64(m.Timestamp.ToUnixTimeMilliseconds())
288− "IsOutgoing", SqlType.Boolean isOutgoing
289− "Status", SqlType.Int32 status ]
290− |> Db.exec
291−
292− conn
293− |> Db.newCommand
294− "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)"
295− |> Db.setParams
296− [ "Id", SqlType.String Constants.settingsId
297− "ServerUrl", SqlType.String data.ServerUrl
298− "PollCursor", SqlType.Int64 data.PollCursor ]
299− |> Db.exec
300−
301− tx.Commit()
302− with ex ->
303− log $"saveData error: {ex.Message}"
304− }
305−
306− // ── Outbox ──
307−
308− let enqueueOutbox (recipientHex: string) (envelopeJson: string) =
309− async {
310− try
311− use conn = openConn ()
312−
313− conn
314− |> Db.newCommand
315− "INSERT INTO outbox (RecipientHex, EnvelopeJson, CreatedAt) VALUES (@RecipientHex, @EnvelopeJson, @CreatedAt)"
316− |> Db.setParams
317− [ "RecipientHex", SqlType.String recipientHex
318− "EnvelopeJson", SqlType.String envelopeJson
319− "CreatedAt", SqlType.Int64(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) ]
320− |> Db.exec
321− with ex ->
322− log $"enqueueOutbox error: {ex.Message}"
323− }
324−
325− let peekOutbox () =
326− async {
327− try
328− use conn = openConn ()
329−
330− return
331− conn
332− |> Db.newCommand "SELECT Id, RecipientHex, EnvelopeJson, CreatedAt FROM outbox ORDER BY Id LIMIT 1"
333− |> Db.querySingle (fun rd ->
334− {| Id = rd.ReadInt64 "Id"
335− RecipientHex = rd.ReadString "RecipientHex"
336− EnvelopeJson = rd.ReadString "EnvelopeJson" |})
337− with ex ->
338− log $"peekOutbox error: {ex.Message}"
339− return None
340− }
341−
342− let dequeueOutbox (id: int64) =
343− async {
344− try
345− use conn = openConn ()
346−
347− conn
348− |> Db.newCommand "DELETE FROM outbox WHERE Id = @Id"
349− |> Db.setParams [ "Id", SqlType.Int64 id ]
350− |> Db.exec
351− with ex ->
352− log $"dequeueOutbox error: {ex.Message}"
353− }
354−
355− let loadMessagesForConversation (conversationId: string) =
356− async {
357− try
358− use conn = openConn ()
359−
360− return
361− conn
362− |> Db.newCommand
363− "SELECT Id, Body, TimestampUnix, IsOutgoing, Status FROM messages WHERE ConversationId = @convId ORDER BY TimestampUnix"
364− |> Db.setParams [ "convId", SqlType.String conversationId ]
365− |> Db.query readMessage
366− with ex ->
367− log $"loadMessagesForConversation error: {ex.Message}"
368− return []
369− }
client/Styles.fsdeleted−96
@@ -1,96 +0,0 @@
1−namespace Skrepka
2−
3−
4−module Styles =
5− open Avalonia
6− open Avalonia.Controls
7− open Avalonia.Controls.Presenters
8− open Avalonia.Media
9− open Avalonia.Styling
10− open Fabulous
11− open Fabulous.Avalonia
12−
13− open type Fabulous.Avalonia.View
14−
15− let style (selector: Selector -> Selector) (setters: Setter list) =
16− let s = Style(selector)
17−
18− for setter in setters do
19− s.Setters.Add(setter)
20−
21− s :> IStyle
22−
23− let withBottomBar safeAreaBottom (bar: WidgetBuilder<'msg, 'a>) (content: WidgetBuilder<'msg, 'b>) =
24− Grid([], [ Dimension.Star; Dimension.Auto ]) {
25− AnyView(content).gridRow (0)
26−
27− Border(AnyView(bar))
28− .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
29− .borderBrush(SolidColorBrush(Colors.Black))
30− .background(Constants.accentColor)
31− .padding(Avalonia.Thickness(0., 0., 0., safeAreaBottom))
32− .gridRow (1)
33− }
34−
35− let noListBoxPadding () =
36− [ style
37− _.OfType<ListBoxItem>()
38− [ Setter(ListBoxItem.PaddingProperty, Thickness(0.))
39− Setter(ListBoxItem.MinHeightProperty, box 0.) ]
40− style
41− (_.OfType<ListBoxItem>().Template().OfType<ContentPresenter>().Name("PART_ContentPresenter"))
42− [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]
43−
44− let noListBoxSelection () =
45− [ style
46− (_.OfType<ListBoxItem>()
47− .Class(":selected")
48− .Template()
49− .OfType<ContentPresenter>()
50− .Name("PART_ContentPresenter"))
51− [ Setter(ContentPresenter.BackgroundProperty, Brushes.Transparent) ]
52− style
53− (_.OfType<ListBoxItem>()
54− .Class(":pointerover")
55− .Template()
56− .OfType<ContentPresenter>()
57− .Name("PART_ContentPresenter"))
58− [ Setter(ContentPresenter.BackgroundProperty, Brushes.Transparent) ] ]
59−
60− let private bitmapCache =
61− System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
62−
63− let private bitmapInsertOrder = System.Collections.Concurrent.ConcurrentQueue<int>()
64−
65− let cachedBitmap (bytes: byte[]) =
66− let h =
67− System.HashCode.Combine(
68− bytes.Length,
69− (if bytes.Length > 3 then
70− System.BitConverter.ToInt32(bytes, 0)
71− else
72− 0),
73− (if bytes.Length > 7 then
74− System.BitConverter.ToInt32(bytes, bytes.Length / 2)
75− else
76− 0),
77− (if bytes.Length > 11 then
78− System.BitConverter.ToInt32(bytes, bytes.Length - 4)
79− else
80− 0)
81− )
82−
83− while bitmapCache.Count >= Constants.maxBitmapCacheSize do
84− match bitmapInsertOrder.TryDequeue() with
85− | true, oldest ->
86− match bitmapCache.TryRemove(oldest) with
87− | true, bmp -> bmp.Dispose()
88− | _ -> ()
89− | _ -> bitmapCache.Clear()
90−
91− bitmapCache.GetOrAdd(
92− h,
93− fun _ ->
94− bitmapInsertOrder.Enqueue(h)
95− new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(bytes))
96− )
client/TextFields.fsdeleted−48
@@ -1,48 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module TextFields =
10− open Avalonia.Layout
11− open Avalonia.Styling
12−
13− let private whiteBg () =
14− let bg = Avalonia.Media.Immutable.ImmutableSolidColorBrush(Colors.White)
15− let setter = Setter(Avalonia.Controls.Border.BackgroundProperty, bg)
16−
17− [ Skrepka.Styles.style
18− (_.OfType<Avalonia.Controls.TextBox>()
19− .Class(":pointerover")
20− .Template()
21− .OfType<Avalonia.Controls.Border>()
22− .Name("PART_BorderElement"))
23− [ setter ]
24− Skrepka.Styles.style
25− (_.OfType<Avalonia.Controls.TextBox>()
26− .Class(":focus")
27− .Template()
28− .OfType<Avalonia.Controls.Border>()
29− .Name("PART_BorderElement"))
30− [ setter ] ]
31−
32− let textField watermark value msg =
33− (Grid() {
34− TextBox(value, msg).watermark(watermark).fontFamily(Constants.fontFamily).verticalAlignment(VerticalAlignment.Stretch).verticalContentAlignment(VerticalAlignment.Center).background(SolidColorBrush(Colors.White))
35− Rectangle().stroke(Colors.Black).strokeThickness (4.)
36− }).styles (whiteBg ())
37−
38− let errorTextField value msg =
39− (Grid() {
40− TextBox(value, msg).fontFamily(Constants.fontFamily).verticalAlignment(VerticalAlignment.Stretch).verticalContentAlignment(VerticalAlignment.Center).background(SolidColorBrush(Colors.White))
41− Rectangle().stroke(Colors.Red).strokeThickness (4.)
42− }).styles (whiteBg ())
43−
44− let multilineTextField value msg =
45− Grid() {
46− TextBox(value, msg).fontFamily(Constants.fontFamily).acceptsReturn(true).height (150)
47− Rectangle().stroke(Colors.Black).strokeThickness (4.)
48− }
client/ViewAddContact.fsdeleted−70
@@ -1,70 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewAddContact =
9−
10− open AppTypes
11− open Buttons
12− open Styles
13− open Labels
14− open TextFields
15−
16− let private qrBytes (text: string) =
17− use qr = new QRCoder.QRCodeGenerator()
18− use data = qr.CreateQrCode(text, QRCoder.QRCodeGenerator.ECCLevel.M)
19− use png = new QRCoder.PngByteQRCode(data)
20− png.GetGraphic(8, [| 0uy; 0uy; 0uy |], [| 255uy; 255uy; 255uy |])
21−
22− let viewAddContact model (cpk: string) =
23− let content =
24− ScrollViewer(
25− (VStack(16.) {
26−
27− h2 ("Share your address")
28−
29− match model.Auth with
30− | Identified(id, _) ->
31− let ob = hexToOb id.PubKeyHex
32−
33− Image(Styles.cachedBitmap (qrBytes ob), Stretch.Uniform).maxWidth(200.).centerHorizontal ()
34−
35− body(ob).fontSize(12.).centerText ()
36−
37− smallButton Primary "Copy Address" CopyPubKey
38− | NoIdentity -> ()
39−
40− h2 "Enter someone else's address"
41−
42− smallButton Secondary "Scan QR Code" DoScanQR
43−
44− let cpkTrimmed: string = cpk.Trim()
45−
46− let isValid =
47− Phonemic.fromOb cpkTrimmed
48− |> Option.orElseWith (fun () -> Crypto.tryFromHex cpkTrimmed)
49− |> Option.filter (fun bytes -> bytes.Length = 32)
50− |> Option.isSome
51−
52− let hasError = cpkTrimmed <> "" && not isValid
53−
54− if hasError then
55− errorTextField cpk (fun key -> SetPage(AddContact(key)))
56− else
57− textField "" cpk (fun key -> SetPage(AddContact(key)))
58−
59−
60− if isValid then
61− smallButton Primary "Add" DoSaveContact
62− else
63− disabledSmallButton Primary "Add" DoSaveContact
64− })
65− .margin (20.)
66− )
67−
68− let bar = HStack(8.) { backButton (SetPage Conversations) }
69−
70− withBottomBar model.SafeAreaBottom bar content
client/ViewChat.fsdeleted−172
@@ -1,172 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Layout
4−open Avalonia.Styling
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module ViewChat =
10−
11− open Store
12− open AppTypes
13− open Buttons
14− open TextFields
15−
16− let private ensureMarchingAntsStyle =
17− lazy
18− (let s =
19− Avalonia.Styling.Style(fun sel ->
20− sel.OfType<Avalonia.Controls.Shapes.Rectangle>().Class("marching"))
21−
22− let anim = Avalonia.Animation.Animation()
23− anim.Duration <- System.TimeSpan.FromSeconds 3.
24− anim.IterationCount <- Avalonia.Animation.IterationCount.Infinite
25− anim.Easing <- Avalonia.Animation.Easings.LinearEasing()
26− let kf1 = Avalonia.Animation.KeyFrame()
27−
28− kf1.Setters.Add(
29− Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 0.)
30− )
31−
32− kf1.Cue <- Avalonia.Animation.Cue(0.)
33− let kf2 = Avalonia.Animation.KeyFrame()
34−
35− kf2.Setters.Add(
36− Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 6.)
37− )
38−
39− kf2.Cue <- Avalonia.Animation.Cue(1.)
40− anim.Children.Add(kf1)
41− anim.Children.Add(kf2)
42− s.Animations.Add(anim)
43− Avalonia.Application.Current.Styles.Add(s))
44−
45− let private scrollRef = Fabulous.ViewRef<Avalonia.Controls.ScrollViewer>()
46− let mutable private lastMsgCount = 0
47− let mutable private pageSize = 50
48− let mutable private loadingMore = false
49− let mutable private isAtBottom = true
50−
51− let private scrollToEnd () =
52− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
53− match scrollRef.TryValue with
54− | Some sv -> sv.ScrollToEnd()
55− | None -> ()
56− , Avalonia.Threading.DispatcherPriority.Background) |> ignore
57−
58− let private messageItem m =
59− let align, cols, col =
60− match m.Direction with
61− | Outgoing _ -> HorizontalAlignment.Right, [ Dimension.Stars 3.; Dimension.Stars 7. ], 1
62− | Incoming -> HorizontalAlignment.Left, [ Dimension.Stars 7.; Dimension.Stars 3. ], 0
63−
64− Grid(cols, [ Dimension.Auto ]) {
65− Border(
66− (Grid() {
67− TextBlock(m.Body)
68− .fontSize(14.)
69− .fontFamily(Constants.fontFamily)
70− .fontWeight(Avalonia.Media.FontWeight.Bold)
71− .textWrapping(Avalonia.Media.TextWrapping.Wrap)
72− .padding (8.)
73−
74− match m.Direction with
75− | Outgoing status ->
76− let isSent = status = DeliveryStatus.Sent
77−
78− if isSent then
79− ensureMarchingAntsStyle.Force()
80−
81− Rectangle()
82− .stroke(Avalonia.Media.Colors.Black)
83− .strokeThickness(4.)
84− .strokeDashArray([ 4.; 2. ])
85− .isHitTestVisible(false)
86− .classes("marching")
87− else
88− Rectangle()
89− .stroke(Avalonia.Media.Colors.Black)
90− .strokeThickness(4.)
91− .isHitTestVisible(false)
92− | Incoming ->
93− Rectangle()
94− .stroke(Avalonia.Media.Colors.Black)
95− .strokeThickness(4.)
96− .isHitTestVisible (false)
97− }))
98− .horizontalAlignment(align)
99− .margin(4.)
100− .gridColumn (col)
101− }
102−
103− let viewChat model pk compose =
104− let name = contactName model.Contacts pk
105− let msgs = messagesFor pk model.Messages
106−
107− let bar =
108− (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], [ Dimension.Auto ]) {
109− (backButton (SetPage Conversations)).gridColumn (0)
110−
111− (textField "Message..." compose (fun text -> SetPage(Chat(pk, text))))
112− .margin(8.)
113− .gridColumn (1)
114−
115− (smallImageButton "avares://Skrepka/Assets/Images/send.png" DoSend).gridColumn (2)
116− })
117− .horizontalAlignment (HorizontalAlignment.Stretch)
118−
119− let content =
120− (Grid([ Dimension.Star ], [ Dimension.Auto; Dimension.Auto; Dimension.Star ]) {
121− TextBlock(name).fontSize(20.).padding(8.).gridRow (0)
122−
123− (viewErrorBanner model.Error).gridRow (1)
124−
125− (let msgView =
126− if msgs.IsEmpty then
127− lastMsgCount <- 0
128− pageSize <- 50
129− AnyView(TextBlock("No messages yet").padding(8.).fontSize (14.))
130− else
131− if msgs.Length <> lastMsgCount then
132− lastMsgCount <- msgs.Length
133− if isAtBottom then scrollToEnd ()
134−
135− let skip = max 0 (msgs.Length - pageSize)
136− let visible = msgs |> List.skip skip
137−
138− AnyView(
139− ScrollViewer(
140− VStack(0.) {
141− for m in visible do
142− messageItem m
143− })
144− .reference(scrollRef)
145− .onLoaded(fun _ ->
146− pageSize <- 50
147− loadingMore <- false
148− isAtBottom <- true
149− scrollToEnd ()
150− DismissError)
151− .onScrollChanged(fun args ->
152− let sv = args.Source :?> Avalonia.Controls.ScrollViewer
153− isAtBottom <- sv.Offset.Y + sv.Viewport.Height >= sv.Extent.Height - 20.
154− if sv.Offset.Y < 50. && not loadingMore && skip > 0 then
155− loadingMore <- true
156− let oldHeight = sv.Extent.Height
157− pageSize <- min msgs.Length (pageSize + 50)
158− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
159− match scrollRef.TryValue with
160− | Some sv2 ->
161− let newHeight = sv2.Extent.Height
162− sv2.Offset <- Avalonia.Vector(0., sv2.Offset.Y + (newHeight - oldHeight))
163− loadingMore <- false
164− | None -> loadingMore <- false
165− , Avalonia.Threading.DispatcherPriority.Background) |> ignore
166− SetPage(Chat(pk, compose))))
167−
168− msgView).gridRow (2)
169− })
170−
171− let barInset = if model.KeyboardHeight > 0. then 0. else model.SafeAreaBottom
172− Styles.withBottomBar barInset bar content
client/ViewConversations.fsdeleted−101
@@ -1,101 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewConversations =
9−
10− open AppTypes
11− open Buttons
12− open TextFields
13− open Styles
14− open Avalonia.Layout
15−
16− let viewConversations model =
17− let contacts =
18− model.Contacts.Values
19− |> Seq.toList
20− |> List.sortBy (fun c ->
21− messagesFor c.Pubkey model.Messages
22− |> List.tryLast
23− |> Option.map (fun m -> m.Timestamp)
24− |> Option.defaultValue c.AddedDate)
25−
26− let content =
27− if contacts.IsEmpty then
28− AnyView(
29− TextBlock("No contacts yet. Tap + to add one.")
30− .centerText()
31− .foreground (SolidColorBrush(Colors.Gray))
32− )
33− else
34− AnyView(
35− ScrollViewer(
36− VStack(0.) {
37− for c in contacts do
38− let name = contactName model.Contacts c.Pubkey
39−
40− let preview =
41− messagesFor c.Pubkey model.Messages
42− |> List.tryLast
43− |> Option.map (fun m ->
44− if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
45− |> Option.defaultValue ""
46−
47− Border(
48− (HStack(8.) {
49−
50− (Grid() {
51− (match c.Photo with
52− | None ->
53− Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
54− .margin (8.)
55− | Some i -> Image(cachedBitmap i, Stretch.UniformToFill))
56− .clipToBounds (true)
57−
58− Rectangle().stroke(Colors.Black).strokeThickness (4.)
59− })
60− .width(40.)
61− .height (40.)
62−
63− VStack(2.) {
64− TextBlock(name)
65− .fontFamily(Constants.fontFamily)
66− .fontWeight(FontWeight.Bold)
67− .fontSize (16.)
68−
69− if preview <> "" then
70− TextBlock(preview).fontSize(12.).fontWeight (FontWeight.Bold)
71− }
72−
73− })
74− .margin (8.)
75− )
76− .borderThickness(Avalonia.Thickness(0., 0., 0., 4.))
77− .borderBrush(SolidColorBrush(Colors.Black))
78− .background(Colors.Transparent)
79− .onTapped (fun _ -> SetPage(Chat(c.Pubkey, "")))
80− }
81− )
82− .background(Colors.White)
83− .verticalAlignment (VerticalAlignment.Bottom)
84− )
85−
86− let bar =
87− VStack(0.) {
88− TextBlock($"{connStatusLabel model.Auth} | {model.PollStatus}")
89− .fontSize(10.)
90− .foreground(SolidColorBrush(Colors.Gray))
91− .centerText ()
92−
93− (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], [ Dimension.Auto ]) {
94− (smallImageButton "avares://Skrepka/Assets/Images/user.png" (SetPage Settings)).gridColumn (0)
95− (textField "" "" Search).margin(8.).verticalAlignment(VerticalAlignment.Stretch).gridColumn (1)
96− (smallTextButton "+" (SetPage(AddContact("")))).gridColumn (2)
97− })
98− .horizontalAlignment (HorizontalAlignment.Stretch)
99− }
100−
101− withBottomBar model.SafeAreaBottom bar content
client/ViewEditProfile.fsdeleted−41
@@ -1,41 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewEditProfile =
9−
10− open AppTypes
11− open Buttons
12− open TextFields
13− open Styles
14−
15− let viewEditProfile model displayName bio (photo: byte[] option) =
16− let content =
17− ScrollViewer(
18− (VStack(16.) {
19− viewErrorBanner model.Error
20−
21− imageButton photo DoPickPhoto
22−
23− TextBlock("Name").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 24, 0, -8)
24− (textField "" displayName (fun text -> SetPage(EditProfile(text, bio, photo)))).margin (0, 0, 0, 8)
25−
26−
27− TextBlock("Bio").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 0, 0, -8)
28−
29− (multilineTextField bio (fun text -> SetPage(EditProfile(displayName, text, photo))))
30− .margin (0, 0, 0, 8)
31− })
32− .margin (20.)
33− )
34−
35− let bar =
36− HStack(8.) {
37− backButton (SetPage Settings)
38− (button Secondary "save" DoSaveProfile).margin (8.)
39− }
40−
41− withBottomBar model.SafeAreaBottom bar content
client/ViewSettings.fsdeleted−57
@@ -1,57 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewSettings =
9−
10− open AppTypes
11−
12− let viewSettings model =
13− ScrollViewer(
14− (VStack(16.) {
15− TextBlock("Settings").fontSize(24.).centerText ()
16−
17− match model.Auth with
18− | Identified(id, conn) ->
19− TextBlock("Your Public Key:")
20−
21− TextBlock(truncKey id.PubKeyHex).fontSize (14.)
22−
23− Button("Copy Public Key", CopyPubKey)
24−
25− TextBlock("Profile:").fontSize (18.)
26−
27− let displayName, bio, photo =
28− match model.Profile with
29− | Some p -> p.DisplayName, p.Bio, p.Photo
30− | None -> "", "", None
31−
32− if displayName <> "" then
33− TextBlock($"Name: {displayName}").fontSize (14.)
34−
35− if bio <> "" then
36− TextBlock($"Bio: {bio}").fontSize(14.).foreground (SolidColorBrush(Colors.DimGray))
37−
38− Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
39−
40− TextBlock("Server:")
41−
42− match conn with
43− | Online _ -> TextBlock(model.ServerUrl).fontSize (14.)
44− | _ -> TextBox(model.ServerUrl, SetServerUrl)
45−
46− viewErrorBanner model.Error
47−
48− match conn with
49− | Offline -> Button("Connect", DoConnect)
50− | Connecting -> TextBlock("Connecting...").centerText ()
51− | Online _ ->
52− Button("Disconnect", DoDisconnect)
53− Button("Go to Conversations", SetPage Conversations)
54− | NoIdentity -> ()
55− })
56− .margin (20.)
57− )
client/ViewSetup.fsdeleted−30
@@ -1,30 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewSetup =
9−
10− open AppTypes
11− open Buttons
12−
13− let viewSetup () =
14− (VStack(24.) {
15− Image("avares://Skrepka/Assets/Images/logo.png", Avalonia.Media.Stretch.Uniform)
16− .margin(8., 0., 8., 0.)
17− .maxWidth (300.)
18−
19− TextBlock("Skrepka")
20− .fontSize(32.)
21− .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
22− .fontWeight(FontWeight.Bold)
23− .centerText ()
24−
25− TextBlock("").height (50.)
26−
27− button Primary "Next" GenIdentity
28− })
29− .margin(30.)
30− .centerVertical ()
client/msbuild.binlogdeleted

Binary file not shown.

client/native/iossimulator-arm64/libsodium.adeleted

Binary file not shown.

client/native/maccatalyst-arm64/libsodium.adeleted

Binary file not shown.

client/run-ios.shdeleted−31
@@ -1,31 +0,0 @@
1−#!/bin/bash
2−set -e
3−cd "$(dirname "$0")"
4−
5−DEVICE="${1:-iPhone 17 Pro}"
6−BUNDLE_ID="com.skrepka.client"
7−APP="bin/Debug/net10.0-ios/iossimulator-arm64/Skrepka.app"
8−
9−dotnet build Skrepka.fsproj -f net10.0-ios
10−
11−UDID=$(xcrun simctl list devices available -j | python3 -c "
12−import json, sys
13−devices = json.load(sys.stdin)['devices']
14−name = '$DEVICE'
15−for runtime, devs in devices.items():
16− for d in devs:
17− if d['name'] == name and d['isAvailable']:
18− print(d['udid']); sys.exit()
19−print('', end=''); sys.exit(1)
20−")
21−
22−if [ -z "$UDID" ]; then
23− echo "Simulator '$DEVICE' not found. Available:"
24− xcrun simctl list devices available | grep -i iphone
25− exit 1
26−fi
27−
28−xcrun simctl boot "$UDID" 2>/dev/null || true
29−open -a Simulator
30−xcrun simctl install "$UDID" "$APP"
31−xcrun simctl launch "$UDID" "$BUNDLE_ID"
client/run-mac.shdeleted−6
@@ -1,6 +0,0 @@
1−#!/bin/bash
2−set -e
3−cd "$(dirname "$0")"
4−dotnet build -f net10.0
5−export DOTNET_ROOT="$(dotnet --info | grep 'Base Path' | sed 's/.*: *//' | sed 's|/sdk/.*||')"
6−exec bin/Debug/net10.0/Skrepka.app/Contents/MacOS/Skrepka