aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-11-27 23:38:04 +0100
commit213710bf5bd6413c747bf126db50816ef5de5a6e (patch)
tree912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline
parentf466b21dc02f78c984ba6748e703f6780a7a0db4 (diff)
parent6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff)
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Assets.xcassets/Classic/174.imageset/174@2x.pngbin0 -> 1096 bytes
-rw-r--r--Hotline/Assets.xcassets/Classic/174.imageset/174@3x.pngbin0 -> 1721 bytes
-rw-r--r--Hotline/Assets.xcassets/Classic/174.imageset/Contents.json2
-rw-r--r--Hotline/Assets.xcassets/Classic/2012.imageset/2012.pngbin1812 -> 909 bytes
-rw-r--r--Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.pngbin0 -> 2424 bytes
-rw-r--r--Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.pngbin0 -> 4602 bytes
-rw-r--r--Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json2
-rw-r--r--Hotline/Assets.xcassets/Default Banner.imageset/Contents.json6
-rw-r--r--Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.pngbin0 -> 54979 bytes
-rw-r--r--Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.pngbin0 -> 218553 bytes
-rw-r--r--Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.pngbin0 -> 474621 bytes
-rw-r--r--Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.pngbin59733 -> 0 bytes
-rw-r--r--Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.pngbin207508 -> 0 bytes
-rw-r--r--Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.pngbin386312 -> 0 bytes
-rw-r--r--Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json23
-rw-r--r--Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.pngbin0 -> 883 bytes
-rw-r--r--Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.pngbin0 -> 2039 bytes
-rw-r--r--Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.pngbin0 -> 3584 bytes
-rw-r--r--Hotline/Assets/App Icon.icon/Assets/hl.svg3
-rw-r--r--Hotline/Assets/App Icon.icon/icon.json49
-rw-r--r--Hotline/Assets/Sounds/chat-message.aiff (renamed from Hotline/Sounds/chat-message.aiff)bin4554 -> 4554 bytes
-rw-r--r--Hotline/Assets/Sounds/error.aiff (renamed from Hotline/Sounds/error.aiff)bin4610 -> 4610 bytes
-rw-r--r--Hotline/Assets/Sounds/logged-in.aiff (renamed from Hotline/Sounds/logged-in.aiff)bin4166 -> 4166 bytes
-rw-r--r--Hotline/Assets/Sounds/new-news.aiff (renamed from Hotline/Sounds/new-news.aiff)bin17514 -> 17514 bytes
-rw-r--r--Hotline/Assets/Sounds/server-message.aiff (renamed from Hotline/Sounds/server-message.aiff)bin7914 -> 7914 bytes
-rw-r--r--Hotline/Assets/Sounds/transfer-complete.aiff (renamed from Hotline/Sounds/transfer-complete.aiff)bin21378 -> 21378 bytes
-rw-r--r--Hotline/Assets/Sounds/user-login.aiff (renamed from Hotline/Sounds/user-login.aiff)bin6186 -> 6186 bytes
-rw-r--r--Hotline/Assets/Sounds/user-logout.aiff (renamed from Hotline/Sounds/user-logout.aiff)bin7080 -> 7080 bytes
-rw-r--r--Hotline/Hotline.entitlements14
-rw-r--r--Hotline/Hotline/HotlineClient.swift1536
-rw-r--r--Hotline/Hotline/HotlineExtensions.swift245
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift379
-rw-r--r--Hotline/Hotline/HotlineTrackerClient.swift327
-rw-r--r--Hotline/Hotline/HotlineTransferClient.swift1289
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift267
-rw-r--r--Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift162
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileUploadClient.swift227
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift410
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift515
-rw-r--r--Hotline/Hotline/Transfers/HotlineTransferClient.swift288
-rw-r--r--Hotline/Info.plist8
-rw-r--r--Hotline/Library/BookmarkDocument.swift (renamed from Hotline/Utility/BookmarkDocument.swift)0
-rw-r--r--Hotline/Library/ColorArt.swift424
-rw-r--r--Hotline/Library/Extensions.swift299
-rw-r--r--Hotline/Library/NetSocket/FileProgress.swift71
-rw-r--r--Hotline/Library/NetSocket/NetSocket.swift1117
-rw-r--r--Hotline/Library/NetSocket/TransferRateEstimator.swift138
-rw-r--r--Hotline/Library/RegularExpressions.swift (renamed from Hotline/Utility/RegularExpressions.swift)0
-rw-r--r--Hotline/Library/Utility/DAKeychain.swift (renamed from Hotline/Utility/DAKeychain.swift)0
-rw-r--r--Hotline/Library/Utility/NSWindowBridge.swift (renamed from Hotline/Utility/NSWindowBridge.swift)21
-rw-r--r--Hotline/Library/Utility/ObservableScrollView.swift (renamed from Hotline/Utility/ObservableScrollView.swift)0
-rw-r--r--Hotline/Library/Utility/TextDocument.swift (renamed from Hotline/Utility/TextDocument.swift)0
-rw-r--r--Hotline/Library/Views/AsyncLinkPreview.swift (renamed from Hotline/Shared/AsyncLinkPreview.swift)0
-rw-r--r--Hotline/Library/Views/BetterTextEditor.swift (renamed from Hotline/Shared/TextView.swift)0
-rw-r--r--Hotline/Library/Views/FileIconView.swift70
-rw-r--r--Hotline/Library/Views/FileImageView.swift (renamed from Hotline/Shared/FileImageView.swift)0
-rw-r--r--Hotline/Library/Views/GroupedIconView.swift23
-rw-r--r--Hotline/Library/Views/HotlinePanel.swift (renamed from Hotline/Shared/HotlinePanel.swift)13
-rw-r--r--Hotline/Library/Views/QuickLookPreviewView.swift22
-rw-r--r--Hotline/Library/Views/SpinningGlobeView.swift90
-rw-r--r--Hotline/Library/Views/VisualEffectView.swift (renamed from Hotline/Shared/VisualEffectView.swift)0
-rw-r--r--Hotline/MacApp.swift (renamed from Hotline/Application-macOS.swift)214
-rw-r--r--Hotline/Managers/ChatStore.swift272
-rw-r--r--Hotline/Managers/SoundEffects.swift40
-rw-r--r--Hotline/Models/ApplicationState.swift16
-rw-r--r--Hotline/Models/Bookmark.swift112
-rw-r--r--Hotline/Models/ChatMessage.swift55
-rw-r--r--Hotline/Models/FileDetails.swift4
-rw-r--r--Hotline/Models/FileInfo.swift54
-rw-r--r--Hotline/Models/FilePreview.swift229
-rw-r--r--Hotline/Models/Hotline.swift1137
-rw-r--r--Hotline/Models/PreviewFileInfo.swift11
-rw-r--r--Hotline/Models/Server.swift66
-rw-r--r--Hotline/Models/TransferInfo.swift87
-rw-r--r--Hotline/Shared/DataAdditions.swift24
-rw-r--r--Hotline/Shared/FileIconView.swift69
-rw-r--r--Hotline/Shared/NetSocket.swift272
-rw-r--r--Hotline/Shared/URLAdditions.swift27
-rw-r--r--Hotline/State/AppState.swift114
-rw-r--r--Hotline/State/AppUpdate.swift346
-rw-r--r--Hotline/State/BonjourState.swift258
-rw-r--r--Hotline/State/FilePreviewState.swift185
-rw-r--r--Hotline/State/HotlineState.swift2500
-rw-r--r--Hotline/State/Preferences.swift (renamed from Hotline/Models/Preferences.swift)12
-rw-r--r--Hotline/State/ServerState.swift46
-rw-r--r--Hotline/Utility/FoundationExtensions.swift87
-rw-r--r--Hotline/Utility/SoundEffects.swift52
-rw-r--r--Hotline/Utility/SwiftUIExtensions.swift9
-rw-r--r--Hotline/iOS/ChatView.swift23
-rw-r--r--Hotline/iOS/ServerView.swift2
-rw-r--r--Hotline/iOS/TrackerView.swift52
-rw-r--r--Hotline/iOSApp.swift (renamed from Hotline/Sounds/Application-iOS.swift)0
-rw-r--r--Hotline/macOS/AboutView.swift340
-rw-r--r--Hotline/macOS/AccountManagerView.swift405
-rw-r--r--Hotline/macOS/Accounts/AccountDetailsView.swift275
-rw-r--r--Hotline/macOS/Accounts/AccountManagerView.swift217
-rw-r--r--Hotline/macOS/AppUpdateView.swift187
-rw-r--r--Hotline/macOS/Board/MessageBoardEditorView.swift (renamed from Hotline/macOS/MessageBoardEditorView.swift)24
-rw-r--r--Hotline/macOS/Board/MessageBoardView.swift91
-rw-r--r--Hotline/macOS/BroadcastMessageSheet.swift66
-rw-r--r--Hotline/macOS/Chat/ChatView.swift345
-rw-r--r--Hotline/macOS/Chat/ServerAgreementView.swift (renamed from Hotline/macOS/ServerAgreementView.swift)31
-rw-r--r--Hotline/macOS/Chat/ServerMessageView.swift (renamed from Hotline/macOS/ServerMessageView.swift)12
-rw-r--r--Hotline/macOS/ChatView.swift242
-rw-r--r--Hotline/macOS/ConnectView.swift147
-rw-r--r--Hotline/macOS/FileDetailsView.swift149
-rw-r--r--Hotline/macOS/Files/FileDetailsSheet.swift172
-rw-r--r--Hotline/macOS/Files/FileItemView.swift67
-rw-r--r--Hotline/macOS/Files/FilePreviewImageView.swift (renamed from Hotline/macOS/FilePreviewImageView.swift)11
-rw-r--r--Hotline/macOS/Files/FilePreviewQuickLookView.swift114
-rw-r--r--Hotline/macOS/Files/FilePreviewTextView.swift (renamed from Hotline/macOS/FilePreviewTextView.swift)9
-rw-r--r--Hotline/macOS/Files/FilesView.swift529
-rw-r--r--Hotline/macOS/Files/FolderItemView.swift140
-rw-r--r--Hotline/macOS/Files/NewFolderPopover.swift51
-rw-r--r--Hotline/macOS/FilesView.swift481
-rw-r--r--Hotline/macOS/HotlinePanelView.swift175
-rw-r--r--Hotline/macOS/MessageBoardView.swift101
-rw-r--r--Hotline/macOS/MessageView.swift240
-rw-r--r--Hotline/macOS/News/NewsEditorView.swift (renamed from Hotline/macOS/NewsEditorView.swift)20
-rw-r--r--Hotline/macOS/News/NewsItemView.swift (renamed from Hotline/macOS/NewsItemView.swift)15
-rw-r--r--Hotline/macOS/News/NewsView.swift (renamed from Hotline/macOS/NewsView.swift)167
-rw-r--r--Hotline/macOS/ServerView.swift707
-rw-r--r--Hotline/macOS/Settings/GeneralSettingsView.swift67
-rw-r--r--Hotline/macOS/Settings/IconSettingsView.swift58
-rw-r--r--Hotline/macOS/Settings/SettingsView.swift31
-rw-r--r--Hotline/macOS/Settings/SoundSettingsView.swift40
-rw-r--r--Hotline/macOS/SettingsView.swift183
-rw-r--r--Hotline/macOS/TrackerView.swift498
-rw-r--r--Hotline/macOS/Trackers/BonjourServerRow.swift19
-rw-r--r--Hotline/macOS/Trackers/ServerBookmarkSheet.swift70
-rw-r--r--Hotline/macOS/Trackers/TrackerBookmarkServerView.swift40
-rw-r--r--Hotline/macOS/Trackers/TrackerBookmarkSheet.swift121
-rw-r--r--Hotline/macOS/Trackers/TrackerItemView.swift75
-rw-r--r--Hotline/macOS/Trackers/TrackerView.swift623
-rw-r--r--Hotline/macOS/TransfersView.swift411
-rw-r--r--Hotline/macOS/UserClientInfoSheet.swift25
136 files changed, 14956 insertions, 7250 deletions
diff --git a/Hotline/Assets.xcassets/Classic/174.imageset/174@2x.png b/Hotline/Assets.xcassets/Classic/174.imageset/174@2x.png
new file mode 100644
index 0000000..241ff7b
--- /dev/null
+++ b/Hotline/Assets.xcassets/Classic/174.imageset/174@2x.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Classic/174.imageset/174@3x.png b/Hotline/Assets.xcassets/Classic/174.imageset/174@3x.png
new file mode 100644
index 0000000..0e55420
--- /dev/null
+++ b/Hotline/Assets.xcassets/Classic/174.imageset/174@3x.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json b/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json
index bb76edf..bfba03a 100644
--- a/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json
+++ b/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json
@@ -6,10 +6,12 @@
"scale" : "1x"
},
{
+ "filename" : "174@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
+ "filename" : "174@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png b/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png
index d7b591d..e3d7254 100644
--- a/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png
+++ b/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.png b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.png
new file mode 100644
index 0000000..0362354
--- /dev/null
+++ b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.png b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.png
new file mode 100644
index 0000000..d067e9f
--- /dev/null
+++ b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json b/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json
index 02eeaf1..6466c2a 100644
--- a/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json
+++ b/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json
@@ -6,10 +6,12 @@
"scale" : "1x"
},
{
+ "filename" : "2012@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
+ "filename" : "2012@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json b/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json
index 1abbabf..ba0af63 100644
--- a/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json
+++ b/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json
@@ -1,17 +1,17 @@
{
"images" : [
{
- "filename" : "Frame 2.png",
+ "filename" : "Default Banner.png",
"idiom" : "universal",
"scale" : "1x"
},
{
- "filename" : "Frame 2@2x.png",
+ "filename" : "Default Banner@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
- "filename" : "Frame 2@3x.png",
+ "filename" : "Default Banner@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png
new file mode 100644
index 0000000..d960610
--- /dev/null
+++ b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png
new file mode 100644
index 0000000..4ce4eab
--- /dev/null
+++ b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png
new file mode 100644
index 0000000..13c2236
--- /dev/null
+++ b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png
deleted file mode 100644
index fae0905..0000000
--- a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png
+++ /dev/null
Binary files differ
diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png
deleted file mode 100644
index 163e634..0000000
--- a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png
+++ /dev/null
Binary files differ
diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png
deleted file mode 100644
index 3f2ef8f..0000000
--- a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png
+++ /dev/null
Binary files differ
diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json
new file mode 100644
index 0000000..490868e
--- /dev/null
+++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "Section Transfers.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "Section Transfers@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "Section Transfers@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png
new file mode 100644
index 0000000..cbbd304
--- /dev/null
+++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png
new file mode 100644
index 0000000..2e8836b
--- /dev/null
+++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png
Binary files differ
diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png
new file mode 100644
index 0000000..a8086b7
--- /dev/null
+++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png
Binary files differ
diff --git a/Hotline/Assets/App Icon.icon/Assets/hl.svg b/Hotline/Assets/App Icon.icon/Assets/hl.svg
new file mode 100644
index 0000000..05a1bbb
--- /dev/null
+++ b/Hotline/Assets/App Icon.icon/Assets/hl.svg
@@ -0,0 +1,3 @@
+<svg width="292" height="524" viewBox="0 0 292 524" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0 72.2197C0 66.5834 2.71083 61.294 7.27848 58.018L86.34 1.31264C90.9415 -1.98774 97.3333 1.31516 97.3333 6.99335V206.35H194.667V73.7098C194.667 67.9266 199.336 63.2383 205.095 63.2383L281.571 63.2383C287.331 63.2383 292 67.9266 292 73.7099L292 448.941C292 454.434 289.424 459.607 285.048 462.903L205.79 522.591C201.207 526.043 194.667 522.759 194.667 517.006V315.04L97.3333 314.717V450.686C97.3333 456.47 92.6643 461.158 86.9048 461.158H10.4286C4.66903 461.158 0 456.47 0 450.686V72.2197Z" fill="white"/>
+</svg>
diff --git a/Hotline/Assets/App Icon.icon/icon.json b/Hotline/Assets/App Icon.icon/icon.json
new file mode 100644
index 0000000..e147fe7
--- /dev/null
+++ b/Hotline/Assets/App Icon.icon/icon.json
@@ -0,0 +1,49 @@
+{
+ "fill" : {
+ "linear-gradient" : [
+ "display-p3:0.98824,0.15686,0.15686,1.00000",
+ "srgb:0.83024,0.00000,0.00000,1.00000"
+ ],
+ "orientation" : {
+ "start" : {
+ "x" : 0.5,
+ "y" : 0
+ },
+ "stop" : {
+ "x" : 0.5,
+ "y" : 0.7
+ }
+ }
+ },
+ "groups" : [
+ {
+ "layers" : [
+ {
+ "image-name" : "hl.svg",
+ "name" : "hl",
+ "position" : {
+ "scale" : 1.33,
+ "translation-in-points" : [
+ 0,
+ -4.509732002588862e-05
+ ]
+ }
+ }
+ ],
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "translucency" : {
+ "enabled" : true,
+ "value" : 0.5
+ }
+ }
+ ],
+ "supported-platforms" : {
+ "circles" : [
+ "watchOS"
+ ],
+ "squares" : "shared"
+ }
+} \ No newline at end of file
diff --git a/Hotline/Sounds/chat-message.aiff b/Hotline/Assets/Sounds/chat-message.aiff
index 73ea391..73ea391 100644
--- a/Hotline/Sounds/chat-message.aiff
+++ b/Hotline/Assets/Sounds/chat-message.aiff
Binary files differ
diff --git a/Hotline/Sounds/error.aiff b/Hotline/Assets/Sounds/error.aiff
index 8a9f7f6..8a9f7f6 100644
--- a/Hotline/Sounds/error.aiff
+++ b/Hotline/Assets/Sounds/error.aiff
Binary files differ
diff --git a/Hotline/Sounds/logged-in.aiff b/Hotline/Assets/Sounds/logged-in.aiff
index 5f59de0..5f59de0 100644
--- a/Hotline/Sounds/logged-in.aiff
+++ b/Hotline/Assets/Sounds/logged-in.aiff
Binary files differ
diff --git a/Hotline/Sounds/new-news.aiff b/Hotline/Assets/Sounds/new-news.aiff
index 5c56582..5c56582 100644
--- a/Hotline/Sounds/new-news.aiff
+++ b/Hotline/Assets/Sounds/new-news.aiff
Binary files differ
diff --git a/Hotline/Sounds/server-message.aiff b/Hotline/Assets/Sounds/server-message.aiff
index 152bebe..152bebe 100644
--- a/Hotline/Sounds/server-message.aiff
+++ b/Hotline/Assets/Sounds/server-message.aiff
Binary files differ
diff --git a/Hotline/Sounds/transfer-complete.aiff b/Hotline/Assets/Sounds/transfer-complete.aiff
index b22e0ae..b22e0ae 100644
--- a/Hotline/Sounds/transfer-complete.aiff
+++ b/Hotline/Assets/Sounds/transfer-complete.aiff
Binary files differ
diff --git a/Hotline/Sounds/user-login.aiff b/Hotline/Assets/Sounds/user-login.aiff
index 0dcfb6f..0dcfb6f 100644
--- a/Hotline/Sounds/user-login.aiff
+++ b/Hotline/Assets/Sounds/user-login.aiff
Binary files differ
diff --git a/Hotline/Sounds/user-logout.aiff b/Hotline/Assets/Sounds/user-logout.aiff
index 8999edc..8999edc 100644
--- a/Hotline/Sounds/user-logout.aiff
+++ b/Hotline/Assets/Sounds/user-logout.aiff
Binary files differ
diff --git a/Hotline/Hotline.entitlements b/Hotline/Hotline.entitlements
index 5baba22..43dc1ea 100644
--- a/Hotline/Hotline.entitlements
+++ b/Hotline/Hotline.entitlements
@@ -2,10 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <key>aps-environment</key>
- <string>development</string>
- <key>com.apple.developer.aps-environment</key>
- <string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.pizza.unlimited.hotline</string>
@@ -14,15 +10,5 @@
<array>
<string>CloudKit</string>
</array>
- <key>com.apple.security.app-sandbox</key>
- <true/>
- <key>com.apple.security.files.downloads.read-write</key>
- <true/>
- <key>com.apple.security.files.user-selected.read-only</key>
- <true/>
- <key>com.apple.security.files.user-selected.read-write</key>
- <true/>
- <key>com.apple.security.network.client</key>
- <true/>
</dict>
</plist>
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift
index c93f223..2219330 100644
--- a/Hotline/Hotline/HotlineClient.swift
+++ b/Hotline/Hotline/HotlineClient.swift
@@ -1,901 +1,1091 @@
import Foundation
import Network
-import RegexBuilder
-enum HotlineClientStatus: Int {
- case disconnected
- case connecting
- case connected
- case loggingIn
- case loggedIn
+// MARK: - Events
+
+/// Events that can be received from a Hotline server
+///
+/// These are unsolicited messages sent by the server (not replies to requests).
+/// Subscribe to the `events` stream to receive them.
+public enum HotlineEvent: Sendable {
+ /// Server sent a chat message
+ case chatMessage(String)
+ /// A user's information changed (name, icon, status)
+ case userChanged(HotlineUser)
+ /// A user disconnected from the server
+ case userDisconnected(UInt16)
+ /// Server sent a broadcast message
+ case serverMessage(String)
+ /// Received a private message from a user
+ case privateMessage(userID: UInt16, message: String)
+ /// Server sent a news post notification
+ case newsPost(String)
+ /// Server is requesting agreement acceptance
+ case agreementRequired(String)
+ /// Server sent user access permissions
+ case userAccess(HotlineUserAccessOptions)
}
-enum HotlineTransactionError: Error {
- case networkFailure
+// MARK: - Errors
+
+/// Errors that can occur during Hotline operations
+public enum HotlineClientError: Error {
+ /// Connection failed
+ case connectionFailed(Error)
+ /// Server responded with an error code
+ case serverError(code: UInt32, message: String?)
+ /// Transaction timed out waiting for reply
case timeout
- case error(UInt32, String?)
- case invalidMessage(UInt32, String?)
+ /// Client is not connected
+ case notConnected
+ /// Invalid response from server
+ case invalidResponse
+ /// Login failed
+ case loginFailed(String?)
+
+ var userMessage: String {
+ switch self {
+ case .connectionFailed:
+ "Failed to connect to server"
+ case .serverError(let code, let message):
+ message ?? "Server error: \(code)"
+ case .timeout:
+ "Request could not be completed"
+ case .notConnected:
+ "Not connected"
+ case .invalidResponse:
+ "Server returned an invalid response"
+ case .loginFailed(let message):
+ message ?? "Login failed"
+ }
+ }
}
-struct HotlineTransactionInfo {
- let type: HotlineTransactionType
- let callback: ((HotlineTransaction) -> Void)?
- let reply: ((HotlineTransaction) -> Void)?
-}
+// MARK: - Login Info
-private struct HotlineLogin {
- let login: String?
- let password: String?
+/// Information needed to log in to a Hotline server
+public struct HotlineLogin: Sendable {
+ let login: String
+ let password: String
let username: String
let iconID: UInt16
- let callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?
-}
-protocol HotlineClientDelegate: AnyObject {
- func hotlineGetUserInfo() -> (String, UInt16)
- @MainActor func hotlineStatusChanged(status: HotlineClientStatus)
- func hotlineReceivedAgreement(text: String)
- func hotlineReceivedErrorMessage(code: UInt32, message: String?)
- func hotlineReceivedChatMessage(message: String)
- func hotlineReceivedUserList(users: [HotlineUser])
- func hotlineReceivedServerMessage(message: String)
- func hotlineReceivedPrivateMessage(userID: UInt16, message: String)
- func hotlineReceivedUserAccess(options: HotlineUserAccessOptions)
- func hotlineUserChanged(user: HotlineUser)
- func hotlineUserDisconnected(userID: UInt16)
- func hotlineReceivedNewsPost(message: String)
+ public init(login: String, password: String, username: String, iconID: UInt16) {
+ self.login = login
+ self.password = password
+ self.username = username
+ self.iconID = iconID
+ }
}
-extension HotlineClientDelegate {
- func hotlineStatusChanged(status: HotlineClientStatus) {}
- func hotlineReceivedAgreement(text: String) {}
- func hotlineReceivedErrorMessage(code: UInt32, message: String?) {}
- func hotlineReceivedChatMessage(message: String) {}
- func hotlineReceivedUserList(users: [HotlineUser]) {}
- func hotlineReceivedServerMessage(message: String) {}
- func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {}
- func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {}
- func hotlineUserChanged(user: HotlineUser) {}
- func hotlineUserDisconnected(userID: UInt16) {}
- func hotlineReceivedNewsPost(message: String) {}
-}
+// MARK: - Server Info
-enum HotlineClientStage {
- case handshake
- case packetHeader
- case packetBody
-}
+/// Information about the connected server
+public struct HotlineServerInfo: Sendable {
+ let name: String?
+ let version: UInt16
-class HotlineClient: NetSocketDelegate {
- static let handshakePacket = Data([
- 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID
- 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID
- 0x00, 0x01, // Version
- 0x00, 0x02, // Sub-version
- ])
+ public init(name: String?, version: UInt16) {
+ self.name = name
+ self.version = version
+ }
+}
- static let HandshakePacket: [UInt8] = [
- 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID
- 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID
- 0x00, 0x01, // Version
- 0x00, 0x02, // Sub-version
- ]
+// MARK: - Hotline Client
- weak var delegate: HotlineClientDelegate?
+/// A client for connecting to and interacting with Hotline servers.
+///
+/// Example usage:
+/// ```swift
+/// let client = try await HotlineClient.connect(
+/// host: "server.example.com",
+/// port: 5500,
+/// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414)
+/// )
+///
+/// // Listen for events
+/// Task {
+/// for await event in client.events {
+/// switch event {
+/// case .chatMessage(let text):
+/// print("Chat: \(text)")
+/// case .userChanged(let user):
+/// print("User changed: \(user.name)")
+/// default:
+/// break
+/// }
+/// }
+/// }
+///
+/// // Send chat message
+/// try await client.sendChat("Hello world!")
+///
+/// // Get user list
+/// let users = try await client.getUserList()
+/// ```
+public actor HotlineClient {
+ // MARK: - Properties
- var connectionStatus: HotlineClientStatus = .disconnected
- var connectCallback: ((Bool) -> Void)?
+ private let socket: NetSocket
+ private var serverInfo: HotlineServerInfo?
+ private var isConnected: Bool = true
- private var serverAddress: String? = nil
- private var serverPort: UInt16? = nil
+ /// Information about the connected server (name and version)
+ public var server: HotlineServerInfo? {
+ return serverInfo
+ }
- private var transactionLog:
- [UInt32: (HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] =
- [:]
+ // Event streaming
+ private let eventContinuation: AsyncStream<HotlineEvent>.Continuation
+ public let events: AsyncStream<HotlineEvent>
- private var socket: NetSocket?
- private var stage: HotlineClientStage = .handshake
- private var packet: HotlineTransaction? = nil
- private var serverVersion: UInt16? = nil
- private var loginDetails: HotlineLogin? = nil
- private var keepAliveTimer: Timer? = nil
+ // Transaction tracking for request/reply pattern
+ private var pendingTransactions: [UInt32: CheckedContinuation<HotlineTransaction, Error>] = [:]
- init() {}
+ // Receive loop task
+ private var receiveTask: Task<Void, Never>?
- // MARK: - NetSocket Delegate
+ // Keep-alive timer
+ private var keepAliveTask: Task<Void, Never>?
- @MainActor func netsocketConnected(socket: NetSocket) {
- self.updateConnectionStatus(.loggingIn)
- self.stage = .handshake
+ // Transaction IDs
+ private var nextTransactionID: UInt32 = 1
+ private func generateTransactionID() -> UInt32 {
+ defer { self.nextTransactionID += 1 }
+ return self.nextTransactionID
}
- @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) {
- self.reset()
- self.updateConnectionStatus(.disconnected)
- self.stage = .handshake
- }
+ // MARK: - Connection
+
+ /// Connect to a Hotline server and log in
+ ///
+ /// This method:
+ /// 1. Establishes TCP connection
+ /// 2. Performs handshake
+ /// 3. Logs in with provided credentials
+ /// 4. Starts event streaming and keep-alive
+ ///
+ /// - Parameters:
+ /// - host: Server hostname or IP address
+ /// - port: Server port (default: 5500)
+ /// - login: Login credentials and user info
+ /// - tls: TLS policy (default: disabled for Hotline)
+ /// - Returns: Connected and logged-in client
+ /// - Throws: `HotlineClientError` if connection or login fails
+ public static func connect(
+ host: String,
+ port: UInt16 = 5500,
+ login: HotlineLogin
+ ) async throws -> HotlineClient {
+ print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'")
- @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {
- switch self.stage {
- case .handshake:
- self.receiveHandshake()
- case .packetHeader:
- self.receivePacket()
- case .packetBody:
- self.receivePacket()
+ // Connect socket
+ print("HotlineClient.connect(): Connecting socket...")
+ let socket: NetSocket
+ do {
+ socket = try await NetSocket.connect(host: host, port: port)
}
- }
+ catch let socketError as NetSocketError {
+ if case .failed(_) = socketError {
+ throw HotlineClientError.connectionFailed(socketError)
+ }
+ throw socketError
+ }
+ print("HotlineClient.connect(): Socket connected")
- // MARK: - Connect
+ // Perform handshake
+ print("HotlineClient.connect(): Sending handshake...")
+ try await socket.write(Data(endian: .big, {
+ "TRTP".fourCharCode() // 'TRTP' protocol ID
+ "HOTL".fourCharCode() // 'HOTL' sub-protocol ID
+ UInt16(0x0001) // Version
+ UInt16(0x0002) // Sub-version
+ }))
+ let handshakeResponse = try await socket.read(8)
+ print("HotlineClient.connect(): Handshake response received")
- @MainActor func login(
- address: String, port: Int, login: String?, password: String?, username: String, iconID: UInt16,
- callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?
- ) {
- if self.socket != nil {
- self.socket?.delegate = nil
- self.socket?.close()
- self.socket = nil
+ // Verify handshake
+ guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else {
+ print("HotlineClient.connect(): Invalid handshake response")
+ throw HotlineClientError.connectionFailed(
+ NSError(domain: "HotlineClient", code: -1, userInfo: [
+ NSLocalizedDescriptionKey: "Invalid handshake response"
+ ])
+ )
}
- self.packet = nil
- self.loginDetails = HotlineLogin(
- login: login, password: password, username: username, iconID: iconID, callback: callback)
+ let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
+ guard errorCode.bigEndian == 0 else {
+ print("HotlineClient.connect(): Handshake failed with error code \(errorCode)")
+ throw HotlineClientError.connectionFailed(
+ NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [
+ NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)"
+ ])
+ )
+ }
- self.socket = NetSocket()
- self.socket?.delegate = self
+ // Create client
+ print("HotlineClient.connect(): Creating client instance")
+ let client = HotlineClient(socket: socket)
- self.updateConnectionStatus(.connecting)
- self.socket?.connect(host: address, port: port)
- self.socket?.write(HotlineClient.HandshakePacket)
- }
+ // Start receive loop
+ print("HotlineClient.connect(): Starting receive loop")
+ await client.startReceiveLoop()
- @MainActor private func startKeepAliveTimer() {
- self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) {
- [weak self] _ in
- DispatchQueue.main.async { [weak self] in
- self?.sendKeepAlive()
- }
- }
- }
+ // Perform login
+ print("HotlineClient.connect(): Performing login")
+ let serverInfo = try await client.performLogin(login)
+ await client.setServerInfo(serverInfo)
+ print("HotlineClient.connect(): Login successful")
- @MainActor func receiveHandshake() {
- guard let socket = self.socket,
- self.stage == .handshake,
- socket.available >= 8
- else {
- return
- }
-
- var handshake: [UInt8] = socket.read(count: 8)
+ // Start keep-alive
+ print("HotlineClient.connect(): Starting keep-alive")
+ await client.startKeepAlive()
- // Verify handshake data
- guard let protocolID = handshake.consumeUInt32(),
- protocolID == "TRTP".fourCharCode()
- else {
- // TODO: Close with appropriate error
- socket.close()
- return
- }
+ print("HotlineClient.connect(): Connected to \(serverInfo.name ?? "Server") (v\(serverInfo.version))")
- // Check for error code
- guard let errorCode = handshake.consumeUInt32(),
- errorCode == 0
- else {
- // TODO: Close with wrapped error
- socket.close()
- return
- }
+ return client
+ }
- self.stage = .packetHeader
+ private init(socket: NetSocket) {
+ self.socket = socket
- let session = self.loginDetails!
- self.loginDetails = nil
- self.sendLogin(
- login: session.login ?? "", password: session.password ?? "", username: session.username,
- iconID: session.iconID
- ) { [weak self] err, serverName, serverVersion in
- self?.serverVersion = serverVersion
- self?.startKeepAliveTimer()
- session.callback?(err, serverName, serverVersion)
+ // Set up event stream
+ var continuation: AsyncStream<HotlineEvent>.Continuation!
+ self.events = AsyncStream { cont in
+ continuation = cont
}
+ self.eventContinuation = continuation
+ }
- self.receivePacket()
+ private func setServerInfo(_ info: HotlineServerInfo) {
+ self.serverInfo = info
}
- @MainActor private func reset() {
- self.transactionLog = [:]
- self.packet = nil
+ // MARK: - Login
- self.keepAliveTimer?.invalidate()
- self.keepAliveTimer = nil
+ private func performLogin(_ login: HotlineLogin) async throws -> HotlineServerInfo {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login)
+ transaction.setFieldEncodedString(type: .userLogin, val: login.login)
+ transaction.setFieldEncodedString(type: .userPassword, val: login.password)
+ transaction.setFieldUInt16(type: .userIconID, val: login.iconID)
+ transaction.setFieldString(type: .userName, val: login.username)
+ transaction.setFieldUInt32(type: .versionNumber, val: 123)
- self.socket?.close()
- self.socket?.delegate = nil
- self.socket = nil
- }
+ let reply = try await sendTransaction(transaction)
+
+ guard reply.errorCode == 0 else {
+ let errorText = reply.getField(type: .errorText)?.getString()
+ throw HotlineClientError.loginFailed(errorText)
+ }
- @MainActor func disconnect() {
- self.reset()
+ // All servers send a server version.
+ let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 123
+
+ // Later clients send a name and banner ID.
+ let serverName = reply.getField(type: .serverName)?.getString()
+// let serverBannerID = reply.getField(type: .communityBannerID)?.getInteger()
+
+ return HotlineServerInfo(name: serverName, version: serverVersion)
}
- // MARK: - Packets
+ // MARK: - Disconnect
- @MainActor private func sendPacket(
- _ t: HotlineTransaction,
- callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil
- ) {
- guard let socket = self.socket else {
+ /// Disconnect from the server
+ ///
+ /// Closes the socket and stops all background tasks.
+ public func disconnect() async {
+ guard isConnected else {
return
}
- print("HotlineClient => \(t.id) \(t.type)")
-
- if callback != nil {
- self.transactionLog[t.id] = (t.type, callback)
- }
+ isConnected = false
- socket.write(t.encoded())
+ print("HotlineClient.disconnect(): Starting disconnect")
+ self.receiveTask?.cancel()
+ self.keepAliveTask?.cancel()
+ await self.socket.close()
+ self.failAllPendingTransactions(HotlineClientError.notConnected)
+ self.eventContinuation.finish()
+ print("HotlineClient.disconnect(): Disconnect complete")
}
- @MainActor private func receivePacket() {
- guard let socket = self.socket else {
- return
- }
+ // MARK: - Receive Loop
- var done: Bool = false
- repeat {
- switch self.stage {
- case .packetHeader:
- guard socket.has(HotlineTransaction.headerSize) else {
- done = true
- break
- }
+ private func startReceiveLoop() {
+ print("HotlineClient.startReceiveLoop(): Creating receive task")
+ self.receiveTask = Task { [weak self] in
+ guard let self else {
+ return
+ }
- let headerData: [UInt8] = socket.read(count: HotlineTransaction.headerSize)
- guard let packet = HotlineTransaction(from: headerData) else {
- done = true
- break
+ do {
+ while !Task.isCancelled {
+ // Read transaction from socket
+ let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big)
+ await self.handleTransaction(transaction)
}
-
- self.packet = packet
- if packet.dataSize == 0 {
- self.stage = .packetHeader
- self.processPacket()
+ print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop")
+ } catch {
+ if Task.isCancelled || error is CancellationError {
+ print("HotlineClient.startReceiveLoop(): Receive loop cancelled")
} else {
- self.stage = .packetBody
- }
-
- case .packetBody:
- guard let packet = self.packet, socket.has(Int(packet.dataSize)) else {
- done = true
- break
+ print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)")
+ await self.disconnect()
}
-
- let bodyData: [UInt8] = socket.read(count: Int(packet.dataSize))
- self.packet?.decodeFields(from: bodyData)
- self.stage = .packetHeader
- self.processPacket()
-
- default:
- done = true
- break
}
- } while !done
+ print("HotlineClient.startReceiveLoop(): Receive loop ended")
+ }
}
- @MainActor private func processPacket() {
- guard let packet = self.packet else {
+ private func handleTransaction(_ transaction: HotlineTransaction) {
+ print("HotlineClient: <= \(transaction.type) [\(transaction.id)]")
+
+ // Check if this is a reply to a pending transaction
+ if transaction.isReply == 1 || transaction.type == .reply {
+ handleReply(transaction)
return
}
- if packet.type == .reply || packet.isReply == 1 {
- print("HotlineClient <= \(packet.type) to \(packet.id):")
- } else {
- print("HotlineClient <= \(packet.type) \(packet.id)")
- }
+ // Handle unsolicited server messages (events)
+ handleEvent(transaction)
+ }
- if packet.isReply == 1 || packet.type == .reply {
- self.processReplyPacket()
+ private func handleReply(_ transaction: HotlineTransaction) {
+ guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else {
+ print("HotlineClient: Received reply for unknown transaction \(transaction.id)")
return
}
- // Mark packet is processed
- self.packet = nil
+ if transaction.errorCode != 0 {
+ let errorText = transaction.getField(type: .errorText)?.getString()
+ continuation.resume(throwing: HotlineClientError.serverError(
+ code: transaction.errorCode,
+ message: errorText
+ ))
+ } else {
+ continuation.resume(returning: transaction)
+ }
+ }
- switch packet.type {
+ private func handleEvent(_ transaction: HotlineTransaction) {
+ switch transaction.type {
case .chatMessage:
- if let chatTextParam = packet.getField(type: .data),
- let chatText = chatTextParam.getString()
- {
- print("HotlineClient: \(chatText)")
- self.delegate?.hotlineReceivedChatMessage(message: chatText)
+ if let text = transaction.getField(type: .data)?.getString() {
+ eventContinuation.yield(.chatMessage(text))
}
case .notifyOfUserChange:
- if let usernameField = packet.getField(type: .userName),
- let username = usernameField.getString(),
- let userIDField = packet.getField(type: .userID),
- let userID = userIDField.getUInt16(),
- let userIconIDField = packet.getField(type: .userIconID),
- let userIconID = userIconIDField.getUInt16(),
- let userFlagsField = packet.getField(type: .userFlags),
- let userFlags = userFlagsField.getUInt16()
- {
- print("HotlineClient: User changed \(userID) \(username) icon: \(userIconID)")
-
- let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username)
- self.delegate?.hotlineUserChanged(user: user)
+ if let usernameField = transaction.getField(type: .userName),
+ let username = usernameField.getString(),
+ let userID = transaction.getField(type: .userID)?.getUInt16(),
+ let iconID = transaction.getField(type: .userIconID)?.getUInt16(),
+ let flags = transaction.getField(type: .userFlags)?.getUInt16() {
+ let user = HotlineUser(id: userID, iconID: iconID, status: flags, name: username)
+ eventContinuation.yield(.userChanged(user))
}
case .notifyOfUserDelete:
- if let userIDField = packet.getField(type: .userID),
- let userID = userIDField.getUInt16()
- {
- self.delegate?.hotlineUserDisconnected(userID: userID)
+ if let userID = transaction.getField(type: .userID)?.getUInt16() {
+ eventContinuation.yield(.userDisconnected(userID))
}
- case .disconnectMessage:
- // Server disconnected us.
- print("HotlineClient ❌")
- self.disconnect()
-
case .serverMessage:
- if let messageField = packet.getField(type: .data),
- let message = messageField.getString()
- {
-
- if let userIDField = packet.getField(type: .userID),
- let userID = userIDField.getUInt16()
- {
- self.delegate?.hotlineReceivedPrivateMessage(userID: userID, message: message)
+ if let message = transaction.getField(type: .data)?.getString() {
+ if let userID = transaction.getField(type: .userID)?.getUInt16() {
+ eventContinuation.yield(.privateMessage(userID: userID, message: message))
} else {
- self.delegate?.hotlineReceivedServerMessage(message: message)
+ eventContinuation.yield(.serverMessage(message))
}
}
case .showAgreement:
- if packet.getField(type: .noServerAgreement) != nil {
- // Server told us there is no agreement to show.
- return
- }
- if let agreementParam = packet.getField(type: .data) {
- if let agreementText = agreementParam.getString() {
- self.delegate?.hotlineReceivedAgreement(text: agreementText)
- }
+ if transaction.getField(type: .noServerAgreement) == nil,
+ let agreementText = transaction.getField(type: .data)?.getString() {
+ eventContinuation.yield(.agreementRequired(agreementText))
}
case .userAccess:
- print(
- "HotlineClient: user access info \(packet.getField(type: .userAccess).debugDescription)")
- if let accessParam = packet.getField(type: .userAccess) {
- if let accessValue = accessParam.getUInt64() {
- let accessOptions = HotlineUserAccessOptions(rawValue: accessValue)
- self.delegate?.hotlineReceivedUserAccess(options: accessOptions)
- }
+ if let accessValue = transaction.getField(type: .userAccess)?.getUInt64() {
+ eventContinuation.yield(.userAccess(HotlineUserAccessOptions(rawValue: accessValue)))
}
case .newMessage:
- if let messageField = packet.getField(type: .data),
- let message = messageField.getString()
- {
- self.delegate?.hotlineReceivedNewsPost(message: message)
+ if let message = transaction.getField(type: .data)?.getString() {
+ eventContinuation.yield(.newsPost(message))
+ }
+
+ case .disconnectMessage:
+ Task {
+ await self.disconnect()
}
default:
- print(
- "HotlineClient: UNKNOWN transaction \(packet.type) with \(packet.fields.count) parameters")
- print(packet.fields)
+ print("HotlineClient: Unhandled event type \(transaction.type)")
}
}
- @MainActor private func processReplyPacket() {
- guard let packet = self.packet else {
- return
- }
+ // MARK: - Transaction Sending
- if packet.errorCode != 0 {
- let errorField: HotlineTransactionField? = packet.getField(type: .errorText)
- print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")")
- self.delegate?.hotlineReceivedErrorMessage(
- code: packet.errorCode, message: errorField?.getString())
- }
+ @discardableResult
+ private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction {
+ print("HotlineClient: => \(transaction.type) [\(transaction.id)]")
- guard let replyCallbackInfo = self.transactionLog[packet.id] else {
- print("Hmm, no reply waiting though")
- return
- }
-
- self.transactionLog[packet.id] = nil
+ let transactionID = transaction.id
- print("HotlineClient reply in response to \(replyCallbackInfo.0)")
+ try await self.socket.send(transaction, endian: .big)
- let replyCallback = replyCallbackInfo.1
-
- guard packet.errorCode == 0 else {
- let errorField: HotlineTransactionField? = packet.getField(type: .errorText)
- replyCallback?(packet, .error(packet.errorCode, errorField?.getString()))
- return
+ do {
+ return try await Task.withTimeout(seconds: timeout) {
+ try await self.awaitReply(for: transactionID)
+ }
+ } catch is TaskTimeoutError {
+ throw HotlineClientError.timeout
+ } catch let error as HotlineClientError {
+ print("Hotline Client Error: \(error)")
+ throw error
+ } catch {
+ throw error
}
-
- replyCallback?(packet, nil)
}
- // MARK: - Messages
+ private func storePendingTransaction(id: UInt32, continuation: CheckedContinuation<HotlineTransaction, Error>) {
+ self.pendingTransactions[id] = continuation
+ }
- @MainActor func sendLogin(
- login: String, password: String, username: String, iconID: UInt16,
- callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?
- ) {
- var t = HotlineTransaction(type: .login)
- t.setFieldEncodedString(type: .userLogin, val: login)
- t.setFieldEncodedString(type: .userPassword, val: password)
- t.setFieldUInt16(type: .userIconID, val: iconID)
- t.setFieldString(type: .userName, val: username)
- t.setFieldUInt32(type: .versionNumber, val: 123)
+ private func awaitReply(for transactionID: UInt32) async throws -> HotlineTransaction {
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ self.storePendingTransaction(id: transactionID, continuation: continuation)
+ }
+ } onCancel: { [weak self] in
+ Task { await self?.failPendingTransaction(id: transactionID, error: HotlineClientError.timeout) }
+ }
+ }
- self.sendPacket(t) { [weak self] reply, err in
- self?.updateConnectionStatus(.loggedIn)
+ private func failPendingTransaction(id: UInt32, error: Error) {
+ guard let continuation = self.pendingTransactions.removeValue(forKey: id) else { return }
+ continuation.resume(throwing: error)
+ }
- var serverVersion: UInt16?
- var serverName: String?
+ private func failAllPendingTransactions(_ error: Error) {
+ guard !self.pendingTransactions.isEmpty else { return }
+ let continuations = self.pendingTransactions
+ self.pendingTransactions.removeAll()
+ for (_, continuation) in continuations {
+ continuation.resume(throwing: error)
+ }
+ }
- if let serverVersionField = reply.getField(type: .versionNumber),
- let serverVersionValue = serverVersionField.getUInt16()
- {
- serverVersion = serverVersionValue
- print("SERVER VERSION: \(serverVersionValue)")
- }
+ // MARK: - Keep-Alive
- if let serverNameField = reply.getField(type: .serverName),
- let serverNameValue = serverNameField.getString()
- {
- serverName = serverNameValue
- print("SERVER NAME: \(serverNameValue)")
+ private func startKeepAlive() {
+ self.keepAliveTask = Task { [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes
+ await self?.sendKeepAlive()
}
-
- callback?(err, serverName, serverVersion)
}
}
-
- @MainActor func sendSetClientUserInfo(
- username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil
- ) {
- var t = HotlineTransaction(type: .setClientUserInfo)
- t.setFieldString(type: .userName, val: username)
- t.setFieldUInt16(type: .userIconID, val: iconID)
- t.setFieldUInt16(type: .options, val: options.rawValue)
- if let text = autoresponse {
- t.setFieldString(type: .automaticResponse, val: text)
+
+ private func sendKeepAlive() async {
+ do {
+ if let version = self.serverInfo?.version, version >= 185 {
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .connectionKeepAlive)
+ try await self.socket.send(transaction, endian: .big)
+ } else {
+ // Older servers: send getUserNameList as keep-alive
+ let _ = try? await self.getUserList()
+ }
+ } catch {
+ print("HotlineClient: Keep-alive failed: \(error)")
}
-
- self.sendPacket(t)
}
- @MainActor func sendAgree(username: String, iconID: UInt16, options: HotlineUserOptions) {
- let t = HotlineTransaction(type: .agreed)
- // t.setFieldString(type: .userName, val: username)
- // t.setFieldUInt16(type: .userIconID, val: iconID)
- // t.setFieldUInt8(type: .options, val: options.rawValue)
- self.sendPacket(t)
+ // MARK: - Chat
+
+ /// Broadcast a message to the server
+ ///
+ /// - Parameters:
+ /// - message: Text to send
+ /// - encoding: Text encoding (default: UTF-8)
+ public func sendBroadcast(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .userBroadcast)
+ transaction.setFieldString(type: .data, val: message, encoding: encoding)
+ try await socket.send(transaction, endian: .big)
}
- @MainActor func sendChat(
- message: String, encoding: String.Encoding = .utf8, announce: Bool = false
- ) {
- var t = HotlineTransaction(type: .sendChat)
- t.setFieldString(type: .data, val: message, encoding: encoding)
- t.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0)
- self.sendPacket(t)
- }
+ /// Send a chat message to the server
+ ///
+ /// - Parameters:
+ /// - message: Text to send
+ /// - encoding: Text encoding (default: UTF-8)
+ /// - announce: Whether this is an announcement (admin only, default: false)
+ public func sendChat(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendChat)
+ transaction.setFieldString(type: .data, val: message, encoding: encoding)
+ transaction.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0)
- @MainActor func sendInstantMessage(
- message: String, userID: UInt16, encoding: String.Encoding = .utf8
- ) {
- var t = HotlineTransaction(type: .sendInstantMessage)
- t.setFieldUInt16(type: .userID, val: userID)
- t.setFieldUInt32(type: .options, val: 1)
- t.setFieldString(type: .data, val: message, encoding: encoding)
- self.sendPacket(t)
+ try await socket.send(transaction, endian: .big)
}
- @MainActor func sendGetUserList() {
- let t = HotlineTransaction(type: .getUserNameList)
- self.sendPacket(t) { [weak self] reply, err in
- var newUsers: [UInt16: HotlineUser] = [:]
- var newUserList: [HotlineUser] = []
- for u in reply.getFieldList(type: .userNameWithInfo) {
- let user = u.getUser()
- newUsers[user.id] = user
- newUserList.append(user)
- }
- self?.delegate?.hotlineReceivedUserList(users: newUserList)
+ // MARK: - Users
+
+ /// Get the list of users currently connected to the server
+ ///
+ /// - Returns: Array of connected users
+ public func getUserList() async throws -> [HotlineUser] {
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getUserNameList)
+ let reply = try await sendTransaction(transaction)
+
+ var users: [HotlineUser] = []
+ for field in reply.getFieldList(type: .userNameWithInfo) {
+ users.append(field.getUser())
}
- }
- @MainActor func sendGetMessageBoard(callback: ((HotlineTransactionError?, [String]) -> Void)?) {
- let t = HotlineTransaction(type: .getMessageBoard)
- self.sendPacket(t) { reply, err in
- guard err == nil,
- let textField = reply.getField(type: .data),
- let text = textField.getString()
- else {
- callback?(err, [])
- return
- }
+ return users
+ }
- var messages: [String] = []
- let matches = text.matches(of: RegularExpressions.messageBoardDivider)
- var start = text.startIndex
+ /// Send a private instant message to a user
+ ///
+ /// - Parameters:
+ /// - message: Text to send
+ /// - userID: Target user ID
+ /// - encoding: Text encoding (default: UTF-8)
+ public func sendInstantMessage(_ message: String, to userID: UInt16, encoding: String.Encoding = .utf8) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendInstantMessage)
+ transaction.setFieldUInt16(type: .userID, val: userID)
+ transaction.setFieldUInt32(type: .options, val: 1)
+ transaction.setFieldString(type: .data, val: message, encoding: encoding)
- if matches.count > 0 {
- for match in matches {
- let range = match.range
- let messageText = String(text[start..<range.lowerBound])
- messages.append(messageText.convertingLinksToMarkdown())
- start = range.upperBound
- }
- } else {
- messages.append(text)
- }
+ try await socket.send(transaction, endian: .big)
+ }
+
+ /// Get information text about a user
+ ///
+ /// - Parameters:
+ /// - userID: Target user ID
+ public func getClientInfoText(for userID: UInt16) async throws -> HotlineUserClientInfo? {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getClientInfoText)
+ transaction.setFieldUInt16(type: .userID, val: userID)
- callback?(err, messages)
+ let reply = try await self.sendTransaction(transaction)
+
+ if let username = reply.getField(type: .userName)?.getString(),
+ let info = reply.getField(type: .data)?.getString() {
+ return HotlineUserClientInfo(username: username, details: info)
}
+
+ return nil
}
- @MainActor func sendPostMessageBoard(text: String) {
- guard text.count > 0 else {
- return
+ /// Update this client's user info (name, icon, options)
+ ///
+ /// - Parameters:
+ /// - username: Display name
+ /// - iconID: Icon ID
+ /// - options: User options flags
+ /// - autoresponse: Optional auto-response text
+ public func setClientUserInfo(
+ username: String,
+ iconID: UInt16,
+ options: HotlineUserOptions = [],
+ autoresponse: String? = nil
+ ) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setClientUserInfo)
+ transaction.setFieldString(type: .userName, val: username)
+ transaction.setFieldUInt16(type: .userIconID, val: iconID)
+ transaction.setFieldUInt16(type: .options, val: options.rawValue)
+
+ if let autoresponse {
+ transaction.setFieldString(type: .automaticResponse, val: autoresponse)
}
- var t = HotlineTransaction(type: .oldPostNews)
- t.setFieldString(type: .data, val: text.convertingLineEndings(to: .cr), encoding: .macOSRoman)
- self.sendPacket(t)
+ try await socket.send(transaction, endian: .big)
}
-
- @MainActor func sendGetNewsCategories(
- path: [String] = [], callback: (([HotlineNewsCategory]) -> Void)?
- ) {
- var t = HotlineTransaction(type: .getNewsCategoryNameList)
- if !path.isEmpty {
- t.setFieldPath(type: .newsPath, val: path)
+
+ /// Force a user to disconnect from the server
+ ///
+ /// - Parameters:
+ /// - userID: Target user ID
+ /// - options: If specified, temporarily or permanently ban the user
+ public func disconnectUser(userID: UInt16, options: HotlineUserDisconnectOptions? = nil) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .disconnectUser)
+
+ transaction.setFieldUInt16(type: .userID, val: userID)
+ if let options {
+ transaction.setFieldUInt16(type: .options, val: options.rawValue)
}
+
+ try await self.sendTransaction(transaction)
+ }
- self.sendPacket(t) { reply, err in
- var categories: [HotlineNewsCategory] = []
- for categoryListItem in reply.getFieldList(type: .newsCategoryListData15) {
- var c = categoryListItem.getNewsCategory()
- c.path = path + [c.name]
- categories.append(c)
- }
- callback?(categories)
- }
+ // MARK: - Agreement
+
+ /// Send agreement acceptance to the server
+ ///
+ /// Call this after receiving `.agreementRequired` event.
+ public func sendAgree() async throws {
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .agreed)
+ try await socket.send(transaction, endian: .big)
}
- @MainActor func sendGetNewsArticle(
- id articleID: UInt32, path: [String], flavor: String, callback: ((String?) -> Void)? = nil
- ) {
- var t = HotlineTransaction(type: .getNewsArticleData)
- t.setFieldPath(type: .newsPath, val: path)
- t.setFieldUInt32(type: .newsArticleID, val: articleID)
- t.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii)
+ // MARK: - Files
- self.sendPacket(t) { reply, err in
- guard err == nil,
- let articleData = reply.getField(type: .newsArticleData),
- let articleString = articleData.getString()
- else {
- callback?(nil)
- return
- }
+ /// Get the file list for a directory
+ ///
+ /// - Parameter path: Directory path (empty for root)
+ /// - Returns: Array of files and folders
+ public func getFileList(path: [String] = []) async throws -> [HotlineFile] {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileNameList)
+ if !path.isEmpty {
+ transaction.setFieldPath(type: .filePath, val: path)
+ }
- callback?(articleString)
+ let reply = try await self.sendTransaction(transaction)
+
+ var files: [HotlineFile] = []
+ for field in reply.getFieldList(type: .fileNameWithInfo) {
+ let file = field.getFile()
+ file.path = path + [file.name]
+ files.append(file)
}
+
+ return files
}
+
+ /// Get detailed information about a file
+ ///
+ /// - Parameters:
+ /// - name: File name
+ /// - path: Directory path containing the file
+ /// - Returns: File details or nil if not found
+ public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
- @MainActor func postNewsArticle(
- title: String, text: String, path: [String] = [], parentID: UInt32 = 0,
- callback: ((Bool) -> Void)? = nil
- ) {
- guard !path.isEmpty else {
- callback?(false)
- return
- }
+ let reply = try await sendTransaction(transaction)
- var t = HotlineTransaction(type: .postNewsArticle)
- t.setFieldPath(type: .newsPath, val: path)
- t.setFieldUInt32(type: .newsArticleID, val: parentID)
- t.setFieldString(type: .newsArticleTitle, val: title)
- t.setFieldString(type: .newsArticleDataFlavor, val: "text/plain")
- t.setFieldUInt32(type: .newsArticleFlags, val: 0)
- t.setFieldString(type: .newsArticleData, val: text.convertingLineEndings(to: .cr))
+ guard
+ let fileName = reply.getField(type: .fileName)?.getString(),
+ let fileCreator = reply.getField(type: .fileCreatorString)?.getString(),
+ let fileType = reply.getField(type: .fileTypeString)?.getString(),
+ let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0),
+ let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0)
+ else {
+ return nil
+ }
- print("HotlineClient postings \(title) under \(parentID)")
+ // Size field is not included in server reply for folders
+ let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0
+ let fileComment = reply.getField(type: .fileComment)?.getString() ?? ""
- self.sendPacket(t) { reply, err in
- guard err == nil else {
- callback?(false)
- return
- }
- callback?(true)
+ return FileDetails(
+ name: fileName,
+ path: path,
+ size: fileSize,
+ comment: fileComment,
+ type: fileType,
+ creator: fileCreator,
+ created: fileCreateDate,
+ modified: fileModifyDate
+ )
+ }
+
+ /// Set a file's information (name/comment)
+ ///
+ /// - Parameters:
+ /// - name: File name
+ /// - path: Directory path containing the file
+ /// - newName: Name to set the file to
+ /// - comment: Comment to set on the file
+ public func setFileInfo(name: String, path: [String], newName: String? = nil, comment: String? = nil, encoding: String.Encoding = .utf8) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setFileInfo)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
+
+ if let newName {
+ transaction.setFieldString(type: .fileNewName, val: newName)
+ }
+
+ if let comment {
+ transaction.setFieldString(type: .fileComment, val: comment)
}
+
+ try await sendTransaction(transaction)
}
- @MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) {
- let t = HotlineTransaction(type: .getAccounts)
+ /// Delete a file or folder
+ ///
+ /// - Parameters:
+ /// - name: File or folder name
+ /// - path: Directory path containing the item
+ /// - Returns: True if deletion succeeded
+ public func deleteFile(name: String, path: [String]) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
- self.sendPacket(t) { reply, err in
- guard err == nil else {
- callback?([])
- return
- }
+ try await self.sendTransaction(transaction)
+ }
+
+ /// Create a folder
+ ///
+ /// - Parameters:
+ /// - name: New folder name
+ /// - path: Directory path for the new folder
+ /// - Returns: True if creation succeeded
+ public func newFolder(name: String, path: [String]) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
+ try await self.sendTransaction(transaction)
+ }
- let accountFields = reply.getFieldList(type: .data)
+ // MARK: - News
- var accounts: [HotlineAccount] = []
- for data in accountFields {
- accounts.append(data.getAcccount())
- }
+ /// Get news categories at a path
+ ///
+ /// - Parameter path: Category path (empty for root)
+ /// - Returns: Array of news categories
+ public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsCategoryNameList)
+ if !path.isEmpty {
+ transaction.setFieldPath(type: .newsPath, val: path)
+ }
- accounts.sort { $0.login < $1.login }
+ let reply = try await self.sendTransaction(transaction)
- callback?(accounts)
+ var categories: [HotlineNewsCategory] = []
+ for field in reply.getFieldList(type: .newsCategoryListData15) {
+ var category = field.getNewsCategory()
+ category.path = path + [category.name]
+ categories.append(category)
}
+
+ return categories
}
- @MainActor func sendGetNewsArticles(
- path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil
- ) {
- var t = HotlineTransaction(type: .getNewsArticleNameList)
+ /// Get news articles in a category
+ ///
+ /// - Parameter path: Category path
+ /// - Returns: Array of news articles
+ public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleNameList)
if !path.isEmpty {
- t.setFieldPath(type: .newsPath, val: path)
+ transaction.setFieldPath(type: .newsPath, val: path)
}
- self.sendPacket(t) { reply, err in
- guard err == nil,
- let articleData = reply.getField(type: .newsArticleListData)
- else {
- callback?([])
- return
- }
- var articles: [HotlineNewsArticle] = []
- let newsList = articleData.getNewsList()
- for art in newsList.articles {
- var blah = art
- blah.path = path
- articles.append(blah)
- }
+ let reply = try await self.sendTransaction(transaction)
- callback?(articles)
+ guard let articleData = reply.getField(type: .newsArticleListData) else {
+ return []
}
- }
- @MainActor func sendGetFileList(path: [String] = [], callback: (([HotlineFile]) -> Void)? = nil) {
- var t = HotlineTransaction(type: .getFileNameList)
- if !path.isEmpty {
- t.setFieldPath(type: .filePath, val: path)
+ let newsList = articleData.getNewsList()
+ return newsList.articles.map { article in
+ var a = article
+ a.path = path
+ return a
}
+ }
- self.sendPacket(t) { reply, err in
- guard err == nil else {
- callback?([])
- return
- }
+ /// Get the content of a news article
+ ///
+ /// - Parameters:
+ /// - id: Article ID
+ /// - path: Category path
+ /// - flavor: Content flavor (default: "text/plain")
+ /// - Returns: Article content as string
+ public func getNewsArticle(id: UInt32, path: [String], flavor: String = "text/plain") async throws -> String? {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleData)
+ transaction.setFieldPath(type: .newsPath, val: path)
+ transaction.setFieldUInt32(type: .newsArticleID, val: id)
+ transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii)
- var files: [HotlineFile] = []
- for fi in reply.getFieldList(type: .fileNameWithInfo) {
- let file = fi.getFile()
- file.path = path + [file.name]
- files.append(file)
- }
+ let reply = try await self.sendTransaction(transaction)
+ return reply.getField(type: .newsArticleData)?.getString()
+ }
- callback?(files)
+ /// Post a news article
+ ///
+ /// - Parameters:
+ /// - title: Article title
+ /// - text: Article body
+ /// - path: Category path
+ /// - parentID: Parent article ID (for replies, default: 0)
+ public func postNewsArticle(
+ title: String,
+ text: String,
+ path: [String],
+ parentID: UInt32 = 0
+ ) async throws {
+ guard !path.isEmpty else {
+ throw HotlineClientError.invalidResponse
}
+
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .postNewsArticle)
+ transaction.setFieldPath(type: .newsPath, val: path)
+ transaction.setFieldUInt32(type: .newsArticleID, val: parentID)
+ transaction.setFieldString(type: .newsArticleTitle, val: title)
+ transaction.setFieldString(type: .newsArticleDataFlavor, val: "text/plain")
+ transaction.setFieldUInt32(type: .newsArticleFlags, val: 0)
+ transaction.setFieldString(type: .newsArticleData, val: text)
+
+ try await self.sendTransaction(transaction)
}
- @MainActor func sendDeleteFile(
- name fileName: String, path filePath: [String], callback: ((Bool) -> Void)? = nil
- ) {
- var t = HotlineTransaction(type: .deleteFile)
- t.setFieldString(type: .fileName, val: fileName)
- t.setFieldPath(type: .filePath, val: filePath)
- self.sendPacket(t) { reply, err in
- callback?(err == nil)
+ // MARK: - Message Board
+
+ /// Get message board posts
+ ///
+ /// - Returns: Array of message strings
+ public func getMessageBoard() async throws -> [String] {
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard)
+ let reply = try await self.sendTransaction(transaction)
+
+ guard let text = reply.getField(type: .data)?.getString() else {
+ return []
}
+
+ // Parse messages (separated by divider pattern)
+ // TODO: Implement proper divider parsing if needed
+ return [text]
}
- @MainActor func sendGetFileInfo(
- name fileName: String, path filePath: [String], callback: ((FileDetails?) -> Void)? = nil
- ) {
- var t = HotlineTransaction(type: .getFileInfo)
- t.setFieldString(type: .fileName, val: fileName)
- t.setFieldPath(type: .filePath, val: filePath)
+ /// Post to the message board
+ ///
+ /// - Parameter text: Message text
+ public func postMessageBoard(_ text: String) async throws {
+ guard !text.isEmpty else { return }
- self.sendPacket(t) { reply, err in
- guard err == nil,
- let fileName = reply.getField(type: .fileName)?.getString(),
- let fileCreator = reply.getField(type: .fileCreatorString)?.getString(),
- let fileType = reply.getField(type: .fileTypeString)?.getString(),
- reply.getField(type: .fileTypeString)?.getString() != nil,
- let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0),
- let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0)
- else {
- callback?(nil)
- return
- }
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews)
+ transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman)
- // Size field is not included in server reply for folders
- let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0
+ try await self.socket.send(transaction, endian: .big)
+ }
+
+ // MARK: - Administration
+
+ /// Get list of user accounts (requires admin access)
+ ///
+ /// - Returns: Array of user accounts sorted by login
+ public func getAccounts() async throws -> [HotlineAccount] {
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts)
+ let reply = try await self.sendTransaction(transaction)
- // Comment field is not included for if no comment present
- let fileComment = reply.getField(type: .fileComment)?.getString() ?? ""
+ let accountFields = reply.getFieldList(type: .data)
+ var accounts: [HotlineAccount] = []
- callback?(
- FileDetails(
- name: fileName, path: filePath, size: fileSize, comment: fileComment, type: fileType,
- creator: fileCreator,
- created: fileCreateDate, modified: fileModifyDate))
+ for data in accountFields {
+ accounts.append(data.getAcccount())
}
+
+ accounts.sort { $0.name < $1.name }
+
+ return accounts
}
- @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) {
- var t = HotlineTransaction(type: .newUser)
+ /// Create a new user account (requires admin access)
+ ///
+ /// - Parameters:
+ /// - name: Display name for the user
+ /// - login: Login username
+ /// - password: Optional password (nil for no password)
+ /// - access: Access permissions bitmask
+ public func createUser(name: String, login: String, password: String?, access: UInt64) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newUser)
- t.setFieldString(type: .userName, val: name)
- t.setFieldEncodedString(type: .userLogin, val: login)
- t.setFieldUInt64(type: .userAccess, val: access)
+ transaction.setFieldString(type: .userName, val: name)
+ transaction.setFieldEncodedString(type: .userLogin, val: login)
+ transaction.setFieldUInt64(type: .userAccess, val: access)
if let password {
- t.setFieldEncodedString(type: .userPassword, val: password)
+ transaction.setFieldEncodedString(type: .userPassword, val: password)
}
- self.sendPacket(t)
- // TODO: handle errors
+ try await self.sendTransaction(transaction)
}
- @MainActor func sendSetUser(
- name: String, login: String, newLogin: String?, password: String?, access: uint64
- ) {
- var t = HotlineTransaction(type: .setUser)
- t.setFieldString(type: .userName, val: name)
- t.setFieldUInt64(type: .userAccess, val: access)
+ /// Update an existing user account (requires admin access)
+ ///
+ /// - Parameters:
+ /// - name: Display name for the user
+ /// - login: Current login username
+ /// - newLogin: New login username (nil to keep current)
+ /// - password: Password update - nil to keep current, "" to remove, or new password string
+ /// - access: Access permissions bitmask
+ public func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setUser)
+ transaction.setFieldString(type: .userName, val: name)
+ transaction.setFieldUInt64(type: .userAccess, val: access)
if let newLogin {
- t.setFieldEncodedString(type: .data, val: login)
- t.setFieldEncodedString(type: .userLogin, val: newLogin)
+ transaction.setFieldEncodedString(type: .data, val: login)
+ transaction.setFieldEncodedString(type: .userLogin, val: newLogin)
} else {
- t.setFieldEncodedString(type: .userLogin, val: login)
+ transaction.setFieldEncodedString(type: .userLogin, val: login)
}
- // In the setUser transaction, there are 3 possibilities for the password field:
- // 1. If the password was not modified, the password field is sent with a zero byte.
+ // Password field handling:
+ // - nil: Keep current password (send zero byte)
+ // - "": Remove password (omit field)
+ // - other: Set new password
if password == nil {
- t.setFieldUInt8(type: .userPassword, val: 0)
+ transaction.setFieldUInt8(type: .userPassword, val: 0)
}
-
- // 2. If the transaction should update the password, the password field is sent with the new password.
- if let password, password != "" {
- t.setFieldEncodedString(type: .userPassword, val: password)
+ else if password == "" {
+ // Don't add password to transaction (password will be removed)
+ }
+ else {
+ transaction.setFieldEncodedString(type: .userPassword, val: password!)
}
- // 3) If the transaction should remove the password, the password field is omitted from the transaction.
- self.sendPacket(t)
- // TODO: handle errors
+ try await self.sendTransaction(transaction)
}
- @MainActor func sendDeleteUser(login: String) {
- var t = HotlineTransaction(type: .deleteUser)
- t.setFieldEncodedString(type: .userLogin, val: login)
+ /// Delete a user account (requires admin access)
+ ///
+ /// - Parameter login: Login username to delete
+ public func deleteUser(login: String) async throws {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser)
+ transaction.setFieldEncodedString(type: .userLogin, val: login)
- self.sendPacket(t)
- // TODO: handle errors
+ try await self.sendTransaction(transaction)
}
- @MainActor func sendSetFileInfo(
- fileName: String, path filePath: [String], fileNewName: String?, comment: String?,
- encoding: String.Encoding = .utf8
- ) {
- var t = HotlineTransaction(type: .setFileInfo)
- t.setFieldString(type: .fileName, val: fileName, encoding: encoding)
- t.setFieldPath(type: .filePath, val: filePath)
+ // MARK: - Banners
- if fileNewName != nil {
- t.setFieldString(type: .fileNewName, val: fileNewName!, encoding: encoding)
- }
+ /// Request to download the server banner image
+ ///
+ /// - Returns: Tuple of (referenceNumber, transferSize) for the banner download
+ /// - Throws: HotlineClientError if not connected or server doesn't support banners
+ public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? {
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner)
+ let reply = try await self.sendTransaction(transaction)
- if comment != nil {
- t.setFieldString(type: .fileComment, val: comment!, encoding: encoding)
+ guard
+ let transferSizeField = reply.getField(type: .transferSize),
+ let transferSize = transferSizeField.getInteger(),
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
+ return nil
}
- self.sendPacket(t)
+ return (referenceNumber, transferSize)
}
- @MainActor func sendDownloadFile(
- name fileName: String, path filePath: [String], preview: Bool = false,
- callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil
- ) {
- var t = HotlineTransaction(type: .downloadFile)
- t.setFieldString(type: .fileName, val: fileName)
- t.setFieldPath(type: .filePath, val: filePath)
+ // MARK: - Transfers
+
+ /// Request to download a file
+ ///
+ /// - Parameters:
+ /// - name: File name to download
+ /// - path: Directory path containing the file
+ /// - preview: If true, request preview mode (smaller transfer)
+ /// - Returns: Tuple of (referenceNumber, transferSize, fileSize, waitingCount) for the download
+ public func downloadFile(name: String, path: [String], preview: Bool = false) async throws -> (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
+
if preview {
- t.setFieldUInt32(type: .fileTransferOptions, val: 2)
+ transaction.setFieldUInt32(type: .fileTransferOptions, val: 2)
}
- self.sendPacket(t) { reply, err in
- guard err == nil,
- let transferSizeField = reply.getField(type: .transferSize),
- let transferSize = transferSizeField.getInteger(),
- let transferReferenceField = reply.getField(type: .referenceNumber),
- let referenceNumber = transferReferenceField.getUInt32()
- else {
- callback?(false, nil, nil, nil, nil)
- return
- }
-
- let transferFileSizeField = reply.getField(type: .fileSize)
- let transferFileSize = transferFileSizeField?.getInteger()
- let transferWaitingCountField = reply.getField(type: .waitingCount)
- let transferWaitingCount = transferWaitingCountField?.getInteger()
+ let reply = try await self.sendTransaction(transaction)
- callback?(
- true, referenceNumber, transferSize, transferFileSize ?? transferSize, transferWaitingCount)
+ guard
+ let transferSizeField = reply.getField(type: .transferSize),
+ let transferSize = transferSizeField.getInteger(),
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
+ return nil
}
+
+ let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? transferSize
+ let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0
+
+ return (referenceNumber, transferSize, fileSize, waitingCount)
}
- @MainActor func sendUploadFile(
- name fileName: String, path filePath: [String], callback: ((Bool, UInt32?) -> Void)? = nil
- ) {
- var t = HotlineTransaction(type: .uploadFile)
- t.setFieldString(type: .fileName, val: fileName)
- t.setFieldPath(type: .filePath, val: filePath)
+ /// Request to download a folder
+ ///
+ /// - Parameters:
+ /// - name: Folder name to download
+ /// - path: Directory path containing the folder
+ /// - Returns: Tuple of (referenceNumber, transferSize, itemCount, waitingCount) for the download
+ public func downloadFolder(name: String, path: [String]) async throws -> (referenceNumber: UInt32, transferSize: Int, itemCount: Int, waitingCount: Int)? {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFolder)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
- self.sendPacket(t) { reply, err in
- guard err == nil,
- let transferReferenceField = reply.getField(type: .referenceNumber),
- let referenceNumber = transferReferenceField.getUInt32()
- else {
- callback?(false, nil)
- return
- }
+ let reply = try await self.sendTransaction(transaction)
- callback?(true, referenceNumber)
+ guard
+ let transferSizeField = reply.getField(type: .transferSize),
+ let transferSize = transferSizeField.getInteger(),
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
+ return nil
}
+
+ let itemCount = reply.getField(type: .folderItemCount)?.getInteger() ?? 0
+ let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0
+
+ return (referenceNumber, transferSize, itemCount, waitingCount)
}
- @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) {
- let t = HotlineTransaction(type: .downloadBanner)
+ /// Uploads a file to the server
+ /// - Parameters:
+ /// - name: File name to upload
+ /// - path: Directory path where the file should be uploaded
+ /// - Returns: Reference number for the upload transfer
+ public func uploadFile(name: String, path: [String]) async throws -> UInt32? {
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFile)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
- self.sendPacket(t) { reply, err in
- guard err == nil,
- let transferSizeField = reply.getField(type: .transferSize),
- let transferSize = transferSizeField.getInteger(),
- let transferReferenceField = reply.getField(type: .referenceNumber),
- let referenceNumber = transferReferenceField.getUInt32()
- else {
- callback?(false, nil, nil)
- return
- }
+ let reply = try await self.sendTransaction(transaction)
- callback?(true, referenceNumber, transferSize)
+ guard
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
+ return nil
}
- }
- @MainActor private func sendKeepAlive() {
- print("HotlineClient: Sending keep alive")
- if let v = self.serverVersion, v >= 185 {
- let t = HotlineTransaction(type: .connectionKeepAlive)
- self.sendPacket(t)
- } else {
- let t = HotlineTransaction(type: .getUserNameList)
- self.sendPacket(t)
- }
+ return referenceNumber
}
+
+ /// Request to upload a folder
+ ///
+ /// - Parameters:
+ /// - name: Folder name to upload
+ /// - path: Directory path where the folder should be uploaded
+ /// - Returns: Reference number for the upload transfer
+ public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? {
+ print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)")
- // MARK: - Utility
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder)
+ transaction.setFieldString(type: .fileName, val: name)
+ transaction.setFieldPath(type: .filePath, val: path)
+ transaction.setFieldUInt32(type: .transferSize, val: totalSize)
+ transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount))
- @MainActor private func updateConnectionStatus(_ status: HotlineClientStatus) {
- self.connectionStatus = status
- self.delegate?.hotlineStatusChanged(status: status)
- }
+ let reply = try await self.sendTransaction(transaction)
+ guard
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
+ return nil
+ }
+
+ return referenceNumber
+ }
}
diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift
index dcfe14b..25ee3b0 100644
--- a/Hotline/Hotline/HotlineExtensions.swift
+++ b/Hotline/Hotline/HotlineExtensions.swift
@@ -7,12 +7,66 @@ enum LineEnding {
case cr // Classic Mac-style (\r)
}
+// MARK: -
+
extension URL {
func urlForResourceFork() -> URL {
self.appendingPathComponent("..namedfork/rsrc")
}
+
+ /// Append multiple path components to a URL by iterating through an array
+ /// - Parameter components: Array of path component strings
+ /// - Returns: New URL with all components appended
+ func appendingPathComponents(_ components: [String]) -> URL {
+ var path = self.path
+ for component in components {
+ path = (path as NSString).appendingPathComponent(component)
+ }
+ return URL(filePath: path)
+ }
+
+#if os(macOS)
+ func notifyDownloadFinished() {
+ print("DOWNLOAD FINISHED AT", self)
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: self.path)
+ }
+#endif
}
+// MARK: -
+
+public struct TaskTimeoutError: Error {}
+
+extension Task where Success == Never, Failure == Never {
+ public static func withTimeout<T>(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T {
+ if seconds <= 0 {
+ throw TaskTimeoutError()
+ }
+
+ return try await withThrowingTaskGroup(of: T.self) { group in
+ group.addTask {
+ try await operation()
+ }
+
+ group.addTask {
+ try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
+ throw TaskTimeoutError()
+ }
+
+ do {
+ let value = try await group.next()!
+ group.cancelAll()
+ return value
+ } catch {
+ group.cancelAll()
+ throw error
+ }
+ }
+ }
+}
+
+// MARK: -
+
extension String {
func convertingLineEndings(to targetEnding: LineEnding) -> String {
let lf = "\n"
@@ -20,17 +74,16 @@ extension String {
let cr = "\r"
// Normalize all line endings to LF (\n)
- let normalizedString = self.replacingOccurrences(of: cr, with: lf).replacingOccurrences(
- of: crlf, with: lf)
-
+ let normalizedString = self.replacing(cr, with: lf).replacing(crlf, with: lf)
+
// Replace normalized LF (\n) line endings with the target line ending
switch targetEnding {
case .lf:
return normalizedString
case .crlf:
- return normalizedString.replacingOccurrences(of: lf, with: crlf)
+ return normalizedString.replacing(lf, with: crlf)
case .cr:
- return normalizedString.replacingOccurrences(of: lf, with: cr)
+ return normalizedString.replacing(lf, with: cr)
}
}
@@ -50,6 +103,8 @@ extension String {
}
}
+// MARK: -
+
extension FileManager {
static var extensionToHFSCreator: [String: UInt32] = [
// Documents
@@ -409,7 +464,7 @@ extension FileManager {
// Get resource fork size.
var resourceForkSize: UInt32 = 0
- let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc")
+ let resourceFileURL: URL = fileURL.urlForResourceFork()
let resourceFilePath: String = fileURL.path(percentEncoded: false)
if self.fileExists(atPath: resourceFilePath) {
let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(
@@ -423,6 +478,36 @@ extension FileManager {
return (dataForkSize: dataForkSize, resourceForkSize: resourceForkSize)
}
+
+ /// Create a file with metadata from a Hotline INFO fork
+ ///
+ /// - Parameters:
+ /// - url: Destination URL for the new file
+ /// - infoFork: Hotline file info containing metadata
+ /// - Returns: FileHandle open for writing
+ /// - Throws: Error if file creation fails
+ func createHotlineFile(at url: URL, infoFork: HotlineFileInfoFork) throws -> FileHandle {
+ var attributes: [FileAttributeKey: Any] = [:]
+
+ if infoFork.creator != 0 {
+ attributes[.hfsCreatorCode] = infoFork.creator as NSNumber
+ }
+ if infoFork.type != 0 {
+ attributes[.hfsTypeCode] = infoFork.type as NSNumber
+ }
+ attributes[.creationDate] = infoFork.createdDate as NSDate
+ attributes[.modificationDate] = infoFork.modifiedDate as NSDate
+
+ guard self.createFile(atPath: url.path, contents: nil, attributes: attributes) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ guard let handle = FileHandle(forWritingAtPath: url.path) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ return handle
+ }
func getFlattenedFileSize(_ fileURL: URL) -> UInt64? {
var fileIsDirectory: ObjCBool = false
@@ -434,8 +519,8 @@ extension FileManager {
else {
return nil
}
-
- guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman) else {
+
+ guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman, allowLossyConversion: true) else {
return nil
}
@@ -465,30 +550,71 @@ extension FileManager {
}
// Add data fork size.
- // var dataSize: UInt64 = 0
- // let dataFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: fileURL.path(percentEncoded: false))
- // if let dataSizeAttribute: UInt64 = dataFileAttributes?[FileAttributeKey.size] as? UInt64 {
- // dataSize = UInt64(dataSizeAttribute)
- // }
- //
- // totalSize += dataSize
- //
- // // Add resource fork size.
- // var resourceForkSize: UInt64 = 0
- // let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc")
- // let resourceFilePath: String = fileURL.path(percentEncoded: false)
- // if self.fileExists(atPath: resourceFilePath) {
- // let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false))
- // if let resourceSizeAttribute: UInt64 = resourceFileAttributes?[FileAttributeKey.size] as? UInt64 {
- // resourceForkSize = UInt64(resourceSizeAttribute)
- // print("FOUND RESOURCE FORK: \(resourceForkSize)")
- // }
- // }
- //
- // totalSize += resourceForkSize
+// var dataSize: UInt64 = 0
+// let dataFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: fileURL.path(percentEncoded: false))
+// if let dataSizeAttribute: UInt64 = dataFileAttributes?[FileAttributeKey.size] as? UInt64 {
+// dataSize = UInt64(dataSizeAttribute)
+// }
+//
+// totalSize += dataSize
+//
+// // Add resource fork size.
+// var resourceForkSize: UInt64 = 0
+// let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc")
+// let resourceFilePath: String = fileURL.path(percentEncoded: false)
+// if self.fileExists(atPath: resourceFilePath) {
+// let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false))
+// if let resourceSizeAttribute: UInt64 = resourceFileAttributes?[FileAttributeKey.size] as? UInt64 {
+// resourceForkSize = UInt64(resourceSizeAttribute)
+// print("FOUND RESOURCE FORK: \(resourceForkSize)")
+// }
+// }
+//
+// totalSize += resourceForkSize
return totalSize
}
+
+ /// Get the total size of all files in a folder (recursively)
+ /// Returns the sum of flattened file sizes for all files in the folder, and the total count of all items (files + folders)
+ func getFolderSize(_ folderURL: URL) -> (size: UInt64, count: UInt32)? {
+ var isDirectory: ObjCBool = false
+ let folderPath = folderURL.path(percentEncoded: false)
+
+ guard folderURL.isFileURL,
+ self.fileExists(atPath: folderPath, isDirectory: &isDirectory),
+ isDirectory.boolValue == true else {
+ return nil
+ }
+
+ var totalSize: UInt64 = 0
+ var itemCount: UInt32 = 0
+
+ guard let enumerator = self.enumerator(at: folderURL, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], options: [.skipsHiddenFiles]) else {
+ return nil
+ }
+
+ for case let fileURL as URL in enumerator {
+ guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) else {
+ continue
+ }
+
+ let isRegularFile = resourceValues.isRegularFile ?? false
+ let isDirectory = resourceValues.isDirectory ?? false
+
+ // Count all items (files and folders)
+ if isRegularFile || isDirectory {
+ itemCount += 1
+
+ // Only add size for files, not folders
+ if isRegularFile, let fileSize = self.getFlattenedFileSize(fileURL) {
+ totalSize += fileSize
+ }
+ }
+ }
+
+ return (size: totalSize, count: itemCount)
+ }
}
extension Date {
@@ -733,15 +859,11 @@ extension Array where Element == UInt8 {
}
func readString(at offset: Int, length: Int) -> String? {
- guard let subdata: Data = self.readData(at: offset, length: length) else {
+ guard let data: Data = self.readData(at: offset, length: length) else {
return nil
}
-
- if subdata.count == 0 {
- return ""
- }
-
- return String(data: subdata, encoding: .macOSRoman)
+
+ return data.readString(at: 0, length: length)
}
func readPString(at offset: Int) -> (String?, Int) {
@@ -1025,3 +1147,54 @@ extension FourCharCode {
return String(bytes: bytes, encoding: .ascii) ?? ""
}
}
+
+
+extension NetSocket {
+ /// Read a pascal string (1-byte length prefix followed by string data)
+ ///
+ /// This method reads a single byte for the length, then reads that many bytes and attempts
+ /// to decode them as a string. It tries multiple encodings for compatibility with legacy
+ /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman.
+ ///
+ /// - Returns: The decoded string, or nil if length is 0
+ /// - Throws: `NetSocketError` if reading fails or no encoding succeeds
+ func readPascalString() async throws -> String? {
+ let length = try await read(UInt8.self)
+ guard length > 0 else { return nil }
+
+ let dataLength = Int(length)
+ let data = try await read(dataLength)
+
+ return data.readString(at: 0, length: dataLength)
+
+ // Try auto-detection with common encodings
+// let allowedEncodings = [
+// String.Encoding.utf8.rawValue,
+// String.Encoding.shiftJIS.rawValue,
+// String.Encoding.unicode.rawValue,
+// String.Encoding.windowsCP1251.rawValue
+// ]
+//
+// var decodedString: NSString?
+// let detected = NSString.stringEncoding(
+// for: data,
+// encodingOptions: [.allowLossyKey: false],
+// convertedString: &decodedString,
+// usedLossyConversion: nil
+// )
+//
+// if allowedEncodings.contains(detected), let str = decodedString as? String {
+// return str
+// }
+//
+// // Fallback to MacRoman for classic Mac compatibility
+// guard let str = String(data: data, encoding: .macOSRoman) else {
+// throw NetSocketError.decodeFailed(NSError(
+// domain: "NetSocket",
+// code: -1,
+// userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"]
+// ))
+// }
+// return str
+ }
+}
diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift
index 52f4b1f..c384124 100644
--- a/Hotline/Hotline/HotlineProtocol.swift
+++ b/Hotline/Hotline/HotlineProtocol.swift
@@ -1,23 +1,41 @@
import Foundation
+enum Endianness {
+ case big
+ case little
+}
+
struct HotlinePorts {
static let DefaultServerPort: Int = 5500
static let DefaultTrackerPort: Int = 5498
}
-struct HotlineUserOptions: OptionSet {
- let rawValue: UInt16
+public enum HotlineUserDisconnectOptions: UInt16 {
+ case temporarilyBan = 1
+ case permanentlyBan = 2
+}
+
+public struct HotlineUserOptions: OptionSet, Sendable {
+ public let rawValue: UInt16
+
+ public init(rawValue: UInt16) {
+ self.rawValue = rawValue
+ }
- static let none: HotlineUserOptions = []
+ public static let none: HotlineUserOptions = []
- static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0)
- static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1)
- static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2)
+ public static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0)
+ public static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1)
+ public static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2)
}
-struct HotlineUserAccessOptions: OptionSet {
- let rawValue: UInt64
+public struct HotlineUserAccessOptions: OptionSet, Sendable {
+ public let rawValue: UInt64
+ public init(rawValue: UInt64) {
+ self.rawValue = rawValue
+ }
+
static func accessIndexToBit(_ index: Int) -> Int {
return 63 - index
}
@@ -205,7 +223,7 @@ struct HotlineUserAccessOptions: OptionSet {
rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(40))
}
-struct HotlineServer: Identifiable, Hashable {
+struct HotlineServer: Identifiable, Hashable, NetSocketDecodable {
let id = UUID()
let address: String
let port: UInt16
@@ -220,39 +238,61 @@ struct HotlineServer: Identifiable, Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
+
+ init(from socket: NetSocket, endian: Endian) async throws {
+ // Read IP address (4 individual bytes)
+ let ip1 = try await socket.read(UInt8.self)
+ let ip2 = try await socket.read(UInt8.self)
+ let ip3 = try await socket.read(UInt8.self)
+ let ip4 = try await socket.read(UInt8.self)
+ self.address = "\(ip1).\(ip2).\(ip3).\(ip4)"
+
+ // Read port and user count (big-endian)
+ self.port = try await socket.read(UInt16.self, endian: endian)
+ self.users = try await socket.read(UInt16.self, endian: endian)
+
+ // Skip 2 unused bytes
+ _ = try await socket.read(2)
+
+ // Read pascal strings (auto-detects encoding)
+ self.name = try await socket.readPascalString()
+ self.description = try await socket.readPascalString()
+ }
}
-struct HotlineNewsArticle: Identifiable {
- let id: UInt32
- let parentID: UInt32
- let flags: UInt32
- let title: String
- let username: String
- let date: Date?
- var flavors: [(String, UInt16)] = []
- var path: [String] = []
+public struct HotlineNewsArticle: Identifiable, Sendable {
+ public let id: UInt32
+ public let parentID: UInt32
+ public let flags: UInt32
+ public let title: String
+ public let username: String
+ public let date: Date?
+ public var flavors: [(String, UInt16)] = []
+ public var path: [String] = []
- static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool {
+ public static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool {
return lhs.id == rhs.id
}
}
-struct HotlineAccount: Identifiable {
- let id: UUID = UUID()
+public struct HotlineAccount: Identifiable {
+ public let id: UUID = UUID()
var name: String = ""
var login: String = ""
- var password: String? = nil
- var persisted: Bool = true
+ var password: String = ""
+ var persisted: Bool = false
var access: HotlineUserAccessOptions = HotlineUserAccessOptions()
var fields: [HotlineTransactionField] = []
init(from data: [UInt8]) {
self.decodeFields(from: data)
+ self.persisted = true
}
init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) {
self.name = name
self.login = login
+ self.password = HotlineAccount.randomPassword()
self.access = access
self.persisted = false
}
@@ -283,7 +323,7 @@ struct HotlineAccount: Identifiable {
}
if fieldType == .userLogin {
- self.login = field.getObfuscatedString()!
+ self.login = field.getObfuscatedString() ?? ""
}
if fieldType == .userPassword {
@@ -303,7 +343,7 @@ struct HotlineAccount: Identifiable {
}
}
- static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool {
+ public static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool {
return lhs.id == rhs.id
}
@@ -317,7 +357,7 @@ struct HotlineAccount: Identifiable {
}
extension HotlineAccount: Hashable {
- func hash(into hasher: inout Hasher) {
+ public func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
}
@@ -416,22 +456,22 @@ struct HotlineNewsList: Identifiable {
}
}
-struct HotlineNewsCategory: Identifiable, Hashable {
- let id = UUID()
- let type: UInt16
- let count: UInt16
- let name: String
- var path: [String] = []
+public struct HotlineNewsCategory: Identifiable, Hashable, Sendable {
+ public let id = UUID()
+ public let type: UInt16
+ public let count: UInt16
+ public let name: String
+ public var path: [String] = []
- static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool {
+ public static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool {
return lhs.id == rhs.id
}
- func hash(into hasher: inout Hasher) {
+ public func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
- init(type: UInt16, count: UInt16, name: String) {
+ public init(type: UInt16, count: UInt16, name: String) {
self.type = type
self.count = count
self.name = name
@@ -462,20 +502,20 @@ struct HotlineNewsCategory: Identifiable, Hashable {
}
@Observable
-class HotlineFile: Identifiable, Hashable {
- let id = UUID()
- let type: String
- let creator: String
- let fileSize: UInt32
- let name: String
-
- var path: [String] = []
- var isExpanded: Bool = false
- var files: [HotlineFile]? = nil
+public class HotlineFile: Identifiable, Hashable {
+ public let id = UUID()
+ public let type: String
+ public let creator: String
+ public let fileSize: UInt32
+ public let name: String
- let isFolder: Bool
+ public var path: [String] = []
+ public var isExpanded: Bool = false
+ public var files: [HotlineFile]? = nil
- var isDropboxFolder: Bool {
+ public let isFolder: Bool
+
+ public var isDropboxFolder: Bool {
guard self.isFolder,
(self.name.range(of: "upload", options: [.caseInsensitive]) != nil)
|| (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil)
@@ -485,11 +525,11 @@ class HotlineFile: Identifiable, Hashable {
return true
}
- static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool {
+ public static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool {
return lhs.id == rhs.id
}
- func hash(into hasher: inout Hasher) {
+ public func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
@@ -527,25 +567,34 @@ class HotlineFile: Identifiable, Hashable {
}
}
-struct HotlineUser: Identifiable, Hashable {
- let id: UInt16
- let iconID: UInt16
- let status: UInt16
- let name: String
+public struct HotlineUserClientInfo: Identifiable {
+ public var username: String
+ public var details: String
+
+ public var id: String {
+ self.username
+ }
+}
+
+public struct HotlineUser: Identifiable, Hashable, Sendable {
+ public let id: UInt16
+ public let iconID: UInt16
+ public let status: UInt16
+ public let name: String
- var isAdmin: Bool {
+ public var isAdmin: Bool {
return ((self.status & 0x0002) != 0)
}
- var isIdle: Bool {
+ public var isIdle: Bool {
return ((self.status & 0x0001) != 0)
}
- static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool {
+ public static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool {
return lhs.id == rhs.id
}
- init(id: UInt16, iconID: UInt16, status: UInt16, name: String) {
+ public init(id: UInt16, iconID: UInt16, status: UInt16, name: String) {
self.id = id
self.iconID = iconID
self.status = status
@@ -560,8 +609,8 @@ struct HotlineUser: Identifiable, Hashable {
let userNameLength = Int(data.readUInt16(at: 6)!)
self.name = data.readString(at: 8, length: userNameLength)!
}
-
- func hash(into hasher: inout Hasher) {
+
+ public func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
@@ -750,13 +799,7 @@ struct HotlineTransactionField {
struct HotlineTransaction {
static let headerSize = 20
- static var sequenceID: UInt32 = 1
-
- static func nextID() -> UInt32 {
- HotlineTransaction.sequenceID += 1
- return HotlineTransaction.sequenceID
- }
-
+
var flags: UInt8 = 0
var isReply: UInt8 = 0
var type: HotlineTransactionType
@@ -766,10 +809,10 @@ struct HotlineTransaction {
var dataSize: UInt32 = 0
var fields: [HotlineTransactionField] = []
-
- init(type: HotlineTransactionType) {
+
+ init(id: UInt32, type: HotlineTransactionType) {
self.type = type
- self.id = HotlineTransaction.nextID()
+ self.id = id
}
init?(from data: [UInt8]) {
@@ -860,7 +903,112 @@ struct HotlineTransaction {
mutating func setFieldPath(type: HotlineTransactionFieldType, val: [String]) {
self.fields.append(HotlineTransactionField(type: type, pathComponents: val))
}
+
+ // MARK: - Subscript support for typed field access
+ // Replaces any existing field with the same type, or removes the field if value is set to nil
+ private mutating func replaceField(type: HotlineTransactionFieldType, with field: HotlineTransactionField?) {
+ // Remove existing fields of this type
+ self.fields.removeAll { $0.type == type }
+ // Append new field if provided
+ if let field = field {
+ self.fields.append(field)
+ }
+ }
+
+ // UInt8
+ subscript(_ type: HotlineTransactionFieldType) -> UInt8? {
+ get { self.getField(type: type)?.getUInt8() }
+ set {
+ if let v = newValue {
+ self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v))
+ } else {
+ self.replaceField(type: type, with: nil)
+ }
+ }
+ }
+
+ // UInt16
+ subscript(_ type: HotlineTransactionFieldType) -> UInt16? {
+ get { self.getField(type: type)?.getUInt16() }
+ set {
+ if let v = newValue {
+ self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v))
+ } else {
+ self.replaceField(type: type, with: nil)
+ }
+ }
+ }
+
+ // UInt32
+ subscript(_ type: HotlineTransactionFieldType) -> UInt32? {
+ get { self.getField(type: type)?.getUInt32() }
+ set {
+ if let v = newValue {
+ self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v))
+ } else {
+ self.replaceField(type: type, with: nil)
+ }
+ }
+ }
+ // UInt64
+ subscript(_ type: HotlineTransactionFieldType) -> UInt64? {
+ get { self.getField(type: type)?.getUInt64() }
+ set {
+ if let v = newValue {
+ self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v))
+ } else {
+ self.replaceField(type: type, with: nil)
+ }
+ }
+ }
+
+ // String (plain, not obfuscated)
+ subscript(_ type: HotlineTransactionFieldType) -> String? {
+ get { self.getField(type: type)?.getString() }
+ set {
+ if let v = newValue {
+ self.replaceField(type: type, with: HotlineTransactionField(type: type, string: v, encoding: .utf8, encrypt: false))
+ } else {
+ self.replaceField(type: type, with: nil)
+ }
+ }
+ }
+
+ // [String] path components
+// subscript(path type: HotlineTransactionFieldType) -> [String]? {
+// get {
+// self.getField(type: type)?.getString()
+// }
+// set {
+// if let v = newValue {
+// self.replaceField(type: type, with: HotlineTransactionField(type: type, pathComponents: v))
+// } else {
+// self.replaceField(type: type, with: nil)
+// }
+// }
+// }
+
+ // Field list
+ subscript(_ type: HotlineTransactionFieldType) -> [HotlineTransactionField]? {
+ get { self.fields.filter { $0.type == type } }
+ set {
+ // Remove existing fields of this type
+ self.fields.removeAll { $0.type == type }
+
+ if let v = newValue {
+ self.fields.append(contentsOf: v)
+ }
+ }
+ }
+
+ // Field
+ subscript(_ type: HotlineTransactionFieldType) -> HotlineTransactionField? {
+ get { self.fields.first { $0.type == type } }
+ set { self.replaceField(type: type, with: newValue) }
+ }
+
+
func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? {
return self.fields.first { p in
p.type == type
@@ -903,6 +1051,64 @@ struct HotlineTransaction {
}
}
+// MARK: - NetSocket Protocol Conformance
+
+extension HotlineTransaction: NetSocketEncodable {
+ func encode(endian: Endian) throws -> Data {
+ return Data(self.encoded())
+ }
+}
+
+extension HotlineTransaction: NetSocketDecodable {
+ /// Decode a Hotline transaction directly from the socket stream
+ ///
+ /// Reads the 20-byte header, then reads and decodes the variable-length body with fields.
+ init(from socket: NetSocket, endian: Endian) async throws {
+ // Read 20-byte header
+ let flags = try await socket.read(UInt8.self)
+ let isReply = try await socket.read(UInt8.self)
+ let typeRaw = try await socket.read(UInt16.self, endian: endian)
+ let id = try await socket.read(UInt32.self, endian: endian)
+ let errorCode = try await socket.read(UInt32.self, endian: endian)
+ let totalSize = try await socket.read(UInt32.self, endian: endian)
+ let dataSize = try await socket.read(UInt32.self, endian: endian)
+
+ // Initialize with header data
+ self.flags = flags
+ self.isReply = isReply
+ self.type = HotlineTransactionType(rawValue: typeRaw) ?? .unknown
+ self.id = id
+ self.errorCode = errorCode
+ self.totalSize = totalSize
+ self.dataSize = dataSize
+ self.fields = []
+
+ // Read body if present
+ guard dataSize > 0 else { return }
+
+ let bodyData = try await socket.read(Int(dataSize))
+ var fieldBytes = [UInt8](bodyData)
+
+ // Decode fields
+ guard let fieldCount = fieldBytes.consumeUInt16(), fieldCount > 0 else { return }
+
+ for _ in 0..<fieldCount {
+ guard
+ let fieldID = fieldBytes.consumeUInt16(),
+ let fieldSize = fieldBytes.consumeUInt16()
+ else { break }
+
+ guard let fieldData: [UInt8] = fieldBytes.consumeBytes(Int(fieldSize)) else {
+ break
+ }
+
+ if let fieldType = HotlineTransactionFieldType(rawValue: fieldID) {
+ self.fields.append(HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldData))
+ }
+ }
+ }
+}
+
enum HotlineTransactionFieldType: UInt16 {
case errorText = 100 // String
case data = 101 // String
@@ -1031,3 +1237,36 @@ enum HotlineTransactionType: UInt16 {
case unknown = 15000
}
+
+// MARK: - NetSocketDecodable Conformance
+//
+//extension HotlineTrackerServer: NetSocketDecodable {
+// /// Decode a tracker server entry directly from the socket stream
+// ///
+// /// Wire format:
+// /// - IP address: 4 bytes (individual octets)
+// /// - Port: UInt16 (big-endian)
+// /// - User count: UInt16 (big-endian)
+// /// - Unused: 2 bytes
+// /// - Server name: Pascal string (1-byte length + data)
+// /// - Server description: Pascal string (1-byte length + data)
+// init(from socket: NetSocket, endian: Endian) async throws {
+// // Read IP address (4 individual bytes)
+// let ip1 = try await socket.read(UInt8.self)
+// let ip2 = try await socket.read(UInt8.self)
+// let ip3 = try await socket.read(UInt8.self)
+// let ip4 = try await socket.read(UInt8.self)
+// self.address = "\(ip1).\(ip2).\(ip3).\(ip4)"
+//
+// // Read port and user count (big-endian)
+// self.port = try await socket.read(UInt16.self, endian: endian)
+// self.users = try await socket.read(UInt16.self, endian: endian)
+//
+// // Skip 2 unused bytes
+// _ = try await socket.read(2)
+//
+// // Read pascal strings (auto-detects encoding)
+// self.name = try await socket.readPascalString()
+// self.description = try await socket.readPascalString()
+// }
+//}
diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift
index c3c1670..1a9b551 100644
--- a/Hotline/Hotline/HotlineTrackerClient.swift
+++ b/Hotline/Hotline/HotlineTrackerClient.swift
@@ -5,8 +5,8 @@ struct HotlineTracker: Identifiable, Equatable {
let id: UUID = UUID()
var address: String
var port: UInt16
-
- init(_ address: String, port: UInt16 = 5498) {
+
+ init(_ address: String, port: UInt16 = UInt16(HotlinePorts.DefaultTrackerPort)) {
self.address = address
self.port = port
}
@@ -16,219 +16,148 @@ struct HotlineTracker: Identifiable, Equatable {
}
}
-enum HotlineTrackerStatus: Int {
- case disconnected
- case connecting
- case connected
-}
-
-private enum HotlineTrackerStage {
- case magic
- case header
- case listing
- case done
-}
-
+/// Client for Hotline trackers
class HotlineTrackerClient {
- static let MagicPacket: [UInt8] = [
- 0x48, 0x54, 0x52, 0x4B, // 'HTRK'
- 0x00, 0x01, // Version
- ]
+ static let MagicCode = Data(endian: .big) {
+ "HTRK".fourCharCode()
+ }
+
+ static let MagicPacket = Data(endian: .big) {
+ HotlineTrackerClient.MagicCode
+ UInt16(0x0001) // Version
+ }
private var tracker: HotlineTracker
- private var connectionStatus: HotlineTrackerStatus = .disconnected
- private var servers: [HotlineServer] = []
-
- private var socket: NetSocket = NetSocket()
- private var stage: HotlineTrackerStage = .magic
-
- private var serverAddress: String
- private var serverPort: Int
- private var expectedDataLength: Int = 0
- private var serverCount: Int = 0
-
- private var fetchContinuation: CheckedContinuation<[HotlineServer], any Error>?
-
+
init() {
- let t = HotlineTracker("hltracker.com")
- self.tracker = t
- self.serverAddress = t.address
- self.serverPort = Int(t.port)
- self.socket.delegate = self
+ self.tracker = HotlineTracker("hltracker.com")
}
init(tracker: HotlineTracker) {
self.tracker = tracker
- self.serverAddress = tracker.address
- self.serverPort = Int(tracker.port)
- self.socket.delegate = self
- }
-
- @MainActor func fetchServers(address: String, port: Int) async throws -> [HotlineServer] {
- self.serverAddress = address
- self.serverPort = Int(port)
-
- self.reset()
-
- return try await withCheckedThrowingContinuation { [weak self] continuation in
- self?.fetchContinuation = continuation
- self?.connect()
- }
- }
-
- @MainActor func close() {
- self.socket.close()
- }
-
- // MARK: -
-
- @MainActor private func reset() {
- self.expectedDataLength = 0
- self.serverCount = 0
- self.servers = []
- }
-
- @MainActor private func connect() {
- self.socket.close()
-
- self.connectionStatus = .connecting
- self.socket.connect(host: self.serverAddress, port: self.serverPort)
- self.socket.write(HotlineTrackerClient.MagicPacket)
- }
-
- @MainActor private func receiveMagic() {
- guard self.stage == .magic, self.socket.available >= HotlineTrackerClient.MagicPacket.count
- else {
- return
- }
-
- let magic: [UInt8] = self.socket.read(count: HotlineTrackerClient.MagicPacket.count)
-
- if magic != HotlineTrackerClient.MagicPacket {
- self.socket.close()
- return
- }
-
- self.stage = .header
- self.receiveHeader()
}
-
- @MainActor private func receiveHeader() {
- guard self.stage == .header, self.socket.available >= 8 else {
- return
- }
-
- var header: [UInt8] = self.socket.read(count: 8)
-
- guard let messageType = header.consumeUInt16(),
- let dataLength = header.consumeUInt16(),
- let numberOfServers = header.consumeUInt16(),
- let numberOfServers2 = header.consumeUInt16()
- else {
- self.socket.close()
- return
- }
-
- print(
- "HotlineTrackerClient: Received response header ", messageType, dataLength, numberOfServers,
- numberOfServers2)
-
- self.expectedDataLength = Int(dataLength)
- self.expectedDataLength -= 4 // Remove the size of the two server count fields
- self.serverCount = Int(numberOfServers)
-
- self.stage = .listing
- self.receiveListing()
- }
-
- @MainActor private func receiveListing() {
- guard self.stage == .listing, self.socket.available >= self.expectedDataLength else {
- return
- }
-
- self.parseListing(self.socket.read(count: self.expectedDataLength))
- }
-
- @MainActor private func parseListing(_ listingBytes: [UInt8]) {
- // IP address (4 bytes)
- // Port number (2 bytes)
- // Number of users (2 bytes)
- // Unused (2 bytes)
- // Name size (1 byte)
- // Name (name size)
- // Description size (1 byte)
- // Description (description size)
-
- var bytes: [UInt8] = listingBytes
- let trackerSeparatorRegex = /^[-]+$/
- var foundServers: [HotlineServer] = []
-
- for _ in 1...self.serverCount {
- guard
- let ip_1 = bytes.consumeUInt8(),
- let ip_2 = bytes.consumeUInt8(),
- let ip_3 = bytes.consumeUInt8(),
- let ip_4 = bytes.consumeUInt8(),
- let port = bytes.consumeUInt16(),
- let userCount = bytes.consumeUInt16(),
- bytes.consume(2),
- let serverName = bytes.consumePString(),
- let serverDescription = bytes.consumePString()
- else {
- print("HotlineTrackerClient: Data isn't long enough for next server")
- break
+
+ /// Fetch server list from the tracker
+ /// - Parameters:
+ /// - address: Tracker hostname or IP
+ /// - port: Tracker port (default: 5498)
+ /// - Returns: AsyncThrowingStream of servers as they arrive from the tracker
+ /// - Throws: Network or protocol errors
+ func fetchServers(address: String, port: Int) -> AsyncThrowingStream<HotlineServer, Error> {
+ return AsyncThrowingStream { continuation in
+ let task = Task {
+ await self.fetchServersInternal(address: address, port: port, continuation: continuation)
}
-
- // Ignore servers that are just used as dividers in the tracker listing.
- let validName = try? trackerSeparatorRegex.prefixMatch(in: serverName)
- if validName == nil {
- let server = HotlineServer(
- address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount,
- name: serverName, description: serverDescription)
- foundServers.append(server)
+
+ continuation.onTermination = { @Sendable _ in
+ task.cancel()
}
}
-
- self.servers = foundServers
- self.stage = .done
- self.socket.close()
- }
-}
-
-// MARK: -
-
-extension HotlineTrackerClient: NetSocketDelegate {
- @MainActor func netsocketConnected(socket: NetSocket) {
- self.connectionStatus = .connected
}
-
- @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) {
- self.stage = .magic
- self.connectionStatus = .disconnected
-
- let servers = self.servers
- self.reset()
-
- if let continuation = self.fetchContinuation {
- self.fetchContinuation = nil
- if let err = error {
- continuation.resume(throwing: err)
- } else {
- continuation.resume(returning: servers)
+
+ private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream<HotlineServer, Error>.Continuation) async {
+ do {
+ try await Task.withTimeout(seconds: 30) {
+ try await self.doFetch(address: address, port: port, continuation: continuation)
}
+ } catch {
+ print("HotlineTrackerClient: Error in fetchServersInternal: \(error)")
+ continuation.finish(throwing: error)
}
}
-
- @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {
- switch self.stage {
- case .magic:
- self.receiveMagic()
- case .header:
- self.receiveHeader()
- case .listing:
- self.receiveListing()
- case .done:
- break
+
+ private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream<HotlineServer, Error>.Continuation) async throws {
+ // Connect to tracker (plaintext, no TLS)
+ let socket = try await NetSocket.connect(
+ host: address,
+ port: UInt16(port)
+ )
+ defer { Task { await socket.close() } }
+
+ // Send magic packet
+ try await socket.write(HotlineTrackerClient.MagicPacket)
+
+ // Receive magic response (6 bytes: 'HTRK' + version)
+ let magicResponse = try await socket.read(6)
+ let magic = magicResponse[0..<4]
+ let version = UInt16(magicResponse[4]) << 8 | UInt16(magicResponse[5])
+
+ // Validate magic ('HTRK')
+ guard magic == Self.MagicCode else {
+ throw NetSocketError.decodeFailed(
+ NSError(domain: "HotlineTracker", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Invalid magic response from tracker (expected 'HTRK')"
+ ])
+ )
}
+
+ print("HotlineTrackerClient: Connected to tracker (version \(version))")
+
+ // Read server listings (may span multiple batches)
+ var totalYielded = 0
+ var totalEntriesParsed = 0 // Includes separators
+ var totalExpectedEntries: Int = 0
+ var batchCount = 0
+
+ repeat {
+ batchCount += 1
+
+ // Receive server information header (8 bytes)
+ // Format: [message type: u16][data length: u16][server count: u16][server count 2: u16]
+ let messageType = try await socket.read(UInt16.self, endian: .big)
+ let dataLength = try await socket.read(UInt16.self, endian: .big)
+ let serverCount = try await socket.read(UInt16.self, endian: .big)
+ let serverCount2 = try await socket.read(UInt16.self, endian: .big)
+
+ // First header tells us the total expected entries (includes separators)
+ if totalExpectedEntries == 0 {
+ totalExpectedEntries = Int(serverCount)
+ }
+
+ print("HotlineTrackerClient: Batch #\(batchCount) - type: \(messageType), dataLen: \(dataLength), count1: \(serverCount), count2: \(serverCount2)")
+
+ // Parse servers directly from socket stream (no buffering!)
+ let trackerSeparatorRegex = /^[-]+$/
+ var batchEntriesParsed = 0
+ var batchServersYielded = 0
+
+ for _ in 0..<Int(serverCount2) {
+ do {
+ // Decode server entry directly from socket stream
+ let server = try await socket.receive(HotlineServer.self)
+
+ batchEntriesParsed += 1
+ totalEntriesParsed += 1
+
+ // Filter out separator entries (servers with names like "-------")
+ let isSeparator = server.name.map {
+ (try? trackerSeparatorRegex.prefixMatch(in: $0)) != nil
+ } ?? false
+
+ if !isSeparator {
+ // Convert wire format to domain model
+ continuation.yield(server)
+ batchServersYielded += 1
+ totalYielded += 1
+ }
+ } catch {
+ print("HotlineTrackerClient: Failed to decode entry #\(batchEntriesParsed + 1): \(error)")
+ break
+ }
+ }
+
+ print("HotlineTrackerClient: Batch #\(batchCount): parsed \(batchEntriesParsed) entries, yielded \(batchServersYielded) servers (filtered \(batchEntriesParsed - batchServersYielded) separators)")
+ print("HotlineTrackerClient: Progress: \(totalEntriesParsed)/\(totalExpectedEntries) entries, \(totalYielded) servers yielded")
+
+ // Safety: don't loop forever
+ if batchCount >= 100 {
+ print("HotlineTrackerClient: WARNING - Stopped after 100 batches")
+ break
+ }
+
+ } while totalEntriesParsed < totalExpectedEntries
+
+ print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers")
+ continuation.finish()
}
}
diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift
deleted file mode 100644
index 7f62518..0000000
--- a/Hotline/Hotline/HotlineTransferClient.swift
+++ /dev/null
@@ -1,1289 +0,0 @@
-import Foundation
-import Network
-import UniformTypeIdentifiers
-
-enum HotlineFileClientError: Error {
- case failedToConnect
- case failedToDownload
- case failedToUpload
-}
-
-enum HotlineFileFork {
- case none
- case info
- case data
- case resource
- case unsupported
-}
-
-enum HotlineTransferStatus: Equatable {
- case unconnected
- case connecting
- case connected
- case progress(Double)
- case completing
- case completed
- case failed(HotlineFileClientError)
-}
-
-enum HotlineFileForkType: UInt32 {
- case none = 0
- case unsupported = 1
- case info = 0x494E_464F // 'INFO'
- case data = 0x4441_5441 // 'DATA'
- case resource = 1_296_122_706 // 'MACR'
-}
-
-protocol HotlineTransferDelegate: AnyObject {
- func hotlineTransferStatusChanged(
- client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus,
- timeRemaining: TimeInterval)
-}
-
-protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate {
- func hotlineFileDownloadReceivedInfo(
- client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork)
- func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL)
-}
-
-protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate {
- func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data)
-}
-
-protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate {
- func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32)
-}
-
-enum HotlineFileTransferStage: Int {
- case fileHeader = 1
- case fileForkHeader = 2
- case fileInfoFork = 3
- case fileDataFork = 4
- case fileResourceFork = 5
- case fileUnsupportedFork = 6
-}
-
-enum HotlineFileUploadStage: Int {
- case magic = 1
- case fileHeader = 2
- case fileInfoForkHeader = 3
- case fileInfoFork = 4
- case fileDataForkHeader = 5
- case fileDataFork = 6
- case fileResourceForkHeader = 7
- case fileResourceFork = 8
- case fileComplete = 9
-}
-
-protocol HotlineTransferClient {
- var serverAddress: NWEndpoint.Host { get }
- var serverPort: NWEndpoint.Port { get }
- var referenceNumber: UInt32 { get }
- var status: HotlineTransferStatus { get set }
-
- func start()
- func cancel()
-}
-
-// MARK: -
-
-class HotlineFileUploadClient: HotlineTransferClient {
- let serverAddress: NWEndpoint.Host
- let serverPort: NWEndpoint.Port
- let referenceNumber: UInt32
-
- weak var delegate: HotlineFileUploadClientDelegate? = nil
-
- private var connection: NWConnection?
- private var stage: HotlineFileUploadStage = .magic
- private var payloadSize: UInt32 = 0
- private let fileURL: URL
- private let fileResourceURL: URL?
- private var fileHandle: FileHandle? = nil
- private var bytesSent: Int = 0
- private let infoForkData: Data
- private let dataForkSize: UInt32
- private let resourceForkSize: UInt32
-
- var status: HotlineTransferStatus = .unconnected {
- didSet {
- DispatchQueue.main.async {
- self.delegate?.hotlineTransferStatusChanged(
- client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
- }
- }
- }
-
- init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) {
- guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else {
- return nil
- }
-
- guard let infoFork = HotlineFileInfoFork(file: fileURL) else {
- return nil
- }
-
- guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else {
- return nil
- }
-
- self.serverAddress = NWEndpoint.Host(address)
- self.serverPort = NWEndpoint.Port(rawValue: port + 1)!
- self.referenceNumber = reference
- self.stage = .magic
- self.payloadSize = UInt32(payloadSize)
- self.fileURL = fileURL
- self.infoForkData = infoFork.data()
- self.dataForkSize = forkSizes.dataForkSize
- self.resourceForkSize = forkSizes.resourceForkSize
- if forkSizes.resourceForkSize > 0 {
- self.fileResourceURL = fileURL.urlForResourceFork()
- } else {
- self.fileResourceURL = nil
- }
- }
-
- deinit {
- self.invalidate()
- }
-
- func start() {
- guard self.status == .unconnected else {
- return
- }
-
- let _ = self.fileURL.startAccessingSecurityScopedResource()
- let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource()
-
- self.bytesSent = 0
- self.connect()
- }
-
- func cancel() {
- self.delegate = nil
-
- if self.status == .unconnected {
- return
- }
-
- self.invalidate()
-
- print("HotlineFileUploadClient: Cancelled upload")
- }
-
- private func connect() {
- self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp)
- self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in
- switch newState {
- case .ready:
- self?.status = .connected
- self?.stage = .magic
- self?.send()
- case .waiting(let err):
- print("HotlineFileClient: Waiting", err)
- case .cancelled:
- print("HotlineFileClient: Cancelled")
- self?.invalidate()
- case .failed(let err):
- print("HotlineFileClient: Connection error \(err)")
- switch self?.status {
- case .connecting:
- print("HotlineFileClient: Failed to connect to file transfer server.")
- self?.invalidate()
- self?.status = .failed(.failedToConnect)
- case .connected, .progress(_):
- print("HotlineFileClient: Failed to finish transfer.")
- self?.invalidate()
- self?.status = .failed(.failedToUpload)
- case .completing:
- print("HotlineFileClient: Completed.")
- self?.invalidate()
- self?.status = .completed
- DispatchQueue.main.async { [weak self] in
- if let s = self {
- s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber)
- }
- }
- case .completed:
- self?.invalidate()
- default:
- break
- }
- default:
- return
- }
- }
-
- self.status = .connecting
- self.connection?.start(queue: .global())
- }
-
- private func invalidate() {
- if let c = self.connection {
- c.stateUpdateHandler = nil
- c.cancel()
-
- self.connection = nil
- }
-
- self.stage = .magic
-
- if let fh = self.fileHandle {
- try? fh.close()
- self.fileHandle = nil
- }
-
- self.fileURL.stopAccessingSecurityScopedResource()
- self.fileResourceURL?.stopAccessingSecurityScopedResource()
- }
-
- private func sendComplete() {
- guard let c = self.connection else {
- self.invalidate()
- print("HotlineFileUploadClient: invalid connection to send data.")
- return
- }
-
- self.status = .completing
-
- c.send(
- content: nil, contentContext: .finalMessage,
- completion: .contentProcessed({ error in
- }))
- }
-
- private func sendFileData(_ data: Data) {
- guard let c = self.connection else {
- self.invalidate()
-
- print("HotlineFileUploadClient: invalid connection to send data.")
- return
- }
-
- let dataSent: Int = data.count
- c.send(
- content: data,
- completion: .contentProcessed({ [weak self] error in
- guard let client = self,
- error == nil
- else {
- self?.status = .failed(.failedToConnect)
- self?.invalidate()
- return
- }
-
- client.bytesSent += dataSent
- client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize))
-
- client.send()
- }))
- }
-
- private func send() {
- guard self.connection != nil else {
- self.invalidate()
- print("HotlineFileUploadClient: Invalid connection to send.")
- return
- }
-
- switch self.stage {
- case .magic:
- print("Upload: Starting upload for \(self.fileURL)")
- print("Upload: Sending magic")
- self.status = .progress(0.0)
-
- let magicData = Data(endian: .big) {
- "HTXF".fourCharCode()
- self.referenceNumber
- self.payloadSize
- UInt32.zero
- }
- // var magicData = Data()
- // magicData.appendUInt32("HTXF".fourCharCode())
- // magicData.appendUInt32(self.referenceNumber)
- // magicData.appendUInt32(self.payloadSize)
- // magicData.appendUInt32(0)
- self.stage = .fileHeader
- self.sendFileData(magicData)
-
- case .fileHeader:
- print("Upload: Sending file header")
- if let header = HotlineFileHeader(file: self.fileURL) {
- self.stage = .fileInfoForkHeader
- self.sendFileData(header.data())
- }
-
- case .fileInfoForkHeader:
- print("Upload: Sending info fork header")
- let header = HotlineFileForkHeader(
- type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count))
- self.stage = .fileInfoFork
- self.sendFileData(header.data())
-
- case .fileInfoFork:
- print("Upload: Sending info fork")
- self.stage = .fileDataForkHeader
- self.sendFileData(self.infoForkData)
-
- case .fileDataForkHeader:
- guard self.dataForkSize > 0 else {
- print("Upload: Data fork empty, skipping to resource fork")
- self.stage = .fileResourceForkHeader
- fallthrough
- }
-
- do {
- let fh = try FileHandle(forReadingFrom: self.fileURL)
- self.fileHandle = fh
- self.stage = .fileDataFork
-
- let header = HotlineFileForkHeader(
- type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize)
-
- print("Upload: Sending data fork header \(self.dataForkSize)")
- self.sendFileData(header.data())
- } catch {
- print("Upload: Error opening data fork", error)
- self.invalidate()
- return
- }
-
- case .fileDataFork:
- guard self.dataForkSize > 0,
- let fh = self.fileHandle
- else {
- print("Upload: Data fork empty, skipping to resource fork")
- self.stage = .fileResourceForkHeader
- try? self.fileHandle?.close()
- self.fileHandle = nil
- fallthrough
- }
-
- do {
- let fileData = try fh.read(upToCount: 4 * 1024)
- if fileData == nil || fileData?.isEmpty == true {
- print("Upload: Finished data fork")
- self.stage = .fileResourceForkHeader
- try? fh.close()
- self.fileHandle = nil
- fallthrough
- }
-
- print("Upload: Sending data Fork \(String(describing: fileData?.count))")
- self.sendFileData(fileData!)
- } catch {
- self.invalidate()
- print("Upload: Error reading data fork", error)
- return
- }
-
- case .fileResourceForkHeader:
- guard self.resourceForkSize > 0,
- let resourceURL = self.fileResourceURL
- else {
- print("Upload: Skipping resource fork header")
- self.stage = .fileComplete
- fallthrough
- }
-
- print("Upload: Sending resource fork header")
- guard let fh = try? FileHandle(forReadingFrom: resourceURL) else {
- print("Upload: Error reading resource fork")
- self.invalidate()
- return
- }
-
- let header = HotlineFileForkHeader(
- type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize)
-
- self.fileHandle = fh
- self.stage = .fileResourceFork
- self.sendFileData(header.data())
-
- case .fileResourceFork:
- guard self.resourceForkSize > 0,
- let fh = self.fileHandle
- else {
- print("Upload: Resource fork empty, skipping to completion")
- self.stage = .fileComplete
- try? self.fileHandle?.close()
- self.fileHandle = nil
- fallthrough
- }
-
- do {
- let resourceData = try fh.read(upToCount: 4 * 1024)
- if resourceData == nil || resourceData?.isEmpty == true {
- print("Upload: Finished resource fork")
- self.stage = .fileComplete
- try? self.fileHandle?.close()
- self.fileHandle = nil
- fallthrough
- }
-
- print("Upload: Sending resource fork \(String(describing: resourceData?.count))")
- self.sendFileData(resourceData!)
- } catch {
- self.invalidate()
- print("Upload: Error reading resource fork", error)
- return
- }
- break
-
- case .fileComplete:
- print("Upload: Complete!")
- self.sendComplete()
- }
- }
-}
-
-// MARK: -
-
-class HotlineFilePreviewClient: HotlineTransferClient {
- let serverAddress: NWEndpoint.Host
- let serverPort: NWEndpoint.Port
- let referenceNumber: UInt32
- let referenceDataSize: UInt32
-
- weak var delegate: HotlineFilePreviewClientDelegate? = nil
-
- var status: HotlineTransferStatus = .unconnected {
- didSet {
- DispatchQueue.main.async {
- self.delegate?.hotlineTransferStatusChanged(
- client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
- }
- }
- }
-
- private var connection: NWConnection?
- private var transferStage: HotlineFileTransferStage = .fileHeader
- private var fileBytes = Data()
- private var fileBytesTransferred: Int = 0
-
- init(address: String, port: UInt16, reference: UInt32, size: UInt32) {
- self.serverAddress = NWEndpoint.Host(address)
- self.serverPort = NWEndpoint.Port(rawValue: port + 1)!
- self.referenceNumber = reference
- self.referenceDataSize = size
- }
-
- deinit {
- self.invalidate()
- }
-
- func start() {
- guard self.status == .unconnected else {
- return
- }
-
- self.connect()
- }
-
- func cancel() {
- self.delegate = nil
-
- if self.status == .unconnected {
- return
- }
-
- self.invalidate()
-
- print("HotlineFilePreviewClient: Cancelled preview transfer")
- }
-
- private func invalidate() {
- if let c = self.connection {
- c.stateUpdateHandler = nil
- c.cancel()
-
- self.connection = nil
- }
-
- self.fileBytes = Data()
- }
-
- private func connect() {
- self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp)
- self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in
- switch newState {
- case .ready:
- self?.status = .connected
- self?.sendMagic()
- case .waiting(let err):
- print("HotlineFilePreviewClient: Waiting", err)
- case .cancelled:
- print("HotlineFilePreviewClient: Cancelled")
- self?.invalidate()
- case .failed(let err):
- print("HotlineFilePreviewClient: Connection error \(err)")
- switch self?.status {
- case .connecting:
- print("HotlineFilePreviewClient: Failed to connect to file transfer server.")
- self?.invalidate()
- self?.status = .failed(.failedToConnect)
- case .connected, .progress(_):
- print("HotlineFilePreviewClient: Failed to finish transfer.")
- self?.invalidate()
- self?.status = .failed(.failedToDownload)
- default:
- break
- }
- default:
- return
- }
- }
-
- self.status = .connecting
- self.connection?.start(queue: .global())
- }
-
- private func sendMagic() {
- guard let c = connection, self.status == .connected else {
- self.invalidate()
- print("HotlineFileClient: invalid connection to send header.")
- return
- }
-
- let headerData = Data(endian: .big) {
- "HTXF".fourCharCode()
- self.referenceNumber
- UInt32.zero
- UInt32.zero
- }
-
- c.send(
- content: headerData,
- completion: .contentProcessed { [weak self] (error) in
- guard let self = self else {
- return
- }
-
- guard error == nil else {
- self.status = .failed(.failedToConnect)
- self.invalidate()
- return
- }
-
- self.status = .progress(0.0)
- self.receive()
- })
- }
-
- private func receive() {
- guard let c = self.connection else {
- return
- }
-
- c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) {
- [weak self] (data, context, isComplete, error) in
- guard let self = self else {
- return
- }
-
- guard error == nil else {
- self.status = .failed(.failedToDownload)
- self.invalidate()
- return
- }
-
- if let newData = data, !newData.isEmpty {
- self.fileBytesTransferred += newData.count
- self.fileBytes.append(newData)
- self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize))
- print(
- "HotlineFilePreviewClient: Download progress", self.fileBytesTransferred,
- self.referenceDataSize, isComplete)
- }
-
- if self.fileBytesTransferred < Int(self.referenceDataSize) {
- self.receive()
- } else {
- print("HotlineFilePreviewClient: Complete")
- let data = self.fileBytes
-
- self.status = .completed
- self.invalidate()
-
- let reference = self.referenceNumber
- DispatchQueue.main.sync {
- self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: data)
- }
- }
- }
- }
-}
-
-// MARK: -
-
-class HotlineFileDownloadClient: HotlineTransferClient {
- let serverAddress: NWEndpoint.Host
- let serverPort: NWEndpoint.Port
- let referenceNumber: UInt32
-
- private var connection: NWConnection?
- private var transferStage: HotlineFileTransferStage = .fileHeader
-
- weak var delegate: HotlineFileDownloadClientDelegate? = nil
-
- var status: HotlineTransferStatus = .unconnected {
- didSet {
- DispatchQueue.main.async {
- self.delegate?.hotlineTransferStatusChanged(
- client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
- }
- }
- }
-
- private let referenceDataSize: UInt32
- private var fileBytes = Data()
- private var fileResourceBytes = Data()
-
- private var fileHeader: HotlineFileHeader? = nil
- private var fileCurrentForkHeader: HotlineFileForkHeader? = nil
- private var fileCurrentForkBytesLeft: Int = 0
- private var fileInfoFork: HotlineFileInfoFork? = nil
- private var fileHandle: FileHandle? = nil
- private var filePath: String? = nil
- private var fileBytesTransferred: Int = 0
- private var fileProgress: Progress
-
- init(address: String, port: UInt16, reference: UInt32, size: UInt32) {
- self.serverAddress = NWEndpoint.Host(address)
- self.serverPort = NWEndpoint.Port(rawValue: port + 1)!
- self.referenceNumber = reference
- self.referenceDataSize = size
- self.transferStage = .fileHeader
- self.fileProgress = Progress(totalUnitCount: Int64(size))
- }
-
- deinit {
- self.invalidate()
- }
-
- func start() {
- guard self.status == .unconnected else {
- return
- }
-
- self.filePath = nil
- self.connect()
- }
-
- func start(to fileURL: URL) {
- guard self.status == .unconnected else {
- return
- }
-
- self.filePath = fileURL.path
- self.connect()
- }
-
- func cancel() {
- self.delegate = nil
-
- if self.status == .unconnected {
- return
- }
-
- // Close file before we try to potentionally delete it.
- if let fh = self.fileHandle {
- try? fh.close()
- self.fileHandle = nil
- }
-
- if let downloadPath = self.filePath {
- print("HotlineFileClient: Deleting file fragment at", downloadPath)
- if FileManager.default.isDeletableFile(atPath: downloadPath) {
- try? FileManager.default.removeItem(atPath: downloadPath)
- }
- self.filePath = nil
- }
-
- self.invalidate()
-
- print("HotlineFileClient: Cancelled transfer")
- }
-
- private func connect() {
- self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp)
- self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in
- switch newState {
- case .ready:
- self?.status = .connected
- self?.sendMagic()
- case .waiting(let err):
- print("HotlineFileClient: Waiting", err)
- case .cancelled:
- print("HotlineFileClient: Cancelled")
- self?.invalidate()
- case .failed(let err):
- print("HotlineFileClient: Connection error \(err)")
- switch self?.status {
- case .connecting:
- print("HotlineFileClient: Failed to connect to file transfer server.")
- self?.invalidate()
- self?.status = .failed(.failedToConnect)
- case .connected, .progress(_):
- print("HotlineFileClient: Failed to finish transfer.")
- self?.invalidate()
- self?.status = .failed(.failedToDownload)
- default:
- break
- }
- default:
- return
- }
- }
-
- self.status = .connecting
- self.connection?.start(queue: .global())
- }
-
- func invalidate() {
- if let c = self.connection {
- c.stateUpdateHandler = nil
- c.cancel()
-
- self.connection = nil
- }
-
- self.fileBytes = Data()
-
- if let fh = self.fileHandle {
- try? fh.close()
- self.fileHandle = nil
- }
-
- self.fileProgress.unpublish()
- }
-
- private func sendMagic() {
- guard let c = connection, self.status == .connected else {
- self.invalidate()
- print("HotlineFileClient: invalid connection to send header.")
- return
- }
-
- let headerData = Data(endian: .big) {
- "HTXF".fourCharCode()
- self.referenceNumber
- UInt32.zero
- UInt32.zero
- }
-
- c.send(
- content: headerData,
- completion: .contentProcessed { [weak self] (error) in
- guard let self = self else {
- return
- }
-
- guard error == nil else {
- self.status = .failed(.failedToConnect)
- self.invalidate()
- return
- }
-
- self.status = .progress(0.0)
- self.receiveFile()
- })
- }
-
- private func receiveFile() {
- guard let c = self.connection else {
- return
- }
-
- c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) {
- [weak self] (data, context, isComplete, error) in
- guard let self = self else {
- return
- }
-
- guard error == nil else {
- self.status = .failed(.failedToDownload)
- self.invalidate()
- return
- }
-
- if let newData = data, !newData.isEmpty {
- self.fileBytesTransferred += newData.count
- self.fileBytes.append(newData)
- self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred)
- self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize))
- }
-
- // See if we need header data still.
- var keepProcessing = false
- repeat {
- keepProcessing = false
-
- switch self.transferStage {
- case .fileHeader:
- if let header = HotlineFileHeader(from: self.fileBytes) {
- self.fileBytes.removeSubrange(0..<HotlineFileHeader.DataSize)
- self.fileHeader = header
- self.transferStage = .fileForkHeader
- keepProcessing = true
- }
- case .fileForkHeader:
- if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) {
- // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes)
- self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize)
- self.fileCurrentForkHeader = forkHeader
- self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize)
-
- if forkHeader.isInfoFork {
- print("HotlineFileClient: Downloading info fork")
- self.transferStage = .fileInfoFork
- } else if forkHeader.isDataFork {
- print("HotlineFileClient: Downloading data fork")
- self.transferStage = .fileDataFork
- } else if forkHeader.isResourceFork {
- print("HotlineFileClient: Downloading resource fork")
- self.fileResourceBytes = Data()
- self.transferStage = .fileResourceFork
- } else {
- print("HotlineFileClient: Downloading unsupported fork")
- self.transferStage = .fileUnsupportedFork
- }
-
- keepProcessing = true
- }
- case .fileInfoFork:
- if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize,
- self.fileBytes.count >= infoForkDataSize
- {
- let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize))
- if let infoFork = HotlineFileInfoFork(from: infoForkData) {
- self.fileInfoFork = infoFork
-
- self.fileBytes.removeSubrange(0..<infoFork.headerSize)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft -= Int(infoForkDataSize)
- self.fileCurrentForkHeader = nil
-
- print("INFO FORK STUFF:", infoFork)
-
- // Create file in Downloads folder if we don't have a destination already.
- if !self.prepareDownloadFile(name: infoFork.name) {
- print("FAILED TO CREATE FILE ON DISK")
- }
-
- let reference = self.referenceNumber
- DispatchQueue.main.async {
- self.delegate?.hotlineFileDownloadReceivedInfo(
- client: self, reference: reference, info: infoFork)
- }
- }
-
- keepProcessing = true
- }
- case .fileDataFork:
- if self.fileBytes.count > 0 {
- if let f = self.fileHandle {
- do {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- keepProcessing = true
- } else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.fileBytes = Data()
- }
-
- try f.write(contentsOf: dataToWrite)
- } catch {
- print("DOWNLOAD WRITE ERROR", error)
- }
- }
- }
- case .fileResourceFork:
- if self.fileBytes.count > 0 {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- keepProcessing = true
- } else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.fileBytes = Data()
- }
-
- print("WRITING TO RESOURCE FORK", dataToWrite.count)
-
- self.fileResourceBytes.append(dataToWrite)
- }
- case .fileUnsupportedFork:
- if self.fileBytes.count > 0 {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- keepProcessing = true
- } else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.fileBytes = Data()
- }
- }
- }
- } while keepProcessing
-
- if self.fileBytesTransferred < Int(self.referenceDataSize) {
- // If we still have more to download, then receive more data.
- self.receiveFile()
- return
- } else {
- self.invalidate()
-
- if self.fileResourceBytes.count > 0 {
- let _ = self.writeResourceFork()
- self.fileResourceBytes = Data()
- }
-
- self.status = .completed
-
- if let downloadPath = self.filePath {
- DispatchQueue.main.sync {
- print("POSTING DOWNLOAD FILE FINISHED", downloadPath)
-
- var downloadURL = URL(filePath: downloadPath)
- downloadURL.resolveSymlinksInPath()
- print("FINAL PATH", downloadURL.path)
-
- self.delegate?.hotlineFileDownloadComplete(
- client: self, reference: self.referenceNumber, at: downloadURL)
-
- #if os(macOS)
- // Bounce dock icon when download completes. Weird this is the only API to do so.
- DistributedNotificationCenter.default().post(
- name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
- #endif
- }
- }
- }
- }
- }
-
- private func writeResourceFork() -> Bool {
- guard let filePath = self.filePath else {
- return false
- }
-
- var resolvedFileURL = URL(filePath: filePath)
- resolvedFileURL.resolveSymlinksInPath()
-
- let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc")
-
- do {
- try self.fileResourceBytes.write(to: resourceFilePath)
- } catch {
- return false
- }
-
- return true
- }
-
- private func prepareDownloadFile(name: String) -> Bool {
- var filePath: String
-
- if self.filePath != nil {
- filePath = self.filePath!
- } else {
- let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
- filePath = folderURL.generateUniqueFilePath(filename: name)
- }
-
- var fileAttributes: [FileAttributeKey: Any] = [:]
- if let creatorCode = self.fileInfoFork?.creator {
- fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber
- }
- if let typeCode = self.fileInfoFork?.type {
- fileAttributes[.hfsTypeCode] = typeCode as NSNumber
- }
- if let createdDate = self.fileInfoFork?.createdDate {
- fileAttributes[.creationDate] = createdDate as NSDate
- }
- if let modifiedDate = self.fileInfoFork?.modifiedDate {
- fileAttributes[.modificationDate] = modifiedDate as NSDate
- }
- if let comment = self.fileInfoFork?.comment {
- if let commentPlistData = try? PropertyListSerialization.data(
- fromPropertyList: comment, format: .binary, options: 0)
- {
- fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [
- FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData
- ]
- }
- }
-
- if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) {
- if let h = FileHandle(forWritingAtPath: filePath) {
- self.filePath = filePath
- self.fileHandle = h
- self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath()
- self.fileProgress.fileOperationKind = .downloading
- self.fileProgress.publish()
- return true
- }
- }
-
- return false
- }
-}
-
-// MARK: -
-
-struct HotlineFileHeader {
- static let DataSize: Int = 4 + 2 + 16 + 2
-
- let format: UInt32
- let version: UInt16
- let forkCount: UInt16
-
- init?(from data: Data) {
- guard data.count >= HotlineFileHeader.DataSize else {
- return nil
- }
-
- self.format = data.readUInt32(at: 0)!
- self.version = data.readUInt16(at: 4)!
- // 16 bytes of reserved data sits here. Skip it.
- self.forkCount = data.readUInt16(at: 4 + 2 + 16)!
- }
-
- init?(file fileURL: URL) {
- guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
- return nil
- }
-
- self.format = "FILP".fourCharCode()
- self.version = 1
-
- let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc")
- if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) {
- self.forkCount = 3
- } else {
- self.forkCount = 2
- }
- }
-
- func data() -> Data {
- Data(endian: .big) {
- self.format
- self.version
- Data(repeating: 0, count: 16)
- self.forkCount
- }
- }
-}
-
-// MARK: -
-
-struct HotlineFileForkHeader {
- static let DataSize: Int = 4 + 4 + 4 + 4
-
- let forkType: UInt32
- let compressionType: UInt32
- let dataSize: UInt32
-
- init(type: UInt32, dataSize: UInt32) {
- self.forkType = type
- self.compressionType = 0
- self.dataSize = dataSize
- }
-
- init?(from data: Data) {
- guard data.count >= HotlineFileForkHeader.DataSize else {
- return nil
- }
-
- self.forkType = data.readUInt32(at: 0)!
- self.compressionType = data.readUInt32(at: 4)!
- // 4 bytes of reserved data sits here. Skip it.
- // self.reserved = data.readUInt32(at: 4 + 4)!
- self.dataSize = data.readUInt32(at: 4 + 4 + 4)!
- }
-
- func data() -> Data {
- Data(endian: .big) {
- self.forkType
- self.compressionType
- UInt32.zero
- self.dataSize
- }
- }
-
- var isInfoFork: Bool {
- return self.forkType == HotlineFileForkType.info.rawValue
- }
-
- var isDataFork: Bool {
- return self.forkType == HotlineFileForkType.data.rawValue
- }
-
- var isResourceFork: Bool {
- return self.forkType == HotlineFileForkType.resource.rawValue
- }
-}
-
-// MARK: -
-
-struct HotlineFileInfoFork {
- static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2
-
- let platform: UInt32
- let type: UInt32
- let creator: UInt32
- let flags: UInt32
- let platformFlags: UInt32
- let createdDate: Date
- let modifiedDate: Date
- let nameScript: UInt16
- let name: String
- let comment: String?
- var headerSize: Int
-
- init?(file fileURL: URL) {
- guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
- return nil
- }
-
- self.platform = "AMAC".fourCharCode()
-
- if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) {
- self.type = hfsInfo.hfsType
- self.creator = hfsInfo.hfsCreator
- } else {
- self.type = 0
- self.creator = 0
- }
-
- self.flags = 0
- self.platformFlags = 0
-
- let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL)
- self.createdDate = dateInfo.createdDate
- self.modifiedDate = dateInfo.modifiedDate
-
- self.nameScript = 0
- self.name = fileURL.lastPathComponent
-
- let fileComment = try? FileManager.default.getFinderComment(fileURL)
- self.comment = fileComment ?? ""
-
- self.headerSize = 0
- }
-
- init?(from data: Data) {
- // Make sure we have at least enough data to read basic header data
- guard data.count >= HotlineFileInfoFork.BaseDataSize else {
- return nil
- }
-
- if let platform = data.readUInt32(at: 0),
- let type = data.readUInt32(at: 4),
- let creator = data.readUInt32(at: 4 + 4),
- let flags = data.readUInt32(at: 4 + 4 + 4),
- let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4),
- // 32 bytes of reserved data sits here. Skip it.
- let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8)
- {
-
- let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now
- let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now
-
- let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2)
- if let name = n {
- self.platform = platform
- self.type = type
- self.creator = creator
- self.flags = flags
- self.platformFlags = platformFlags
- self.createdDate = createdDate
- self.modifiedDate = modifiedDate
- self.nameScript = nameScript
- self.name = name
-
- var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl
- var commentRead: String? = nil
- if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 {
- let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)!
- var commentCorrupted = false
-
- // Some servers have incorrect data length for the INFO fork
- // the length they send is what it should be but don't include
- // the comment length in the actual data, so we end up with mismatched
- // lengths. So here we test if the length we read is actually 'DA'
- // or the first part of the "DATA" fork header.
- // Needless to say, stuff like this makes for sad code but this ain't so bad.
- if commentLength == 0x4441 {
- commentCorrupted = true
- }
-
- if !commentCorrupted {
- let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl)
- calculatedHeaderSize += 2
- if cl > 0 {
- calculatedHeaderSize += Int(cl)
- if let ct = c, cl > 0 {
- commentRead = ct
- }
- }
- }
- }
-
- self.comment = commentRead
- self.headerSize = calculatedHeaderSize
- return
- }
- }
-
- return nil
- }
-
- func data() -> Data {
- let fileName = self.name.data(using: .macOSRoman)!
-
- let data = Data(endian: .big) {
- self.platform
- self.type
- self.creator
- self.flags
- self.platformFlags
- Data(repeating: 0, count: 32)
- self.createdDate
- self.modifiedDate
- self.nameScript
- UInt16(fileName.count)
- fileName
- if let commentData = self.comment?.data(using: .macOSRoman) {
- UInt16(commentData.count)
- commentData
- }
- }
-
- return data
- }
-}
diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift
new file mode 100644
index 0000000..6441047
--- /dev/null
+++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift
@@ -0,0 +1,267 @@
+import Foundation
+import Network
+
+public enum HotlineDownloadLocation: Sendable {
+ case url(URL)
+ case downloads(String) // filename
+}
+
+public enum HotlineTransferProgress: Sendable {
+ case error(Error) // An error occurred
+ case unconnected // Initial state
+ case preparing // Preparing to begin
+ case connecting // Connecting to server
+ case connected // Connected to server
+ case transfer(name: String, size: Int, total: Int, progress: Double, speed: Double?, estimate: TimeInterval?) // size transferred, total size, progress (0.0-1.0), speed (in bytes/sec), time remaining
+ case completed(url: URL?) // Download or upload complete (local url valid for downloads)
+}
+
+
+@MainActor
+public class HotlineFileDownloadClient: @MainActor HotlineTransferClient {
+ public struct Configuration: Sendable {
+ public var chunkSize: Int = 256 * 1024
+ public init() {}
+ }
+
+ private let serverAddress: String
+ private let serverPort: UInt16
+ private let referenceNumber: UInt32
+
+ private let config: Configuration
+
+ private var transferSize: Int
+ private let transferTotal: Int
+ private var transferProgress: Progress
+
+ private var socket: NetSocket?
+ private var downloadTask: Task<URL, Error>?
+
+ public init(
+ address: String,
+ port: UInt16,
+ reference: UInt32,
+ size: UInt32,
+ configuration: Configuration = .init()
+ ) {
+ self.serverAddress = address
+ self.serverPort = port
+ self.referenceNumber = reference
+ self.config = configuration
+
+ self.transferTotal = Int(size)
+ self.transferSize = 0
+ self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal))
+ }
+
+ // MARK: - API
+
+ public func download(
+ to location: HotlineDownloadLocation,
+ progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil
+ ) async throws -> URL {
+ self.downloadTask?.cancel()
+
+ let task = Task<URL, Error> {
+ try await performDownload(to: location, progressHandler: progressHandler)
+ }
+ self.downloadTask = task
+
+ do {
+ let url = try await task.value
+ self.downloadTask = nil
+ return url
+ }
+ catch {
+ self.downloadTask = nil
+ try? progressHandler?(.error(error))
+ throw error
+ }
+ }
+
+ /// Cancel the current download
+ public func cancel() {
+ self.downloadTask?.cancel()
+ self.downloadTask = nil
+ }
+
+ // MARK: - Implementation
+
+ private func updateProgress(sent: Int) throws {
+ self.transferSize = sent
+ self.transferProgress.completedUnitCount = Int64(sent)
+ try self.checkCancelled()
+ }
+
+ private func checkCancelled() throws {
+ if Task.isCancelled {
+ throw CancellationError()
+ }
+
+ // People can cancel a transfer from the file icon in the Finder.
+ // This code handles that.
+ if self.transferProgress.isCancelled {
+ throw CancellationError()
+ }
+ }
+
+ private func performDownload(
+ to destination: HotlineDownloadLocation,
+ progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)?
+ ) async throws -> URL {
+
+ let fm = FileManager.default
+ var fileHandle: FileHandle?
+ var resourceForkData: Data?
+
+ try progressHandler?(.preparing)
+
+ // Determine the download name
+ // Determine destination URL based on location
+ let destinationURL: URL
+ let destinationFilename: String
+ switch destination {
+ case .url(let url):
+ destinationURL = url.resolvingSymlinksInPath()
+ destinationFilename = destinationURL.lastPathComponent
+ case .downloads(let filename):
+ destinationURL = URL.downloadsDirectory.resolvingSymlinksInPath().generateUniqueFileURL(filename: filename)
+ destinationFilename = destinationURL.lastPathComponent
+ }
+
+ try self.checkCancelled()
+ try progressHandler?(.connecting)
+
+ // Connect to transfer server
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
+ defer { Task { await socket.close() } }
+ self.socket = socket
+
+ // See if we've been cancelled
+ try self.checkCancelled()
+
+ // Send magic header
+ try await socket.write(Data(endian: .big) {
+ "HTXF".fourCharCode()
+ self.referenceNumber
+ UInt32.zero
+ UInt32.zero
+ })
+
+ // Read file header
+ let headerData = try await socket.read(HotlineFileHeader.DataSize)
+ guard let header = HotlineFileHeader(from: headerData) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Connected
+ try progressHandler?(.connected)
+
+ do {
+ // Process each fork
+ for _ in 0..<Int(header.forkCount) {
+ // Read fork header
+ let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize)
+ guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Handle whichever fork is being sent.
+ if forkHeader.isInfoFork {
+ // Read info fork
+ let infoData = try await socket.read(Int(forkHeader.dataSize))
+ guard let info = HotlineFileInfoFork(from: infoData) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+ self.transferSize += infoData.count
+
+ // Prepare temporary file for atomic write
+ try? fm.removeItem(at: destinationURL)
+
+ // Create file with metadata
+ fileHandle = try fm.createHotlineFile(at: destinationURL, infoFork: info)
+
+ // Create and configure progress
+ self.transferProgress.fileURL = destinationURL
+ self.transferProgress.fileOperationKind = .downloading
+ self.transferProgress.publish()
+
+ // Update progress
+ try self.updateProgress(sent: infoData.count)
+ }
+ else if forkHeader.isDataFork {
+ guard let fh = fileHandle else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Stream data fork to disk
+ let updates = await socket.receiveFile(to: fh, length: Int(forkHeader.dataSize))
+ for try await p in updates {
+ try self.updateProgress(sent: p.sent)
+ try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining))
+ }
+ }
+ else if forkHeader.isResourceFork {
+ // Read resource fork into memory
+ let rsrcData = try await socket.read(Int(forkHeader.dataSize))
+ resourceForkData = rsrcData
+ try self.updateProgress(sent: Int(rsrcData.count))
+
+ } else {
+ // Skip unsupported fork
+ let dataSize = Int(forkHeader.dataSize)
+ try await socket.skip(dataSize)
+
+ try self.updateProgress(sent: dataSize)
+ }
+
+ try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil))
+ }
+
+ self.transferProgress.unpublish()
+
+ // Close file handle
+ try fileHandle?.close()
+ fileHandle = nil
+
+ // Write resource fork if present
+ if let rsrcData = resourceForkData, !rsrcData.isEmpty {
+ try writeResourceFork(data: rsrcData, to: destinationURL)
+ }
+
+ // See if we've been cancelled
+ try self.checkCancelled()
+
+ try progressHandler?(.completed(url: destinationURL))
+
+ return destinationURL
+
+ }
+ catch {
+ // Cleanup on failure
+ try? fileHandle?.close()
+ try? fm.removeItem(at: destinationURL)
+ self.transferProgress.unpublish()
+
+ try? progressHandler?(.error(error))
+
+ throw error
+ }
+ }
+
+ // MARK: - Utility
+
+ private func writeResourceFork(data: Data, to url: URL) throws {
+ var resolvedURL = url
+ resolvedURL.resolveSymlinksInPath()
+
+ let resourceURL = resolvedURL.urlForResourceFork()
+ try data.write(to: resourceURL)
+
+ print("HotlineFileDownloadClient[\(self.referenceNumber)]: Wrote resource fork (\(data.count) bytes)")
+ }
+}
+
diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift
new file mode 100644
index 0000000..8f3088d
--- /dev/null
+++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift
@@ -0,0 +1,162 @@
+import Foundation
+import Network
+
+@MainActor
+public class HotlineFilePreviewClient {
+ private let serverAddress: String
+ private let serverPort: UInt16
+ private let referenceNumber: UInt32
+ private let fileName: String
+ private let transferSize: UInt32
+ private let fileType: String?
+ private let fileCreator: String?
+
+ private var downloadClient: HotlineFileDownloadClient?
+ private var previewTask: Task<URL, Error>?
+ private var temporaryFileURL: URL?
+
+ public init(
+ fileName: String,
+ address: String,
+ port: UInt16,
+ reference: UInt32,
+ size: UInt32,
+ fileType: String? = nil,
+ fileCreator: String? = nil
+ ) {
+ self.fileName = fileName
+ self.serverAddress = address
+ self.serverPort = port
+ self.referenceNumber = reference
+ self.transferSize = size
+ self.fileType = fileType
+ self.fileCreator = fileCreator
+ }
+
+ // MARK: - API
+
+ /// Download file to temporary location for preview
+ /// - Parameter progressHandler: Optional progress callback
+ /// - Returns: URL to temporary file for preview
+ public func preview(
+ progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil
+ ) async throws -> URL {
+ self.previewTask?.cancel()
+
+ let task = Task<URL, Error> {
+ try await performPreview(progressHandler: progressHandler)
+ }
+ self.previewTask = task
+
+ do {
+ let url = try await task.value
+ self.previewTask = nil
+ return url
+ } catch {
+ print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)")
+ self.previewTask = nil
+ progressHandler?(.error(error))
+ throw error
+ }
+ }
+
+ /// Cancel the current preview download
+ public func cancel() {
+ self.previewTask?.cancel()
+ self.previewTask = nil
+ self.downloadClient?.cancel()
+ }
+
+ /// Manually cleanup temporary file
+ /// Call this when preview is complete and you no longer need the file
+ public func cleanup() {
+ self.cleanupTempFile()
+ }
+
+ // MARK: - Implementation
+
+ private func performPreview(
+ progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?
+ ) async throws -> URL {
+
+ // Create temporary file path directly in system temp directory
+ let tempDir = FileManager.default.temporaryDirectory
+ let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)"
+ let tempFileURL = tempDir.appendingPathComponent(uniqueFileName)
+ self.temporaryFileURL = tempFileURL
+
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)")
+
+ progressHandler?(.connecting)
+
+ // Connect to transfer server
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)")
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
+ defer { Task { await socket.close() } }
+
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!")
+
+ // Send magic header for raw data download
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header")
+ try await socket.write(Data(endian: .big) {
+ "HTXF".fourCharCode()
+ self.referenceNumber
+ UInt32.zero
+ UInt32.zero
+ })
+
+ progressHandler?(.connected)
+
+ // Stream raw data directly to temp file with progress tracking
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(self.transferSize) bytes to temp file")
+
+ let totalSize = Int(self.transferSize)
+
+ // Create empty file (with HFS attributes if available)
+ var attributes: [FileAttributeKey: Any] = [:]
+ if let creator = self.fileCreator, !creator.isBlank {
+ attributes[.hfsCreatorCode] = creator.fourCharCode() as NSNumber
+ }
+ if let type = self.fileType, !type.isBlank {
+ attributes[.hfsTypeCode] = type.fourCharCode() as NSNumber
+ }
+
+ guard FileManager.default.createFile(atPath: tempFileURL.path, contents: nil, attributes: attributes) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ let fileHandle = try FileHandle(forWritingTo: tempFileURL)
+ defer { try? fileHandle.close() }
+
+ let updates = await socket.receiveFile(to: fileHandle, length: totalSize)
+ for try await p in updates {
+ progressHandler?(.transfer(
+ name: uniqueFileName,
+ size: p.sent,
+ total: totalSize,
+ progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0,
+ speed: p.bytesPerSecond,
+ estimate: p.estimatedTimeRemaining
+ ))
+ }
+
+ progressHandler?(.completed(url: tempFileURL))
+
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)")
+
+ return tempFileURL
+ }
+
+ private func cleanupTempFile() {
+ guard let tempURL = self.temporaryFileURL else { return }
+ self.temporaryFileURL = nil
+
+ // Delete the temp file
+ try? FileManager.default.removeItem(at: tempURL)
+
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file")
+ }
+}
diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift
new file mode 100644
index 0000000..937db6a
--- /dev/null
+++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift
@@ -0,0 +1,227 @@
+import Foundation
+import Network
+
+@MainActor
+public class HotlineFileUploadClient: @MainActor HotlineTransferClient {
+ public struct Configuration: Sendable {
+ public var chunkSize: Int = 256 * 1024
+ public init() {}
+ }
+
+ private let serverAddress: String
+ private let serverPort: UInt16
+ private let referenceNumber: UInt32
+ private let fileURL: URL
+
+ private let config: Configuration
+
+ private var transferSize: Int
+ private let transferTotal: Int
+ private var transferProgress: Progress
+
+ private var socket: NetSocket?
+ private var uploadTask: Task<Void, Error>?
+
+ public init?(
+ fileURL: URL,
+ address: String,
+ port: UInt16,
+ reference: UInt32,
+ configuration: Configuration = .init()
+ ) {
+ // Validate file and get total size
+ guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else {
+ return nil
+ }
+
+ guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
+ return nil
+ }
+
+ self.serverAddress = address
+ self.serverPort = port
+ self.referenceNumber = reference
+ self.fileURL = fileURL
+ self.config = configuration
+
+ self.transferTotal = Int(payloadSize)
+ self.transferSize = 0
+ self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal))
+ }
+
+ // MARK: - Public API
+
+ public func upload(
+ progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil
+ ) async throws {
+ self.uploadTask?.cancel()
+
+ let task = Task<Void, Error> {
+ try await performUpload(progressHandler: progressHandler)
+ }
+ self.uploadTask = task
+
+ do {
+ try await task.value
+ self.uploadTask = nil
+ } catch {
+ print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)")
+ self.uploadTask = nil
+ progressHandler?(.error(error))
+ throw error
+ }
+ }
+
+ /// Cancel the current upload
+ public func cancel() {
+ self.uploadTask?.cancel()
+ self.uploadTask = nil
+
+ if let socket = self.socket {
+ Task {
+ await socket.close()
+ }
+ }
+ }
+
+ // MARK: - Implementation
+
+ private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) {
+ self.transferSize = sent
+ self.transferProgress.completedUnitCount = Int64(sent)
+
+ if self.transferProgress.isCancelled {
+ self.cancel()
+ }
+ }
+
+ private func performUpload(
+ progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?
+ ) async throws {
+ let filename = self.fileURL.lastPathComponent
+
+ progressHandler?(.connecting)
+
+ // Start accessing security-scoped resource
+ let didStartAccess = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccess {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ // Connect to transfer server
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
+ defer { Task { await socket.close() } }
+ self.socket = socket
+
+ // Get file metadata
+ guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ guard let header = HotlineFileHeader(file: self.fileURL) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ guard let infoForkData = infoFork.data() else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+ let dataForkSize = forkSizes.dataForkSize
+ let resourceForkSize = forkSizes.resourceForkSize
+
+ // Configure progress for Finder if enabled
+ self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath()
+ self.transferProgress.fileOperationKind = .uploading
+ self.transferProgress.publish()
+
+ // Connected
+ progressHandler?(.connected)
+
+ // Send magic header
+ try await socket.write(Data(endian: .big) {
+ "HTXF".fourCharCode()
+ self.referenceNumber
+ UInt32(self.transferTotal)
+ UInt32.zero
+ })
+
+ var totalBytesSent = 0
+
+ // MARK: - Info Fork
+ // Send file header
+ let headerData = header.data()
+ try await socket.write(headerData)
+ totalBytesSent += headerData.count
+
+ // Send info fork header
+ let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count))
+ try await socket.write(infoForkHeader.data())
+ totalBytesSent += HotlineFileForkHeader.DataSize
+
+ // Send info fork
+ try await socket.write(infoForkData)
+ totalBytesSent += infoForkData.count
+
+ self.updateProgress(sent: totalBytesSent)
+ progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil))
+
+ // MARK: - Data Fork
+ // Send data fork (if present)
+ if dataForkSize > 0 {
+ // Data fork header
+ let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize)
+ try await socket.write(dataForkHeader.data())
+ totalBytesSent += HotlineFileForkHeader.DataSize
+
+ // Stream data fork from disk
+ let fileHandle = try FileHandle(forReadingFrom: self.fileURL)
+ defer { try? fileHandle.close() }
+
+ let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize))
+ for try await p in updates {
+ let bytesSentNow = totalBytesSent + p.sent
+ self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)
+ progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining))
+ }
+
+ totalBytesSent += Int(dataForkSize)
+ }
+
+ // MARK: - Resource Fork
+ // Send resource fork (if present)
+ if resourceForkSize > 0 {
+ let resourceURL = self.fileURL.urlForResourceFork()
+
+ // Resource fork header
+ let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize)
+ try await socket.write(resourceForkHeader.data())
+ totalBytesSent += HotlineFileForkHeader.DataSize
+
+ // Stream resource fork from disk
+ let resourceHandle = try FileHandle(forReadingFrom: resourceURL)
+ defer { try? resourceHandle.close() }
+
+ let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize))
+ for try await p in updates {
+ let bytesSentNow = totalBytesSent + p.sent
+ self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)
+ progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining))
+ }
+
+ totalBytesSent += Int(resourceForkSize)
+ }
+
+ self.transferProgress.unpublish()
+ progressHandler?(.completed(url: nil))
+
+ print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!")
+ }
+}
diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift
new file mode 100644
index 0000000..a915043
--- /dev/null
+++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift
@@ -0,0 +1,410 @@
+import Foundation
+import Network
+
+/// Item progress callback for folder downloads
+public struct HotlineFolderItemProgress: Sendable {
+ public let fileName: String
+ public let itemNumber: Int
+ public let totalItems: Int
+}
+
+@MainActor
+public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient {
+ private let serverAddress: String
+ private let serverPort: UInt16
+ private let referenceNumber: UInt32
+
+ private let transferTotal: Int // Total byte size of all files in folder.
+ private let folderItemCount: Int // Total numbner of items in the folder hierarchy.
+
+ private var socket: NetSocket?
+ private var downloadTask: Task<URL, Error>?
+ private var folderProgress: Progress?
+
+ private var estimator: TransferRateEstimator
+
+ public init(
+ address: String,
+ port: UInt16,
+ reference: UInt32,
+ size: UInt32,
+ itemCount: Int
+ ) {
+ self.serverAddress = address
+ self.serverPort = port
+ self.referenceNumber = reference
+ self.transferTotal = Int(size)
+ self.folderItemCount = itemCount
+
+ self.estimator = TransferRateEstimator(total: self.transferTotal)
+ }
+
+ // MARK: - API
+
+ public func download(
+ to location: HotlineDownloadLocation,
+ progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil,
+ items itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil
+ ) async throws -> URL {
+ progressHandler?(.preparing)
+
+ self.downloadTask?.cancel()
+
+ let task = Task<URL, Error> {
+ try await performDownload(
+ to: location,
+ progressHandler: progressHandler,
+ itemProgressHandler: itemProgressHandler
+ )
+ }
+ self.downloadTask = task
+
+ do {
+ let url = try await task.value
+ self.downloadTask = nil
+ return url
+ } catch {
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Failed to download folder: \(error)")
+ self.downloadTask = nil
+ progressHandler?(.error(error))
+ throw error
+ }
+ }
+
+ /// Cancel the current download
+ public func cancel() {
+ self.downloadTask?.cancel()
+ self.downloadTask = nil
+
+ if let socket = self.socket {
+ Task {
+ await socket.close()
+ }
+ }
+ }
+
+ // MARK: - Implementation
+
+ private func performDownload(
+ to destination: HotlineDownloadLocation,
+ progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?,
+ itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)?
+ ) async throws -> URL {
+
+ var destinationFilename: String
+
+ progressHandler?(.connecting)
+
+ // Connect to transfer server
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
+ self.socket = socket
+ defer { Task { await socket.close() } }
+
+ // Determine destination folder URL
+ let fm = FileManager.default
+ let destinationURL: URL
+
+ switch destination {
+ case .url(let url):
+ destinationURL = url
+ destinationFilename = url.lastPathComponent
+ case .downloads(let filename):
+ destinationURL = URL.downloadsDirectory.generateUniqueFileURL(filename: filename)
+ destinationFilename = destinationURL.lastPathComponent
+ }
+
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Downloading folder to \(destinationURL.path)")
+
+ // Create destination folder
+ try? fm.removeItem(at: destinationURL)
+ try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true)
+
+ // Create and publish progress for the entire folder (shows in Finder)
+ let progress = Progress(totalUnitCount: Int64(self.transferTotal))
+ progress.fileURL = destinationURL
+ progress.fileOperationKind = .downloading
+ progress.publish()
+ defer { progress.unpublish() }
+ self.folderProgress = progress
+
+ // Send initial magic header
+ try await socket.write(Data(endian: .big) {
+ "HTXF".fourCharCode()
+ self.referenceNumber
+ UInt32.zero // data size = 0
+ UInt16(1) // type = 1 (folder transfer)
+ UInt16.zero // reserved = 0
+ HotlineFolderAction.nextFile.rawValue // action = 3 (next file)
+ })
+
+ progressHandler?(.connected)
+ progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil))
+
+ var completedItemCount = 0
+
+ // Process each item in the folder
+ while completedItemCount < self.folderItemCount {
+ // Read item header
+ let headerLenData = try await socket.read(2)
+ let headerLen = Int(headerLenData.readUInt16(at: 0)!)
+ let headerData = try await socket.read(headerLen)
+
+ self.updateProgress(2 + headerLen)
+
+ guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ if itemType == 1 {
+ // Folder entry - no progress shown for folder creation
+ if !pathComponents.isEmpty {
+ let folderURL = destinationURL.appendingPathComponents(pathComponents)
+ try fm.createDirectory(at: folderURL, withIntermediateDirectories: true)
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Created folder at \(folderURL.path)")
+ }
+
+ completedItemCount += 1
+
+ // Request next item if not done
+ if completedItemCount < self.folderItemCount {
+ try await sendAction(socket: socket, action: .nextFile) // nextFile
+ }
+
+ } else if itemType == 0 {
+ // File entry
+ let parentComponents = pathComponents.dropLast()
+ let fileName = pathComponents.last ?? "Untitled"
+
+ // Request file download
+ try await sendAction(socket: socket, action: .sendFile) // sendFile
+
+ // Read file size
+ let fileSizeData = try await socket.read(4)
+ let fileSize = fileSizeData.readUInt32(at: 0)!
+
+ self.updateProgress(4)
+
+ // Notify item progress before download starts
+ completedItemCount += 1
+ itemProgressHandler?(HotlineFolderItemProgress(
+ fileName: fileName,
+ itemNumber: completedItemCount,
+ totalItems: self.folderItemCount
+ ))
+
+ // Download the file with overall folder progress tracking
+ try await self.downloadFile(
+ socket: socket,
+ fileName: fileName,
+ parentPath: Array(parentComponents),
+ destinationFolder: destinationURL,
+ fileSize: fileSize,
+ progressHandler: progressHandler
+ )
+
+ // Request next item if not done
+ if completedItemCount < self.folderItemCount {
+ try await sendAction(socket: socket, action: .nextFile) // nextFile
+ }
+
+ } else {
+ // Unknown item type
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Unknown item type \(itemType), skipping")
+ completedItemCount += 1
+
+ if completedItemCount < self.folderItemCount {
+ try await sendAction(socket: socket, action: .nextFile) // nextFile
+ }
+ }
+ }
+
+ print("HotlineFolderDownloadClient[\(referenceNumber)]: Download complete!")
+
+ // Ensure folder progress shows 100% complete
+ self.folderProgress?.completedUnitCount = Int64(self.transferTotal)
+ progressHandler?(.completed(url: destinationURL))
+
+ return destinationURL
+ }
+
+ // MARK: -
+
+ private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws {
+ let actionData = Data(endian: .big) {
+ action.rawValue
+ }
+ try await socket.write(actionData)
+ print("HotlineFolderDownloadClient[\(referenceNumber)]: Sent action: \(action)")
+ }
+
+ private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? {
+ // Need at least: type(2) + count(2)
+ guard headerData.count >= 4,
+ let type = headerData.readUInt16(at: 0),
+ let count = headerData.readUInt16(at: 2) else { return nil }
+
+ var ofs = 4
+ var comps: [String] = []
+ for _ in 0..<Int(count) {
+ guard headerData.count >= ofs + 3 else { return nil }
+ // per Hotline path encoding: reserved(2) then nameLen(1) then name
+ ofs += 2 // reserved == 0
+ let nameLen = Int(headerData.readUInt8(at: ofs)!)
+ ofs += 1
+ guard headerData.count >= ofs + nameLen else { return nil }
+ let nameData = headerData.subdata(in: ofs..<(ofs + nameLen))
+ ofs += nameLen
+
+ let name = String(data: nameData, encoding: .macOSRoman)
+ ?? String(data: nameData, encoding: .utf8)
+ ?? ""
+ comps.append(name)
+ }
+ return (type, comps)
+ }
+
+ @discardableResult
+ private func updateProgress(_ sent: Int) -> NetSocket.FileProgress {
+ let progress = self.estimator.update(bytes: sent)
+ self.folderProgress?.completedUnitCount = Int64(progress.sent)
+ return progress
+ }
+
+ @discardableResult
+ private func downloadFile(
+ socket: NetSocket,
+ fileName: String,
+ parentPath: [String],
+ destinationFolder: URL,
+ fileSize: UInt32,
+ progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?
+ ) async throws -> URL {
+ let fm = FileManager.default
+// var bytesRead = 0
+
+ // Read file header
+ let headerData = try await socket.read(HotlineFileHeader.DataSize)
+ guard let header = HotlineFileHeader(from: headerData) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+ self.updateProgress(HotlineFileHeader.DataSize)
+
+ print("HotlineFolderDownloadClient[\(self.referenceNumber)]: File has \(header.forkCount) forks")
+
+ var resourceForkData: Data?
+ var fileHandle: FileHandle?
+ var filePath: URL?
+ var fileDataForkSize: Int = 0
+
+ defer {
+ try? fileHandle?.close()
+ }
+
+ // Process each fork
+ for _ in 0..<Int(header.forkCount) {
+ // Read fork header
+ let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize)
+ guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+ self.updateProgress(HotlineFileForkHeader.DataSize)
+
+ if forkHeader.isInfoFork {
+ // Info fork
+ let infoData = try await socket.read(Int(forkHeader.dataSize))
+ self.updateProgress(infoData.count)
+
+ guard let info = HotlineFileInfoFork(from: infoData) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Create parent folder
+ let parentFolderURL = destinationFolder.appendingPathComponents(parentPath)
+ if !fm.fileExists(atPath: parentFolderURL.path) {
+ try fm.createDirectory(at: parentFolderURL, withIntermediateDirectories: true)
+ }
+
+ // Determine final file path
+ let finalURL = parentFolderURL.appendingPathComponent(fileName)
+ filePath = finalURL
+
+ // Remove existing file if present and create with metadata
+ try? fm.removeItem(at: finalURL)
+ fileHandle = try fm.createHotlineFile(at: finalURL, infoFork: info)
+
+ }
+ else if forkHeader.isDataFork {
+ // Data fork
+ guard let fh = fileHandle else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ fileDataForkSize = Int(forkHeader.dataSize)
+
+ // Stream data fork to disk
+ let updates = await socket.receiveFile(to: fh, length: fileDataForkSize)
+ for try await fileProgress in updates {
+ let progress = self.updateProgress(fileProgress.now)
+
+ // Report overall folder progress to UI
+ progressHandler?(.transfer(
+ name: fileName,
+ size: progress.sent,
+ total: progress.total ?? 0,
+ progress: progress.progress,
+ speed: progress.bytesPerSecond,
+ estimate: progress.estimatedTimeRemaining
+ ))
+ }
+
+ } else if forkHeader.isResourceFork {
+ // Resource fork
+ resourceForkData = try await socket.read(Int(forkHeader.dataSize))
+ let progress = self.updateProgress(resourceForkData?.count ?? 0)
+ progressHandler?(.transfer(
+ name: fileName,
+ size: progress.sent,
+ total: progress.total ?? 0,
+ progress: progress.progress,
+ speed: progress.bytesPerSecond,
+ estimate: progress.estimatedTimeRemaining
+ ))
+
+ } else {
+ // Unsupported fork
+ try await socket.skip(Int(forkHeader.dataSize))
+ let progress = self.updateProgress(Int(forkHeader.dataSize))
+ progressHandler?(.transfer(
+ name: fileName,
+ size: progress.sent,
+ total: progress.total ?? 0,
+ progress: progress.progress,
+ speed: progress.bytesPerSecond,
+ estimate: progress.estimatedTimeRemaining
+ ))
+ }
+ }
+
+ // Close file handle
+ try? fileHandle?.close()
+ fileHandle = nil
+
+ guard let filePath else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Write resource fork if present
+ if let rsrcData = resourceForkData, !rsrcData.isEmpty {
+ try writeResourceFork(data: rsrcData, to: filePath)
+ }
+
+ return filePath
+ }
+
+ private func writeResourceFork(data: Data, to url: URL) throws {
+ try data.write(to: url.resolvingSymlinksInPath().urlForResourceFork())
+ }
+}
diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift
new file mode 100644
index 0000000..f8dfe02
--- /dev/null
+++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift
@@ -0,0 +1,515 @@
+import Foundation
+import Network
+
+/// Item progress callback for folder uploads
+public struct HotlineFolderItemUploadProgress: Sendable {
+ public let fileName: String
+ public let itemNumber: Int
+ public let totalItems: Int
+}
+
+/// Represents a file or folder in the upload queue
+private struct FolderItem {
+ let url: URL
+ let pathComponents: [String] // Path relative to upload root
+ let isFolder: Bool
+}
+
+@MainActor
+public class HotlineFolderUploadClient: @MainActor HotlineTransferClient {
+ public struct Configuration: Sendable {
+ public var chunkSize: Int = 256 * 1024
+ public init() {}
+ }
+
+ private let serverAddress: String
+ private let serverPort: UInt16
+ private let referenceNumber: UInt32
+ private let folderURL: URL
+
+ private let config: Configuration
+
+ private var transferTotal: Int = 0
+ private var transferSize: Int = 0
+ private var folderItems: [FolderItem] = []
+ private var totalItems: Int = 0
+
+ private var socket: NetSocket?
+ private var uploadTask: Task<Void, Error>?
+
+ public init?(
+ folderURL: URL,
+ address: String,
+ port: UInt16,
+ reference: UInt32,
+ configuration: Configuration = .init()
+ ) {
+ guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else {
+ return nil
+ }
+
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory),
+ isDirectory.boolValue else {
+ return nil
+ }
+
+ self.serverAddress = address
+ self.serverPort = port
+ self.referenceNumber = reference
+ self.folderURL = folderURL
+ self.config = configuration
+
+ print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'")
+ }
+
+ // MARK: - API
+
+ public func upload(
+ progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil,
+ itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil
+ ) async throws {
+ self.uploadTask?.cancel()
+
+ let task = Task<Void, Error> {
+ try await performUpload(
+ progressHandler: progressHandler,
+ itemProgressHandler: itemProgressHandler
+ )
+ }
+ self.uploadTask = task
+
+ do {
+ try await task.value
+ self.uploadTask = nil
+ } catch {
+ print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)")
+ self.uploadTask = nil
+ progressHandler?(.error(error))
+ throw error
+ }
+ }
+
+ /// Cancel the current upload
+ public func cancel() {
+ uploadTask?.cancel()
+ uploadTask = nil
+
+ if let socket = socket {
+ Task {
+ await socket.close()
+ }
+ }
+ }
+
+ // MARK: - Implementation
+
+ private enum UploadStage {
+ case waitingForNextFile // Waiting for server to send .nextFile action
+ case sendingItemHeader // Sending item header to server
+ case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile)
+ case uploadingFile // Uploading file data
+ case done // All items uploaded
+ }
+
+ private func performUpload(
+ progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?,
+ itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)?
+ ) async throws {
+
+ // Note that we're preparing now.
+ progressHandler?(.preparing)
+
+ // Start accessing security-scoped resource
+ let didStartAccess = folderURL.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccess {
+ self.folderURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ // Build folder hierarchy (excluding root folder itself)
+ try buildFolderHierarchy()
+
+ // Fast path if this is an empty folder
+ if self.totalItems == 0 {
+ progressHandler?(.completed(url: nil))
+ return
+ }
+
+ // Note that we're connecting now.
+ progressHandler?(.connecting)
+
+ // Connect to transfer server
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
+
+ self.socket = socket
+ defer { Task { await socket.close() } }
+
+ // Send magic header for folder upload
+ try await socket.write(Data(endian: .big) {
+ "HTXF".fourCharCode()
+ self.referenceNumber
+ UInt32.zero // data size = 0
+ UInt16(1) // type = 1 (folder transfer)
+ UInt16.zero // reserved = 0
+ })
+
+ progressHandler?(.connected)
+
+ var completedItemCount = 0
+ var totalBytesTransferred = 0
+ var itemIndex = 0
+ var stage: UploadStage = .waitingForNextFile
+ var currentItem: FolderItem?
+
+ // State machine loop
+ while stage != .done {
+ switch stage {
+
+ case .waitingForNextFile:
+ // Wait for server to send .nextFile action
+ let action = try await self.readAction(socket: socket)
+ guard action == .nextFile else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Check if we have more items to send
+ if itemIndex < self.folderItems.count {
+ currentItem = self.folderItems[itemIndex]
+ itemIndex += 1
+ stage = .sendingItemHeader
+ } else {
+ // No more items
+ stage = .done
+ }
+
+ case .sendingItemHeader:
+ // Send item header to server
+ guard let item = currentItem else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Encode and send item header
+ totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item))
+
+ // Next: wait for server's response
+ if item.isFolder {
+ // For folders, we're done with this item (just creating the directory)
+ completedItemCount += 1
+ // Server should immediately respond with .nextFile
+ stage = .waitingForNextFile
+ } else {
+ // For files, server will tell us what to do
+ stage = .waitingForFileAction
+ }
+
+ case .waitingForFileAction:
+ // Wait for server action after file header (.sendFile, .nextFile, .resumeFile)
+ guard currentItem != nil else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ let action = try await self.readAction(socket: socket)
+ switch action {
+ case .nextFile:
+ // Server wants to skip this file
+ completedItemCount += 1
+ // The .nextFile action means send next item, check if we have more
+ if itemIndex < self.folderItems.count {
+ currentItem = self.folderItems[itemIndex]
+ itemIndex += 1
+ stage = .sendingItemHeader
+ } else {
+ stage = .done
+ }
+
+ case .sendFile:
+ // Server wants the file
+ completedItemCount += 1
+ stage = .uploadingFile
+
+ case .resumeFile:
+ // Server wants to resume
+ let resumeSizeData = try await socket.read(2)
+ let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!)
+ let _ = try await socket.read(resumeSize)
+ completedItemCount += 1
+ stage = .uploadingFile
+ }
+
+ case .uploadingFile:
+ // Upload file data
+ guard let item = currentItem else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ // Notify item progress
+ itemProgressHandler?(HotlineFolderItemUploadProgress(
+ fileName: item.url.lastPathComponent,
+ itemNumber: completedItemCount,
+ totalItems: self.totalItems
+ ))
+
+ // Upload the file
+ let bytesUploaded = try await self.uploadFile(
+ socket: socket,
+ fileURL: item.url,
+ itemNumber: completedItemCount,
+ totalItems: self.totalItems,
+ totalBytesTransferredSoFar: totalBytesTransferred,
+ progressHandler: progressHandler
+ )
+
+ totalBytesTransferred += bytesUploaded
+ self.transferSize = totalBytesTransferred
+
+ // After uploading, wait for server to send .nextFile
+ stage = .waitingForNextFile
+
+ case .done:
+ break
+ }
+ }
+
+ // All items processed
+ progressHandler?(.completed(url: nil))
+ }
+
+ private func buildFolderHierarchy() throws {
+ let fm = FileManager.default
+ folderItems = []
+ transferTotal = 0
+
+ let rootFolderName = folderURL.lastPathComponent
+
+ // Recursively walk the folder
+ func walkFolder(at url: URL, relativePath: [String]) throws {
+ let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles])
+
+ for itemURL in contents {
+ let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey])
+ let isDirectory = resourceValues.isDirectory ?? false
+ let itemName = itemURL.lastPathComponent
+ let itemPath = relativePath + [itemName]
+
+ if isDirectory {
+ // Add folder to list
+ folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true))
+
+ // Recurse into subfolder
+ try walkFolder(at: itemURL, relativePath: itemPath)
+
+ } else {
+ // Add file to list and calculate size
+ if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) {
+ folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false))
+ transferTotal += Int(fileSize)
+ }
+ }
+ }
+ }
+
+ // Start from root folder with root name as first path component
+ try walkFolder(at: folderURL, relativePath: [rootFolderName])
+ totalItems = folderItems.count
+
+ print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems)
+ }
+
+ private func encodeItemHeader(item: FolderItem) -> Data {
+ let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents
+ let strippedPathCount = strippedPath.count
+
+ // Build path components (Hotline format: reserved(2) + nameLen(1) + name)
+ var pathData = Data()
+ for component in strippedPath {
+ let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data()
+ let nameLen = min(nameData.count, 255)
+
+ pathData.append(contentsOf: [0, 0]) // reserved
+ pathData.append(UInt8(nameLen))
+ pathData.append(nameData.prefix(nameLen))
+ }
+
+ // Calculate header size (this is what goes in the DataSize field)
+ // DataSize = isFolder(2) + pathCount(2) + pathData
+ let headerSize = 2 + 2 + pathData.count
+
+ return Data(endian: .big) {
+ UInt16(headerSize)
+ UInt16(item.isFolder ? 1 : 0)
+ UInt16(strippedPathCount)
+ pathData
+ }
+ }
+
+ private func readAction(socket: NetSocket) async throws -> HotlineFolderAction {
+ let actionData = try await socket.read(2)
+ guard let rawAction = actionData.readUInt16(at: 0),
+ let action = HotlineFolderAction(rawValue: rawAction) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+ return action
+ }
+
+ private func uploadFile(
+ socket: NetSocket,
+ fileURL: URL,
+ itemNumber: Int,
+ totalItems: Int,
+ totalBytesTransferredSoFar: Int,
+ progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?
+ ) async throws -> Int {
+ var bytesUploaded = 0
+ let filename = fileURL.lastPathComponent
+
+ // Get file metadata
+ guard let infoFork = HotlineFileInfoFork(file: fileURL) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ guard let header = HotlineFileHeader(file: fileURL) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ guard let infoForkData = infoFork.data() else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+
+ let dataForkSize = forkSizes.dataForkSize
+ let resourceForkSize = forkSizes.resourceForkSize
+
+ // Calculate total flattened file size
+ guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else {
+ throw HotlineTransferClientError.failedToTransfer
+ }
+ let totalFileSize = Int(flattenedSize)
+
+ // Send file size
+ let fileSizeData = Data(endian: .big) {
+ UInt32(totalFileSize)
+ }
+ try await socket.write(fileSizeData)
+ bytesUploaded += 4
+
+ // Send file header
+ let headerData = header.data()
+ try await socket.write(headerData)
+ bytesUploaded += headerData.count
+
+ // Send INFO fork header
+ let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count))
+ try await socket.write(infoForkHeader.data())
+ bytesUploaded += HotlineFileForkHeader.DataSize
+
+ // Send INFO fork data
+ try await socket.write(infoForkData)
+ bytesUploaded += infoForkData.count
+
+ // Create per-file progress for Finder
+ let fileProgress = Progress(totalUnitCount: Int64(totalFileSize))
+ fileProgress.fileURL = fileURL.resolvingSymlinksInPath()
+ fileProgress.fileOperationKind = Progress.FileOperationKind.uploading
+ fileProgress.publish()
+
+ defer {
+ fileProgress.unpublish()
+ }
+
+ // Send DATA fork if present
+ let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize)
+ try await socket.write(dataForkHeader.data())
+ bytesUploaded += HotlineFileForkHeader.DataSize
+
+ if dataForkSize > 0 {
+ // Stream DATA fork
+ let fileHandle = try FileHandle(forReadingFrom: fileURL)
+ defer { try? fileHandle.close() }
+
+ let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize))
+ for try await p in updates {
+ // Update per-file Finder progress
+ fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent)
+
+ // Calculate overall folder progress
+ let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent
+ let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0
+ let overallProgress = min(rawProgress, 1.0)
+
+ // Calculate overall time estimate
+ let remainingBytes = max(0, self.transferTotal - totalBytesNow)
+ let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 {
+ TimeInterval(remainingBytes) / speed
+ } else {
+ nil
+ }
+
+ // Report overall folder progress
+ progressHandler?(.transfer(
+ name: filename,
+ size: totalBytesNow,
+ total: self.transferTotal,
+ progress: overallProgress,
+ speed: p.bytesPerSecond,
+ estimate: estimate
+ ))
+ }
+
+ bytesUploaded += Int(dataForkSize)
+ }
+
+ // Send RESOURCE fork if present
+ if resourceForkSize > 0 {
+ let resourceURL = fileURL.urlForResourceFork()
+
+ let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize)
+ try await socket.write(resourceForkHeader.data())
+ bytesUploaded += HotlineFileForkHeader.DataSize
+
+ // Stream RESOURCE fork
+ let resourceHandle = try FileHandle(forReadingFrom: resourceURL)
+ defer { try? resourceHandle.close() }
+
+ let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize))
+ for try await p in updates {
+ // Update per-file Finder progress
+ fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent)
+
+ // Calculate overall folder progress
+ let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent
+ let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0
+ let overallProgress = min(rawProgress, 1.0)
+
+ // Calculate overall time estimate
+ let remainingBytes = max(0, self.transferTotal - totalBytesNow)
+ let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 {
+ TimeInterval(remainingBytes) / speed
+ } else {
+ nil
+ }
+
+ // Report overall folder progress
+ progressHandler?(.transfer(
+ name: filename,
+ size: totalBytesNow,
+ total: self.transferTotal,
+ progress: overallProgress,
+ speed: p.bytesPerSecond,
+ estimate: estimate
+ ))
+ }
+
+ bytesUploaded += Int(resourceForkSize)
+ }
+
+ return bytesUploaded
+ }
+}
diff --git a/Hotline/Hotline/Transfers/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift
new file mode 100644
index 0000000..5f786fc
--- /dev/null
+++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift
@@ -0,0 +1,288 @@
+import Foundation
+import Network
+import UniformTypeIdentifiers
+
+protocol HotlineTransferClient {
+// var serverAddress: NWEndpoint.Host { get }
+// var serverPort: NWEndpoint.Port { get }
+// var referenceNumber: UInt32 { get }
+// var status: HotlineTransferStatus { get set }
+
+// func start()
+ func cancel()
+}
+
+enum HotlineTransferClientError: Error {
+ case failedToConnect
+ case failedToTransfer
+ case cancelled
+}
+
+enum HotlineFileFork {
+ case none
+ case info
+ case data
+ case resource
+ case unsupported
+}
+
+enum HotlineTransferStatus: Equatable {
+ case unconnected
+ case connecting
+ case connected
+ case progress(Double)
+ case completing
+ case completed
+ case failed(HotlineTransferClientError)
+}
+
+enum HotlineFileForkType: UInt32 {
+ case none = 0
+ case unsupported = 1
+ case info = 0x494E464F // 'INFO'
+ case data = 0x44415441 // 'DATA'
+ case resource = 1296122706 // 'MACR'
+}
+
+public enum HotlineFolderAction: UInt16 {
+ case sendFile = 1
+ case resumeFile = 2
+ case nextFile = 3
+}
+
+struct HotlineFileHeader {
+ static let DataSize: Int = 4 + 2 + 16 + 2
+
+ let format: UInt32
+ let version: UInt16
+ let forkCount: UInt16
+
+ init?(from data: Data) {
+ guard data.count >= HotlineFileHeader.DataSize else {
+ return nil
+ }
+
+ self.format = data.readUInt32(at: 0)!
+ self.version = data.readUInt16(at: 4)!
+ // 16 bytes of reserved data sits here. Skip it.
+ self.forkCount = data.readUInt16(at: 4 + 2 + 16)!
+ }
+
+ init?(file fileURL: URL) {
+ guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
+ return nil
+ }
+
+ self.format = "FILP".fourCharCode()
+ self.version = 1
+
+ let resourceURL = fileURL.urlForResourceFork()
+ if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) {
+ self.forkCount = 3
+ }
+ else {
+ self.forkCount = 2
+ }
+ }
+
+ func data() -> Data {
+ Data(endian: .big) {
+ self.format
+ self.version
+ Data(repeating: 0, count: 16)
+ self.forkCount
+ }
+ }
+}
+
+// MARK: -
+
+struct HotlineFileForkHeader {
+ static let DataSize: Int = 4 + 4 + 4 + 4
+
+ let forkType: UInt32
+ let compressionType: UInt32
+ let dataSize: UInt32
+
+ init(type: UInt32, dataSize: UInt32) {
+ self.forkType = type
+ self.compressionType = 0
+ self.dataSize = dataSize
+ }
+
+ init?(from data: Data) {
+ guard data.count >= HotlineFileForkHeader.DataSize else {
+ return nil
+ }
+
+ self.forkType = data.readUInt32(at: 0)!
+ self.compressionType = data.readUInt32(at: 4)!
+ // 4 bytes of reserved data sits here. Skip it.
+ // self.reserved = data.readUInt32(at: 4 + 4)!
+ self.dataSize = data.readUInt32(at: 4 + 4 + 4)!
+ }
+
+ func data() -> Data {
+ Data(endian: .big) {
+ self.forkType
+ self.compressionType
+ UInt32.zero
+ self.dataSize
+ }
+ }
+
+ var isInfoFork: Bool {
+ return self.forkType == HotlineFileForkType.info.rawValue
+ }
+
+ var isDataFork: Bool {
+ return self.forkType == HotlineFileForkType.data.rawValue
+ }
+
+ var isResourceFork: Bool {
+ return self.forkType == HotlineFileForkType.resource.rawValue
+ }
+}
+
+// MARK: -
+
+struct HotlineFileInfoFork {
+ static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2
+
+ let platform: UInt32
+ let type: UInt32
+ let creator: UInt32
+ let flags: UInt32
+ let platformFlags: UInt32
+ let createdDate: Date
+ let modifiedDate: Date
+ let nameScript: UInt16
+ let name: String
+ let comment: String?
+ var headerSize: Int
+
+ init?(file fileURL: URL) {
+ guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
+ return nil
+ }
+
+ self.platform = "AMAC".fourCharCode()
+
+ if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) {
+ self.type = hfsInfo.hfsType
+ self.creator = hfsInfo.hfsCreator
+ }
+ else {
+ self.type = 0
+ self.creator = 0
+ }
+
+ self.flags = 0
+ self.platformFlags = 0
+
+ let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL)
+ self.createdDate = dateInfo.createdDate
+ self.modifiedDate = dateInfo.modifiedDate
+
+ self.nameScript = 0
+ self.name = fileURL.lastPathComponent
+
+ let fileComment = try? FileManager.default.getFinderComment(fileURL)
+ self.comment = fileComment ?? ""
+
+ self.headerSize = 0
+ }
+
+ init?(from data: Data) {
+ // Make sure we have at least enough data to read basic header data
+ guard data.count >= HotlineFileInfoFork.BaseDataSize else {
+ return nil
+ }
+
+ if
+ let platform = data.readUInt32(at: 0),
+ let type = data.readUInt32(at: 4),
+ let creator = data.readUInt32(at: 4 + 4),
+ let flags = data.readUInt32(at: 4 + 4 + 4),
+ let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4),
+ // 32 bytes of reserved data sits here. Skip it.
+ let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) {
+
+ let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now
+ let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now
+
+ let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2)
+ if let name = n {
+ self.platform = platform
+ self.type = type
+ self.creator = creator
+ self.flags = flags
+ self.platformFlags = platformFlags
+ self.createdDate = createdDate
+ self.modifiedDate = modifiedDate
+ self.nameScript = nameScript
+ self.name = name
+
+ var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl
+ var commentRead: String? = nil
+ if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 {
+ let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)!
+ var commentCorrupted = false
+
+ // Some servers have incorrect data length for the INFO fork
+ // the length they send is what it should be but don't include
+ // the comment length in the actual data, so we end up with mismatched
+ // lengths. So here we test if the length we read is actually 'DA'
+ // or the first part of the "DATA" fork header.
+ // Needless to say, stuff like this makes for sad code but this ain't so bad.
+ if commentLength == 0x4441 {
+ commentCorrupted = true
+ }
+
+ if !commentCorrupted {
+ let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl)
+ calculatedHeaderSize += 2
+ if cl > 0 {
+ calculatedHeaderSize += Int(cl)
+ if let ct = c, cl > 0 {
+ commentRead = ct
+ }
+ }
+ }
+ }
+
+ self.comment = commentRead
+ self.headerSize = calculatedHeaderSize
+ return
+ }
+ }
+
+ return nil
+ }
+
+ func data() -> Data? {
+ guard let fileName = self.name.data(using: .macOSRoman, allowLossyConversion: true) else {
+ return nil
+ }
+
+ let data = Data(endian: .big) {
+ self.platform
+ self.type
+ self.creator
+ self.flags
+ self.platformFlags
+ Data(repeating: 0, count: 32)
+ self.createdDate
+ self.modifiedDate
+ self.nameScript
+ UInt16(fileName.count)
+ fileName
+ if let commentData = self.comment?.data(using: .macOSRoman) {
+ UInt16(commentData.count)
+ commentData
+ }
+ }
+
+ return data
+ }
+}
diff --git a/Hotline/Info.plist b/Hotline/Info.plist
index a84b52a..608b0b3 100644
--- a/Hotline/Info.plist
+++ b/Hotline/Info.plist
@@ -9,8 +9,10 @@
<string>Editor</string>
<key>CFBundleTypeName</key>
<string>Hotline Bookmark</string>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
<key>LSHandlerRank</key>
- <string>None</string>
+ <string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>&apos;HTbm&apos;</string>
@@ -21,7 +23,7 @@
<array>
<dict>
<key>CFBundleTypeRole</key>
- <string>Viewer</string>
+ <string>Editor</string>
<key>CFBundleURLName</key>
<string>co.goodmake.hotline</string>
<key>CFBundleURLSchemes</key>
@@ -31,7 +33,7 @@
</dict>
<dict>
<key>CFBundleTypeRole</key>
- <string>Viewer</string>
+ <string>Editor</string>
<key>CFBundleURLName</key>
<string>co.goodmake.hotline</string>
<key>CFBundleURLSchemes</key>
diff --git a/Hotline/Utility/BookmarkDocument.swift b/Hotline/Library/BookmarkDocument.swift
index 43f251c..43f251c 100644
--- a/Hotline/Utility/BookmarkDocument.swift
+++ b/Hotline/Library/BookmarkDocument.swift
diff --git a/Hotline/Library/ColorArt.swift b/Hotline/Library/ColorArt.swift
new file mode 100644
index 0000000..93889a3
--- /dev/null
+++ b/Hotline/Library/ColorArt.swift
@@ -0,0 +1,424 @@
+
+// Swift translation and modernization
+// by Dustin Mierau
+//
+// of:
+//
+// ColorArt.swift
+// SLColorArt by Panic Inc.
+//
+// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved.
+//
+// Redistribution and use, with or without modification, are permitted
+// provided that the following conditions are met:
+//
+// - Redistributions must reproduce the above copyright notice, this list of
+// conditions and the following disclaimer in the documentation and/or other
+// materials provided with the distribution.
+//
+// - Neither the name of Panic Inc nor the names of its contributors may be used
+// to endorse or promote works derived from this software without specific prior
+// written permission from Panic Inc.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT,
+// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+
+import AppKit
+import SwiftUI
+
+fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001
+
+// ColorArt.analyze(image: img) -> ColorArt?
+
+struct ColorArt: Equatable {
+ let backgroundColor: NSColor
+ let primaryColor: NSColor
+ let secondaryColor: NSColor
+ let detailColor: NSColor
+// let scaledImage: NSImage
+
+ static func == (lhs: ColorArt, rhs: ColorArt) -> Bool {
+ return lhs.backgroundColor == rhs.backgroundColor &&
+ lhs.primaryColor == rhs.primaryColor &&
+ lhs.secondaryColor == rhs.secondaryColor &&
+ lhs.detailColor == rhs.detailColor
+ }
+
+ static func analyze(image: NSImage) -> ColorArt? {
+ print("ColorArt.analyze: Starting, image size: \(image.size)")
+ // Scale image to a reasonable size for analysis
+ // This is important because:
+ // 1. Makes analysis faster (fewer pixels)
+ // 2. Normalizes weird image dimensions
+ // 3. Ensures CGImage conversion succeeds
+ print("ColorArt.analyze: Calling scaleImage...")
+ let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100))
+ print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)")
+
+ guard let colors = Self.analyzeImage(finalImage) else {
+ print("ColorArt.analyze: failed with no colors")
+ return nil
+ }
+
+ print("ColorArt.analyze: returning colors", colors)
+
+ return ColorArt(backgroundColor: colors.background,
+ primaryColor: colors.primary,
+ secondaryColor: colors.secondary,
+ detailColor: colors.detail)
+ }
+
+ // MARK: - Image Scaling
+
+ private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage {
+ print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)")
+ // Get CGImage directly without using lockFocus
+ print("ColorArt.scaleImage: Getting CGImage...")
+ guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ print("ColorArt.scaleImage: Failed to get CGImage, returning original")
+ return image
+ }
+ print("ColorArt.scaleImage: Got CGImage")
+
+ let imageSize = image.size
+ let squareSize = min(imageSize.width, imageSize.height)
+
+ // Use native square size if passed zero size
+ let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize
+
+ // Create bitmap context for drawing
+ let width = Int(finalScaledSize.width)
+ let height = Int(finalScaledSize.height)
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
+ let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
+
+ guard let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: width * 4,
+ space: colorSpace,
+ bitmapInfo: bitmapInfo.rawValue
+ ) else {
+ return image
+ }
+
+ // Draw the image scaled
+ context.interpolationQuality = .high
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height))
+
+ // Create NSImage from context
+ guard let scaledCGImage = context.makeImage() else {
+ return image
+ }
+
+ let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage)
+ let finalImage = NSImage(size: finalScaledSize)
+ finalImage.addRepresentation(bitmapRep)
+
+ return finalImage
+ }
+
+ // MARK: - Image Analysis
+
+ private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? {
+ var imageColors: NSCountedSet?
+ guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors),
+ let colors = imageColors
+ else {
+ return nil
+ }
+
+ let darkBackground = backgroundColor.isDarkColor
+ var primaryColor: NSColor?
+ var secondaryColor: NSColor?
+ var detailColor: NSColor?
+
+ self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor)
+
+ // Fallback to black or white if colors not found
+ if primaryColor == nil {
+ primaryColor = darkBackground ? .white : .black
+ }
+
+ if secondaryColor == nil {
+ secondaryColor = darkBackground ? .white : .black
+ }
+
+ if detailColor == nil {
+ detailColor = darkBackground ? .white : .black
+ }
+
+ // Convert all colors to calibrated RGB color space for consistency
+ // This ensures all colors are in the same color space and prevents
+ // any color space conversion issues when used in SwiftUI
+ let rgbColorSpace = NSColorSpace.genericRGB
+ let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor
+ let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor!
+ let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor!
+ let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor!
+
+ return (finalBackground, finalPrimary, finalSecondary, finalDetail)
+ }
+
+ // MARK: - Edge Color Detection
+
+ private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? {
+ var bitmapRep: NSBitmapImageRep?
+
+ // Try to get existing bitmap representation
+ if let existingRep = image.representations.last as? NSBitmapImageRep {
+ bitmapRep = existingRep
+ } else {
+ // Create bitmap rep from CGImage instead of using lockFocus
+ guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ return nil
+ }
+ bitmapRep = NSBitmapImageRep(cgImage: cgImage)
+ }
+
+ // Convert to RGB color space
+ guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else {
+ return nil
+ }
+
+ let pixelsWide = bitmapRep.pixelsWide
+ let pixelsHigh = bitmapRep.pixelsHigh
+
+ let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh)
+ let leftEdgeColors = NSCountedSet(capacity: pixelsHigh)
+ var searchColumnX = 0
+
+ for x in 0..<pixelsWide {
+ for y in 0..<pixelsHigh {
+ guard let color = bitmapRep.colorAt(x: x, y: y) else { continue }
+
+ if x == searchColumnX {
+ // Make sure it's a meaningful color
+ if color.alphaComponent > 0.5 {
+ leftEdgeColors.add(color)
+ }
+ }
+
+ if color.alphaComponent > CGFloat.ulpOfOne {
+ colors.add(color)
+ }
+ }
+
+ // Background is clear, keep looking in next column for background color
+ if leftEdgeColors.count == 0 {
+ searchColumnX += 1
+ }
+ }
+
+ imageColors = colors
+
+ var sortedColors: [CountedColor] = []
+
+ for color in leftEdgeColors {
+ guard let nsColor = color as? NSColor else { continue }
+ let colorCount = leftEdgeColors.count(for: nsColor)
+
+ let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage)
+
+ if colorCount <= randomColorsThreshold {
+ continue
+ }
+
+ sortedColors.append(CountedColor(color: nsColor, count: colorCount))
+ }
+
+ sortedColors.sort { $0.count > $1.count }
+
+ guard var proposedEdgeColor = sortedColors.first else {
+ return nil
+ }
+
+ // Want to choose color over black/white so we keep looking
+ if proposedEdgeColor.color.isBlackOrWhite {
+ for i in 1..<sortedColors.count {
+ let nextProposedColor = sortedColors[i]
+
+ // Make sure the second choice color is 30% as common as the first choice
+ if Double(nextProposedColor.count) / Double(proposedEdgeColor.count) > 0.3 {
+ if !nextProposedColor.color.isBlackOrWhite {
+ proposedEdgeColor = nextProposedColor
+ break
+ }
+ } else {
+ // Reached color threshold less than 30% of the original proposed edge color
+ break
+ }
+ }
+ }
+
+ return proposedEdgeColor.color
+ }
+
+ // MARK: - Text Color Detection
+
+ private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) {
+ var sortedColors: [CountedColor] = []
+ let findDarkTextColor = !backgroundColor.isDarkColor
+
+ for color in colors {
+ guard let nsColor = color as? NSColor else { continue }
+ let adjustedColor = nsColor.withMinimumSaturation(0.15)
+
+ if adjustedColor.isDarkColor == findDarkTextColor {
+ let colorCount = colors.count(for: nsColor)
+ sortedColors.append(CountedColor(color: adjustedColor, count: colorCount))
+ }
+ }
+
+ sortedColors.sort { $0.count > $1.count }
+
+ for container in sortedColors {
+ let curColor = container.color
+
+ if primaryColor == nil {
+ if curColor.isContrasting(to: backgroundColor) {
+ primaryColor = curColor
+ }
+ } else if secondaryColor == nil {
+ if let primary = primaryColor,
+ primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) {
+ secondaryColor = curColor
+ }
+ } else if detailColor == nil {
+ if let primary = primaryColor,
+ let secondary = secondaryColor,
+ secondary.isDistinct(from: curColor) &&
+ primary.isDistinct(from: curColor) &&
+ curColor.isContrasting(to: backgroundColor) {
+ detailColor = curColor
+ break
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Helper Classes
+
+fileprivate struct CountedColor {
+ let color: NSColor
+ let count: Int
+}
+
+// MARK: - NSColor Extensions
+
+extension NSColor {
+ var isDarkColor: Bool {
+ guard let convertedColor = usingColorSpace(.genericRGB) else {
+ return false
+ }
+
+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
+ convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a)
+
+ let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
+
+ return lum < 0.5
+ }
+
+ func isDistinct(from compareColor: NSColor) -> Bool {
+ guard let convertedColor = usingColorSpace(.genericRGB),
+ let convertedCompareColor = compareColor.usingColorSpace(.genericRGB)
+ else {
+ return false
+ }
+
+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
+ var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
+
+ convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a)
+ convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
+
+ let threshold: CGFloat = 0.25
+
+ if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold {
+ // Check for grays, prevent multiple gray colors
+ if abs(r - g) < 0.03 && abs(r - b) < 0.03 {
+ if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ return false
+ }
+
+ func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor {
+ guard let tempColor = usingColorSpace(.genericRGB) else {
+ return self
+ }
+
+ var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0
+ tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
+
+ if saturation < minSaturation {
+ return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha)
+ }
+
+ return self
+ }
+
+ var isBlackOrWhite: Bool {
+ guard let tempColor = usingColorSpace(.genericRGB) else {
+ return false
+ }
+
+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
+ tempColor.getRed(&r, green: &g, blue: &b, alpha: &a)
+
+ // White
+ if r > 0.91 && g > 0.91 && b > 0.91 {
+ return true
+ }
+
+ // Black
+ if r < 0.09 && g < 0.09 && b < 0.09 {
+ return true
+ }
+
+ return false
+ }
+
+ func isContrasting(to color: NSColor) -> Bool {
+ guard let backgroundColor = usingColorSpace(.genericRGB),
+ let foregroundColor = color.usingColorSpace(.genericRGB)
+ else {
+ return true
+ }
+
+ var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0
+ var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0
+
+ backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba)
+ foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa)
+
+ let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb
+ let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb
+
+ let contrast: CGFloat
+ if bLum > fLum {
+ contrast = (bLum + 0.05) / (fLum + 0.05)
+ } else {
+ contrast = (fLum + 0.05) / (bLum + 0.05)
+ }
+
+ return contrast > 1.6
+ }
+}
diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift
new file mode 100644
index 0000000..5ebc7db
--- /dev/null
+++ b/Hotline/Library/Extensions.swift
@@ -0,0 +1,299 @@
+import Foundation
+import SwiftUI
+import UniformTypeIdentifiers
+
+extension FileManager {
+ @discardableResult
+ func moveToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool {
+ let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename)
+ let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath()
+
+ do {
+ try FileManager.default.moveItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL)
+ }
+ catch {
+ return false
+ }
+
+ if bounceDock {
+ #if os(macOS)
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path)
+ #endif
+ }
+
+ return true
+ }
+
+ @discardableResult
+ func copyToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool {
+ let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename)
+ let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath()
+
+ do {
+ try FileManager.default.copyItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL)
+ }
+ catch {
+ return false
+ }
+
+ if bounceDock {
+ #if os(macOS)
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path)
+ #endif
+ }
+
+ return true
+ }
+}
+
+// MARK: -
+
+extension View {
+ @ViewBuilder
+ func applyNavigationDocumentIfPresent(_ url: URL?) -> some View {
+ if let url {
+ self.navigationDocument(url)
+ } else {
+ self
+ }
+ }
+}
+
+// MARK: -
+
+extension Data {
+ func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool {
+ let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename)
+
+ if FileManager.default.createFile(atPath: filePath, contents: self) {
+ if bounceDock {
+ #if os(macOS)
+ var downloadURL = URL(filePath: filePath)
+ downloadURL.resolveSymlinksInPath()
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
+ #endif
+ }
+ return true
+
+// if FileManager.default.createFile(atPath: filePath, contents: nil) {
+// if let h = FileHandle(forWritingAtPath: filePath) {
+// try? h.write(contentsOf: self)
+// try? h.close()
+//
+// }
+ }
+ return false
+ }
+
+ enum ImageFormat {
+ case gif
+ case jpeg
+ case png
+ case webp
+ case unknown
+ }
+
+ var detectedImageFormat: ImageFormat {
+ guard self.count >= 12 else { return .unknown }
+
+ let bytes = [UInt8](self.prefix(12))
+
+ // GIF: "GIF8"
+ if bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 {
+ return .gif
+ }
+
+ // JPEG: FF D8 FF
+ if bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
+ return .jpeg
+ }
+
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
+ if bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A {
+ return .png
+ }
+
+ // WebP: "RIFF" at 0-3 and "WEBP" at 8-11
+ if bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 {
+ return .webp
+ }
+
+ return .unknown
+ }
+}
+
+// MARK: -
+
+extension URL {
+ func generateUniqueFilePath(filename base: String) -> String {
+ let fileManager = FileManager.default
+ var finalName = base
+ var counter = 2
+
+ // Helper function to generate a new filename with a counter
+ func makeFileName() -> String {
+ let baseName = (base as NSString).deletingPathExtension
+ let extensionName = (base as NSString).pathExtension
+ return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)"
+ }
+
+ // Check if file exists and append counter until a unique name is found
+ var filePath = self.appending(component: finalName).path(percentEncoded: false)
+ while fileManager.fileExists(atPath: filePath) {
+ finalName = makeFileName()
+ filePath = self.appending(component: finalName).path(percentEncoded: false)
+ counter += 1
+ }
+
+ return filePath
+ }
+
+ func generateUniqueFileURL(filename base: String) -> URL {
+ let filePath = self.generateUniqueFilePath(filename: base)
+ return URL(filePath: filePath)
+ }
+}
+
+// MARK: -
+
+extension UTType {
+ var canBePreviewedByQuickLook: Bool {
+ // QuickLook supports most common document types
+ let supportedSupertypes: [UTType] = [
+ .image,
+ .movie,
+ .audio,
+ .pdf,
+ .font,
+ .usdz,
+ .text,
+ .sourceCode,
+ .spreadsheet,
+ .presentation,
+
+// Microsoft Office
+ .init(filenameExtension: "doc")!,
+ .init(filenameExtension: "docx")!,
+ .init(filenameExtension: "xls")!,
+ .init(filenameExtension: "xlsx")!,
+ .init(filenameExtension: "ppt")!,
+ .init(filenameExtension: "pptx")!,
+ ]
+
+ return supportedSupertypes.contains { self.conforms(to: $0) }
+ }
+}
+
+// MARK: -
+
+extension String {
+
+ var isBlank: Bool {
+ self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ func markdownToAttributedString() -> AttributedString {
+ let markdownText = self.convertingLinksToMarkdown()
+ let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self)
+
+ return attr
+ }
+
+ func convertToAttributedStringWithLinks() -> AttributedString {
+ let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self)
+ let matches = self.ranges(of: RegularExpressions.relaxedLink)
+ for match in matches {
+ let matchString = String(self[match])
+ if matchString.isEmailAddress() {
+ attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self))
+ }
+ else {
+ attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self))
+ }
+// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self))
+ }
+ return AttributedString(attributedString)
+ }
+
+ func isEmailAddress() -> Bool {
+ self.wholeMatch(of: RegularExpressions.emailAddress) != nil
+ }
+
+ func isWebURL() -> Bool {
+ guard let url = URL(string: self) else {
+ return false
+ }
+ switch url.scheme?.lowercased() {
+ case "http", "https":
+ return true
+ default:
+ return false
+ }
+ }
+
+ func isImageURL() -> Bool {
+ guard let url = URL(string: self) else {
+ return false
+ }
+
+ switch url.pathExtension.lowercased() {
+ case "jpg", "jpeg", "png", "gif":
+ return true
+ default:
+ return false
+ }
+ }
+
+ func convertingLinksToMarkdown() -> String {
+// var cp = String(self)
+
+ self.replacing(RegularExpressions.relaxedLink) { match in
+ let linkText = self[match.range]
+
+ // Only add https:// if the link doesn't already have a scheme
+ let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil
+ let url = hasScheme ? String(linkText) : "https://\(linkText)"
+
+ return "[\(linkText)](\(url))"
+ }
+
+// cp.replace(RegularExpressions.relaxedLink) { match -> String in
+// let linkText = self[match.range]
+// var injectedScheme = "https://"
+// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) {
+// injectedScheme = ""
+// }
+//
+// return "[\(linkText)](\(injectedScheme)\(linkText))"
+// }
+// return cp
+ }
+}
+
+// MARK: -
+
+extension Binding where Value: OptionSet, Value == Value.Element {
+ func bindedValue(_ options: Value) -> Bool {
+ return wrappedValue.contains(options)
+ }
+
+ func bind(_ options: Value) -> Binding<Bool> {
+ return .init { () -> Bool in
+ self.wrappedValue.contains(options)
+ } set: { newValue in
+ if newValue {
+ self.wrappedValue.insert(options)
+ } else {
+ self.wrappedValue.remove(options)
+ }
+ }
+ }
+}
+
+// MARK: -
+
+extension Color {
+ init(hex: Int, opacity: Double = 1.0) {
+ self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity)
+ }
+}
diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift
new file mode 100644
index 0000000..2064c59
--- /dev/null
+++ b/Hotline/Library/NetSocket/FileProgress.swift
@@ -0,0 +1,71 @@
+// NetSocket FileProgress
+// Dustin Mierau • @mierau
+// MIT License
+
+import Foundation
+
+public extension NetSocket {
+
+ /// Progress information for file uploads/downloads
+ struct FileProgress: Sendable {
+ /// Number of bytes sent/received so far
+ public let sent: Int
+ /// Total file size (may be nil if unknown)
+ public let total: Int?
+ /// Size of most recent packet
+ public let now: Int
+ /// Total progress so far (0.0 to 1.0)
+ public let progress: Double
+ /// Smoothed transfer rate in bytes per second (EMA), if enough samples collected
+ public let bytesPerSecond: Double?
+ /// Estimated time remaining (seconds) based on smoothed rate, if available
+ public let estimatedTimeRemaining: TimeInterval?
+
+ public init(sent: Int, total: Int?, now: Int = 0, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) {
+ self.sent = sent
+ self.total = total
+ self.now = now
+
+ if let t = total {
+ self.progress = max(0.0, min(1.0, Double(sent) / Double(t)))
+ }
+ else {
+ self.progress = 0.0
+ }
+
+ self.bytesPerSecond = bytesPerSecond
+ self.estimatedTimeRemaining = estimatedTimeRemaining
+ }
+
+ /// Format transfer speed in human-readable format
+ ///
+ /// Automatically selects appropriate unit (B/sec, KB/sec, MB/sec, GB/sec)
+ /// based on the magnitude of the speed.
+ ///
+ /// - Returns: Formatted string like "45KB/sec", "5B/sec", "12.5MB/sec", or nil if speed unavailable
+ ///
+ /// Example:
+ /// ```swift
+ /// if let speedString = progress.formattedSpeed {
+ /// print(speedString) // "2.5MB/sec"
+ /// }
+ /// ```
+ public var formattedSpeed: String? {
+ guard let bytesPerSecond = bytesPerSecond, bytesPerSecond > 0 else { return nil }
+
+ let kb = 1024.0
+ let mb = kb * 1024.0
+ let gb = mb * 1024.0
+
+ if bytesPerSecond >= gb {
+ return String(format: "%.1fGB/sec", bytesPerSecond / gb)
+ } else if bytesPerSecond >= mb {
+ return String(format: "%.1fMB/sec", bytesPerSecond / mb)
+ } else if bytesPerSecond >= kb {
+ return String(format: "%.0fKB/sec", bytesPerSecond / kb)
+ } else {
+ return String(format: "%.0fB/sec", bytesPerSecond)
+ }
+ }
+ }
+}
diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift
new file mode 100644
index 0000000..ffccf5c
--- /dev/null
+++ b/Hotline/Library/NetSocket/NetSocket.swift
@@ -0,0 +1,1117 @@
+// NetSocket.swift
+// Dustin Mierau • @mierau
+// MIT License
+
+import Foundation
+import Network
+
+/// Byte order for multi-byte integer values in binary protocols
+public enum Endian {
+ /// Big-endian (network byte order, most significant byte first)
+ case big
+ /// Little-endian (least significant byte first)
+ case little
+}
+
+/// Delimiter patterns for text-based protocols
+public enum Delimiter {
+ /// Custom single byte delimiter
+ case byte(UInt8)
+ /// Null terminator (0x00)
+ case zeroByte
+ /// Line feed (\n, 0x0A)
+ case lineFeed
+ /// Carriage return + line feed (\r\n, 0x0D 0x0A)
+ case carriageReturnLineFeed
+
+ /// Binary representation of this delimiter
+ var data: Data {
+ switch self {
+ case .byte(let b): return Data([b])
+ case .zeroByte: return Data([0x00])
+ case .lineFeed: return Data([0x0A])
+ case .carriageReturnLineFeed: return Data([0x0D, 0x0A])
+ }
+ }
+}
+
+/// TLS/SSL encryption policy for socket connections
+public struct TLSPolicy: Sendable {
+ /// Create a TLS-enabled policy with optional custom configuration
+ /// - Parameter configure: Optional closure to customize TLS options
+ public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy {
+ TLSPolicy(enabled: true, configure: configure)
+ }
+
+ /// Create a policy with TLS disabled (plaintext connection)
+ public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) }
+
+ /// Whether TLS is enabled
+ public let enabled: Bool
+ /// Optional TLS configuration closure
+ public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)?
+}
+
+// MARK: - Errors
+
+/// Errors that can occur during socket operations
+public enum NetSocketError: Error, CustomStringConvertible, Sendable {
+ /// Socket is not yet in ready state
+ case notReady
+ /// Connection has been closed
+ case closed
+ /// Invalid port number provided
+ case invalidPort
+ /// Network operation failed with underlying error
+ case failed(underlying: Error)
+ /// Not enough data available to fulfill read request
+ case insufficientData(expected: Int, got: Int)
+ /// Frame size exceeds configured maximum
+ case framingExceeded(max: Int)
+ /// Failed to decode data
+ case decodeFailed(Error)
+ /// Failed to encode data
+ case encodeFailed(Error)
+
+ public var description: String {
+ switch self {
+ case .notReady: return "Connection not ready."
+ case .closed: return "Connection closed."
+ case .invalidPort: return "Invalid port number."
+ case .failed(let e): return "Network failure: \(e.localizedDescription)"
+ case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)."
+ case .framingExceeded(let max): return "Frame length exceeded maximum \(max)."
+ case .decodeFailed(let e): return "Decoding failed: \(e)"
+ case .encodeFailed(let e): return "Encoding failed: \(e)"
+ }
+ }
+}
+
+/// An async TCP socket with automatic buffering
+///
+/// NetSocket provides:
+/// - Async connection management
+/// - Automatic receive buffering with memory compaction
+/// - Type-safe reading/writing of integers, strings, and custom types
+/// - File upload/download with progress tracking
+///
+/// Example usage:
+/// ```swift
+/// let socket = try await NetSocket.connect(host: "example.com", port: 80)
+/// try await socket.write("Hello\n".data(using: .utf8)!)
+/// let response = try await socket.read(until: .lineFeed)
+/// ```
+public actor NetSocket {
+ /// Configuration options for the socket
+ public struct Config: Sendable {
+ /// Size of chunks to receive from network at once (default: 64 KB)
+ public var receiveChunk: Int = 64 * 1024
+ /// Maximum bytes to buffer before disconnecting (default: 8 MB)
+ public var maxBufferBytes: Int = 8 * 1024 * 1024
+ public init() {}
+ }
+
+ // Connection + state
+ private let connection: NWConnection
+ private let queue = DispatchQueue(label: "NetSocket.NWConnection")
+ private var ready = false
+ private var isClosed = false
+ private let connectionID: String // For logging
+
+ // Buffer with compaction
+ private var buffer = Data()
+ private var head = 0 // start of unread bytes
+ private let config: Config
+
+ // Waiters for data/ready
+ private var dataWaiters: [CheckedContinuation<Void, Error>] = []
+ private var readyWaiters: [CheckedContinuation<Void, Error>] = []
+
+ // MARK: Init
+
+ private init(connection: NWConnection, config: Config) {
+ self.connection = connection
+ self.config = config
+ // Create a human-readable connection ID for logging
+ if case .hostPort(host: let h, port: let p) = connection.endpoint {
+ self.connectionID = "\(h):\(p)"
+ } else {
+ self.connectionID = "unknown"
+ }
+ }
+
+ // MARK: Connect
+
+ /// Connect to a remote host and return a ready socket
+ ///
+ /// This method establishes a TCP connection using Network framework types and waits until
+ /// the connection is in `.ready` state.
+ ///
+ /// - Parameters:
+ /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`)
+ /// - port: Network framework port
+ /// - config: Socket configuration (default: standard settings)
+ /// - parameters: NWParameters (default: .tcp)
+ /// - Returns: A connected and ready `NetSocket`
+ /// - Throws: Network errors or connection failures
+ public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, config: Config = .init(), parameters: NWParameters = .tcp) async throws -> NetSocket {
+ let conn = NWConnection(host: host, port: port, using: parameters)
+ let socket = NetSocket(connection: conn, config: config)
+ try await socket.start()
+ return socket
+ }
+
+ /// Convenience wrapper to connect using string hostname and integer port
+ public static func connect(host: String, port: UInt16, config: Config = .init()) async throws -> NetSocket {
+ guard let nwPort = NWEndpoint.Port(rawValue: port) else {
+ throw NetSocketError.invalidPort
+ }
+
+ return try await self.connect(host: NWEndpoint.Host(host), port: nwPort, config: config)
+ }
+
+ // MARK: Close
+
+ /// Close the connection gracefully
+ ///
+ /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN)
+ /// and wakes all pending read/write operations with a `NetSocketError.closed` error.
+ /// This method is idempotent - subsequent calls are ignored.
+ ///
+ /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST).
+ public func close() {
+ guard !isClosed else { return }
+ isClosed = true
+ connection.cancel()
+ resumeDataWaiters()
+ resumeReadyWaiters(with: .failure(NetSocketError.closed))
+ }
+
+ /// Force close the connection immediately (non-graceful)
+ ///
+ /// Performs an immediate non-graceful shutdown of the underlying network connection
+ /// (e.g., TCP RST). Use this when you need to terminate the connection immediately
+ /// without waiting for graceful closure. For normal shutdown, use `close()` instead.
+ ///
+ /// This method is idempotent - subsequent calls are ignored.
+ public func forceClose() {
+ guard !isClosed else { return }
+ isClosed = true
+ connection.forceCancel()
+ resumeDataWaiters()
+ resumeReadyWaiters(with: .failure(NetSocketError.closed))
+ }
+
+ // MARK: Send Data
+
+ /// Write raw data to the socket
+ ///
+ /// Sends data and waits for confirmation that it has been processed by the network stack.
+ ///
+ /// - Parameter data: Raw bytes to send
+ /// - Throws: `NetSocketError` if connection is not ready or send fails
+ @discardableResult
+ public func write(_ data: Data) async throws -> Int {
+ try await ensureReady()
+ return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Int, Error>) in
+ connection.send(content: data, completion: .contentProcessed { error in
+ if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) }
+ else { cont.resume(returning: data.count) }
+ })
+ }
+ }
+
+ /// Write a fixed-width integer to the socket
+ ///
+ /// - Parameters:
+ /// - value: The integer value to write
+ /// - endian: Byte order (default: big-endian)
+ /// - Throws: `NetSocketError` if write fails
+ @discardableResult
+ public func write<T: FixedWidthInteger>(_ value: T, endian: Endian = .big) async throws -> Int {
+ var v = value
+ switch endian {
+ case .big: v = T(bigEndian: value)
+ case .little: v = T(littleEndian: value)
+ }
+ var copy = v
+ let size = MemoryLayout<T>.size
+ let bytes = withUnsafePointer(to: &copy) {
+ Data(bytes: $0, count: size)
+ }
+ try await write(bytes)
+ return bytes.count
+ }
+
+ /// Write a boolean as a single byte (0 or 1)
+ /// - Parameter value: Boolean value
+ @discardableResult
+ public func write(_ value: Bool) async throws -> Int {
+ return try await write(UInt8(value ? 0x01 : 0x00))
+ }
+
+ /// Write a Float as its IEEE 754 bit pattern
+ /// - Parameters:
+ /// - value: Float value
+ /// - endian: Byte order (default: big-endian)
+ @discardableResult
+ public func write(_ value: Float, endian: Endian = .big) async throws -> Int {
+ return try await write(value.bitPattern, endian: endian)
+ }
+
+ /// Write a Double as its IEEE 754 bit pattern
+ /// - Parameters:
+ /// - value: Double value
+ /// - endian: Byte order (default: big-endian)
+ @discardableResult
+ public func write(_ value: Double, endian: Endian = .big) async throws -> Int {
+ return try await write(value.bitPattern, endian: endian)
+ }
+
+ /// Write a string to the socket, optionally length-prefixed
+ ///
+ /// - Parameters:
+ /// - string: String to write
+ /// - encoding: Text encoding (default: UTF-8)
+ /// - allowLossyConversion: Allow lossy encoding if necessary (default: false)
+ /// - Throws: `NetSocketError` if encoding fails or write fails
+ @discardableResult
+ public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int {
+ guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else {
+ throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1))
+ }
+ return try await write(data)
+ }
+
+ // MARK: Receive Data
+
+ /// Read data until a delimiter is found
+ ///
+ /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including)
+ /// the delimiter. The delimiter is always consumed from the stream.
+ ///
+ /// - Parameters:
+ /// - delimiter: Binary delimiter pattern to search for
+ /// - maxBytes: Maximum bytes to read before throwing (default: no limit)
+ /// - includeDelimiter: Whether to include delimiter in result (default: false)
+ /// - Returns: Data read from stream
+ /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors
+ public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data {
+ while true {
+ try Task.checkCancellation()
+ if let r = search(delimiter: delimiter) {
+ let consumeLen = r.upperBound - head
+ let data = try await read(consumeLen)
+ return includeDelimiter ? data : data.dropLast(delimiter.count)
+ }
+ if let maxBytes, availableBytes >= maxBytes {
+ throw NetSocketError.framingExceeded(max: maxBytes)
+ }
+ try await waitForData()
+ guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed }
+ }
+ }
+
+ /// Read exactly N bytes from the socket
+ ///
+ /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer
+ /// is automatically compacted after reading to prevent unbounded memory growth.
+ ///
+ /// - Parameter count: Number of bytes to read
+ /// - Returns: Exactly `count` bytes
+ /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives
+ public func read(_ count: Int) async throws -> Data {
+ try await self.ensureReadable(count)
+ let start = self.head
+ let end = self.head + count
+ let slice = self.buffer[start..<end]
+ self.head = end
+ self.compactIfNeeded()
+ return Data(slice)
+ }
+
+ /// Read a fixed-width integer from the socket
+ ///
+ /// - Parameters:
+ /// - type: Integer type to read
+ /// - endian: Byte order (default: big-endian)
+ /// - Returns: The integer value
+ /// - Throws: `NetSocketError` if insufficient data or connection closed
+ public func read<T: FixedWidthInteger>(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T {
+ let size = MemoryLayout<T>.size
+ let data = try await self.read(size)
+ let value: T = data.withUnsafeBytes { raw in
+ raw.load(as: T.self)
+ }
+ switch endian {
+ case .big: return T(bigEndian: value)
+ case .little: return T(littleEndian: value)
+ }
+ }
+
+ /// Read a fixed-length string
+ ///
+ /// - Parameters:
+ /// - length: Number of bytes to read
+ /// - encoding: Text encoding (default: UTF-8)
+ /// - Returns: Decoded string
+ /// - Throws: `NetSocketError` if decoding fails or insufficient data
+ public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String {
+ let data = try await self.read(length)
+ guard let s = String(data: data, encoding: encoding) else {
+ throw NetSocketError.decodeFailed(NSError())
+ }
+ return s
+ }
+
+ /// Read a string until a delimiter is found
+ ///
+ /// - Parameters:
+ /// - delimiter: Delimiter pattern to search for
+ /// - maxBytes: Maximum bytes to read before throwing (default: no limit)
+ /// - includeDelimiter: Whether to include delimiter in result (default: false)
+ /// - Returns: String read from stream (delimiter consumed but not included unless specified)
+ /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed
+ public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String {
+ let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter)
+ guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) }
+ return s
+ }
+
+ /// Read exactly N bytes with progress callbacks
+ ///
+ /// Like `read(_:)`, but reads in chunks and reports progress after each chunk.
+ /// Useful for downloading large amounts of data where you want to update UI progress.
+ ///
+ /// Example:
+ /// ```swift
+ /// let data = try await socket.read(1_000_000) { current, total in
+ /// print("Progress: \(current)/\(total)")
+ /// }
+ /// ```
+ ///
+ /// - Parameters:
+ /// - count: Number of bytes to read
+ /// - chunkSize: Size of chunks to read at a time (default: 8192)
+ /// - progress: Optional callback with (bytesReceived, totalBytes)
+ /// - Returns: Exactly `count` bytes
+ /// - Throws: `NetSocketError` if connection closes before enough data arrives
+ public func read(
+ _ count: Int,
+ chunkSize: Int = 8192,
+ progress: (@Sendable (Int, Int) -> Void)? = nil
+ ) async throws -> Data {
+ var data = Data()
+ data.reserveCapacity(count)
+ var received = 0
+
+ while received < count {
+ try Task.checkCancellation()
+ let toRead = min(chunkSize, count - received)
+ let chunk = try await read(toRead)
+ data.append(chunk)
+ received += chunk.count
+ progress?(received, count)
+ }
+
+ return data
+ }
+
+ // MARK: Peek Data
+
+ public var availableBytes: Int { self.buffer.count - self.head }
+
+ public func peek(_ count: Int) -> Data? {
+ guard self.availableBytes >= count else {
+ return nil
+ }
+
+ let slice = self.buffer[self.head..<(self.head + count)]
+ return Data(slice) // Don't advance head
+ }
+
+ public func peek(upto count: Int) -> Data {
+ let amount = min(self.availableBytes, count)
+ guard amount > 0 else {
+ return Data()
+ }
+
+ let slice = self.buffer[self.head..<(self.head + amount)]
+ return Data(slice)
+ }
+
+ public func peek(awaiting count: Int) async throws -> Data {
+ try await self.ensureReadable(count)
+ let slice = self.buffer[self.head..<(self.head + count)]
+ return Data(slice) // Don't advance head
+ }
+
+ // MARK: Skip Data
+
+ /// Skip/discard exactly N bytes from the stream without allocating memory
+ public func skip(_ count: Int) async throws {
+ guard count > 0 else { return }
+ try await self.ensureReadable(count)
+ self.head += count
+ self.compactIfNeeded()
+ }
+
+ /// Skip until delimiter is found (discards delimiter too)
+ public func skip(past delimiter: Data) async throws {
+ while true {
+ try Task.checkCancellation()
+ if let r = self.search(delimiter: delimiter) {
+ self.head = r.upperBound // Skip to end of delimiter
+ self.compactIfNeeded()
+ return
+ }
+ try await self.waitForData()
+ guard !self.isClosed else {
+ throw NetSocketError.closed
+ }
+ }
+ }
+
+ // MARK: Files
+
+ /// Upload a file from a URL, yielding progress as an AsyncSequence.
+ ///
+ /// Iterating this sequence drives the transfer. Each yielded value reports
+ /// the total bytes sent so far and the known total. Cancel the consuming
+ /// task to cancel the transfer.
+ ///
+ /// This method handles opening and closing the file handle automatically.
+ ///
+ /// - Parameters:
+ /// - url: File URL to upload.
+ /// - chunkSize: Size of each read chunk.
+ /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates.
+ func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream<FileProgress, Error> {
+ // This stream wrapper manages the FileHandle's lifetime.
+ return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in
+ // Capture self (the actor) to use in detached task
+ let actor = self
+
+ // Open file on a background thread (file I/O is blocking)
+ let task = Task.detached {
+ let fh: FileHandle
+ let total: Int
+
+ // 1. Open file and get length (blocking I/O, done off-actor)
+ do {
+ total = Int(try NetSocket.fileLength(at: url))
+ fh = try FileHandle(forReadingFrom: url)
+ } catch {
+ continuation.finish(throwing: NetSocketError.failed(underlying: error))
+ return
+ }
+
+ // 2. Now switch to the actor context to call the actor-isolated method
+ let stream = await actor.writeFile(
+ from: fh,
+ length: total,
+ chunkSize: chunkSize
+ )
+
+ // 3. Forward all elements from the underlying stream to our stream
+ do {
+ for try await progress in stream {
+ try Task.checkCancellation() // Exit early if cancelled
+ continuation.yield(progress)
+ }
+ try? fh.close()
+ continuation.finish()
+ } catch is CancellationError {
+ try? fh.close()
+ continuation.finish()
+ } catch {
+ try? fh.close()
+ continuation.finish(throwing: error)
+ }
+ }
+
+ // If the *consumer* cancels the stream, we cancel our managing task.
+ continuation.onTermination = { @Sendable _ in
+ task.cancel()
+ }
+ }
+ }
+
+ /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence.
+ ///
+ /// Iterating this sequence drives the transfer. Each yielded value reports
+ /// the total bytes sent so far and the known total. Cancel the consuming
+ /// task to cancel the transfer.
+ ///
+ /// **Note:** The caller is responsible for opening and closing the `fileHandle`.
+ ///
+ /// - Parameters:
+ /// - fileHandle: Open `FileHandle` for reading.
+ /// - length: Exact number of bytes to send (total file size).
+ /// - chunkSize: Size of each read chunk.
+ /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates.
+ func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream<FileProgress, Error> {
+ precondition(length >= 0, "length must be >= 0")
+
+ if length == 0 {
+ return AsyncThrowingStream { continuation in
+ continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0))
+ continuation.finish()
+ }
+ }
+
+ return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in
+ let task = Task { [weak self] in
+ guard let self else {
+ continuation.finish()
+ return
+ }
+
+ var estimator = TransferRateEstimator(total: Int(length))
+
+ do {
+ try await self.ensureReady()
+
+ while estimator.transferred < length {
+ try Task.checkCancellation()
+
+ let toRead = Int(min(chunkSize, length - estimator.transferred))
+
+ // Read from disk
+ guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else {
+ if estimator.transferred < length {
+ throw NetSocketError.failed(underlying: NSError(
+ domain: "NetSocket", code: 9001,
+ userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."]
+ ))
+ }
+ break
+ }
+
+ // Write to network
+ try await self.write(chunk)
+
+ // Update estimator and yield progress
+ let progress = estimator.update(bytes: chunk.count)
+ continuation.yield(progress)
+ }
+
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ task.cancel()
+ }
+ }
+ }
+
+ /// Receive a file of known length and yield progress updates as an AsyncSequence.
+ ///
+ /// Iterating this sequence drives the transfer. Each yielded value reports
+ /// the total bytes written so far and the known total. Cancel the consuming
+ /// task to cancel the transfer.
+ ///
+ /// - Parameters:
+ /// - fileHandle: Open `FileHandle` for writing (caller must close).
+ /// - length: Exact number of bytes expected.
+ /// - chunkSize: Size of each read chunk.
+ /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates.
+ func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream<FileProgress, Error> {
+ precondition(length >= 0, "length must be >= 0")
+
+ if length == 0 {
+ return AsyncThrowingStream { continuation in
+ continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0))
+ continuation.finish()
+ }
+ }
+
+ return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in
+ let task = Task { [weak self] in
+ guard let self else {
+ continuation.finish()
+ return
+ }
+
+ var estimator = TransferRateEstimator(total: length)
+
+ do {
+ var remaining: Int = length
+
+ while remaining > 0 {
+ try Task.checkCancellation()
+ let n = min(chunkSize, remaining)
+
+ let chunk = try await self.read(n)
+ try fileHandle.write(contentsOf: chunk)
+
+ let chunkSize = Int(chunk.count)
+ remaining -= chunkSize
+ let progress = estimator.update(bytes: chunkSize)
+ continuation.yield(progress)
+ }
+
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ task.cancel()
+ }
+ }
+ }
+
+ /// Download a file of known length and write it to disk in chunks
+ ///
+ /// This method does **not** read a length prefix. The caller must provide the expected
+ /// file size (e.g., from protocol metadata). The file is streamed directly to disk to
+ /// avoid loading it entirely into memory.
+ ///
+ /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and
+ /// renamed on success. If an error occurs, the temporary file is automatically cleaned up.
+ ///
+ /// - Parameters:
+ /// - url: Destination file URL
+ /// - length: Exact number of bytes to read (must match what's on the wire)
+ /// - chunkSize: Chunk size for reading/writing (default: 256 KB)
+ /// - overwrite: Whether to overwrite existing file (default: true)
+ /// - atomic: Write to temporary file and rename on success (default: true)
+ /// - progress: Optional progress callback
+ /// - Returns: Total bytes written (equals `length` on success)
+ /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up.
+ ///
+ /// Example:
+ /// ```swift
+ /// // Hotline protocol: file size comes from transaction header
+ /// let transaction = try await socket.receive(HotlineTransaction.self)
+ /// try await socket.receiveFile(
+ /// to: destinationURL,
+ /// length: transaction.fileSize
+ /// )
+ /// ```
+ @discardableResult
+ func receiveFile(
+ to url: URL,
+ length: Int,
+ chunkSize: Int = 256 * 1024,
+ overwrite: Bool = true,
+ atomic: Bool = true,
+ progress: (@Sendable (FileProgress) -> Void)? = nil
+ ) async throws -> Int {
+ precondition(length >= 0, "length must be >= 0")
+
+ // Fast path: nothing to do
+ if length == 0 {
+ if overwrite { try? FileManager.default.removeItem(at: url) }
+ FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil)
+ return 0
+ }
+
+ // Prepare destination (optionally atomic)
+ let fm = FileManager.default
+ let dir = url.deletingLastPathComponent()
+ let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url
+
+ if overwrite { try? fm.removeItem(at: tmp) }
+ if overwrite, !atomic { try? fm.removeItem(at: url) }
+
+ // Create and open the file for writing
+ fm.createFile(atPath: tmp.path, contents: nil, attributes: nil)
+ let fh = try FileHandle(forWritingTo: tmp)
+ defer { try? fh.close() }
+
+ var remaining: Int = length
+ var written: Int = 0
+
+ do {
+ while remaining > 0 {
+ try Task.checkCancellation()
+ let n = Int(min(chunkSize, remaining))
+ let chunk = try await self.read(n)
+ try fh.write(contentsOf: chunk)
+ remaining -= n
+ written += Int(n)
+ progress?(.init(sent: written, total: length))
+ }
+ } catch {
+ // Cleanup partial file on failure if we were writing atomically
+ if atomic { try? fm.removeItem(at: tmp) }
+ throw error
+ }
+
+ // Atomically move into place if requested
+ if atomic {
+ if overwrite { try? fm.removeItem(at: url) }
+ try fm.moveItem(at: tmp, to: url)
+ }
+
+ return written
+ }
+
+ // MARK: Internals
+
+ private func start() async throws {
+ self.connection.stateUpdateHandler = { state in
+ Task { [weak self] in
+ guard let self else { return }
+ switch state {
+ case .ready:
+ await self.setReady()
+ await self.resumeReadyWaiters(with: .success(()))
+ case .failed(let error):
+ await self.failAllWaiters(NetSocketError.failed(underlying: error))
+ await self.setClosed()
+ case .waiting(let error):
+ // bubble as transient failure for awaiters; reconnect logic could live here
+ await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error)))
+ case .cancelled:
+ await self.failAllWaiters(NetSocketError.closed)
+ await self.setClosed()
+ default:
+ break
+ }
+ }
+ }
+
+ // Kick off receive loop after .start
+ self.connection.start(queue: queue)
+ try await self.waitUntilReady()
+ self.startReceiveLoop()
+ }
+
+ private func startReceiveLoop() {
+ @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in
+ Task {
+ guard let o = owner else {
+ return
+ }
+
+ if let error {
+ await o.handleReceiveError(error)
+ return
+ }
+ if let data, !data.isEmpty {
+ await o.append(data, connID: connID)
+ }
+ if isComplete {
+ await o.handleEOF()
+ return
+ }
+ loop(connection, chunk: chunk, owner: o, connID: connID)
+ }
+ }
+ }
+ loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID)
+ }
+
+ private func handleReceiveError(_ error: Error) {
+ self.isClosed = true
+ self.failAllWaiters(NetSocketError.failed(underlying: error))
+ }
+
+ private func handleEOF() {
+ self.isClosed = true
+ let waiters = self.dataWaiters
+ self.dataWaiters.removeAll()
+ for w in waiters {
+ w.resume()
+ } // wake so readers can observe closure
+ }
+
+ private func setReady() {
+ self.ready = true
+ }
+
+ private func setClosed() {
+ self.isClosed = true
+ }
+
+ private func ensureReady() async throws {
+ if self.isClosed {
+ throw NetSocketError.closed
+ }
+ if !self.ready {
+ try await self.waitUntilReady()
+ }
+ }
+
+ private func ensureReadable(_ count: Int) async throws {
+ try await self.ensureReady()
+ while self.availableBytes < count {
+ try Task.checkCancellation()
+ if self.isClosed {
+ throw NetSocketError.insufficientData(expected: count, got: self.availableBytes)
+ }
+ try await self.waitForData()
+ }
+ }
+
+ private func waitForData() async throws {
+ try Task.checkCancellation()
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
+ if self.isClosed {
+ cont.resume()
+ return
+ }
+ self.dataWaiters.append(cont)
+ }
+ }
+
+ private func compactIfNeeded() {
+ // Avoid unbounded memory as head advances
+ if self.head > 64 * 1024 && self.head > self.buffer.count / 2 {
+ self.buffer.removeSubrange(0..<self.head)
+ self.head = 0
+ }
+ }
+
+ private func search(delimiter: Data) -> Range<Int>? {
+ guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil }
+ let hay = buffer[head..<buffer.count]
+
+ // Fast path for single-byte delimiters
+ if delimiter.count == 1, let byte = delimiter.first {
+ if let idx = hay.firstIndex(of: byte) {
+ let pos = head + hay.distance(from: hay.startIndex, to: idx)
+ return pos..<(pos + 1)
+ }
+ return nil
+ }
+
+ // General case
+ if let r = hay.firstRange(of: delimiter) {
+ let lower = head + hay.distance(from: hay.startIndex, to: r.lowerBound)
+ let upper = head + hay.distance(from: hay.startIndex, to: r.upperBound)
+ return lower..<upper
+ }
+
+ return nil
+ }
+
+ private static func fileLength(at url: URL) throws -> Int64 {
+ let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
+ guard values.isRegularFile == true else {
+ throw NetSocketError.failed(underlying: NSError(
+ domain: "NetSocket", code: 1001,
+ userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"]
+ ))
+ }
+ if let s = values.fileSize { return Int64(s) }
+ let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
+ if let n = attrs[.size] as? NSNumber {
+ return n.int64Value
+ }
+ throw NetSocketError.failed(underlying: NSError(
+ domain: "NetSocket", code: 1002,
+ userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"]
+ ))
+ }
+
+ private func waitUntilReady() async throws {
+ guard !self.ready else { return }
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
+ self.readyWaiters.append(cont)
+ }
+ }
+
+ private func resumeReadyWaiters(with result: Result<Void, Error>) {
+ let waiters = self.readyWaiters
+ self.readyWaiters.removeAll()
+ for w in waiters {
+ switch result {
+ case .success: w.resume()
+ case .failure(let e): w.resume(throwing: e)
+ }
+ }
+ }
+
+ private func failAllWaiters(_ error: Error) {
+ self.resumeReadyWaiters(with: .failure(error))
+ let waiters = self.dataWaiters
+ self.dataWaiters.removeAll()
+ for w in waiters {
+ w.resume(throwing: error)
+ }
+ }
+
+ private func append(_ data: Data, connID: String) {
+ buffer.append(data)
+ if buffer.count - head > config.maxBufferBytes {
+ // Hard stop: drop connection rather than OOM'ing.
+ isClosed = true
+ connection.cancel()
+ failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes))
+ return
+ }
+ resumeDataWaiters()
+ }
+
+ private func resumeDataWaiters() {
+ let waiters = dataWaiters
+ dataWaiters.removeAll()
+ for w in waiters { w.resume() }
+ }
+}
+
+// MARK: - Utilities
+
+private extension Data {
+ mutating func appendInteger<T: FixedWidthInteger>(_ value: T, endian: Endian) throws {
+ var v = value
+ switch endian {
+ case .big: v = T(bigEndian: value)
+ case .little: v = T(littleEndian: value)
+ }
+ var copy = v
+ withUnsafePointer(to: &copy) { ptr in
+ self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout<T>.size))
+ }
+ }
+}
+
+// MARK: - NetSocketEncodable
+
+/// Protocol for types that can encode themselves to binary data
+///
+/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over
+/// a socket. Unlike writing field-by-field to the socket, encodable types build complete
+/// binary messages that are sent in a single write operation for efficiency.
+///
+/// Example:
+/// ```swift
+/// struct MyMessage: NetSocketEncodable {
+/// let id: UInt32
+/// let name: String
+///
+/// func encode(endian: Endian) throws -> Data {
+/// var data = Data()
+/// // Encode fields to data...
+/// return data
+/// }
+/// }
+///
+/// try await socket.send(message)
+/// ```
+public protocol NetSocketEncodable: Sendable {
+ /// Encode this value to binary data
+ ///
+ /// Implementations should build a complete binary message and return it as Data.
+ /// The data will be sent to the socket in a single write operation.
+ ///
+ /// - Parameter endian: Byte order for multi-byte values
+ /// - Returns: Encoded binary data ready to send
+ /// - Throws: Encoding errors
+ func encode(endian: Endian) throws -> Data
+}
+
+/// Protocol for types that can decode themselves directly from a socket stream
+///
+/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket
+/// using async reads. This enables true streaming without buffering entire messages.
+///
+/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails),
+/// the socket will be left with those bytes consumed. In practice, this usually means the
+/// connection should be closed. For most protocols this is acceptable since decode errors
+/// indicate corrupt data or protocol violations.
+///
+/// Example:
+/// ```swift
+/// struct MyMessage: NetSocketDecodable {
+/// let id: UInt32
+/// let name: String
+///
+/// init(from socket: NetSocket, endian: Endian) async throws {
+/// self.id = try await socket.read(UInt32.self, endian: endian)
+/// let nameLen = try await socket.read(UInt16.self, endian: endian)
+/// let nameData = try await socket.readExactly(Int(nameLen))
+/// guard let name = String(data: nameData, encoding: .utf8) else {
+/// throw NetSocketError.decodeFailed(NSError())
+/// }
+/// self.name = name
+/// }
+/// }
+///
+/// let message = try await socket.receive(MyMessage.self)
+/// ```
+public protocol NetSocketDecodable: Sendable {
+ /// Decode a value by reading directly from the socket stream
+ ///
+ /// This initializer should read all necessary fields from the socket using
+ /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc.
+ ///
+ /// The socket handles waiting for data to arrive, so you can read field by field
+ /// without worrying about buffering.
+ ///
+ /// - Parameters:
+ /// - socket: Socket to read from
+ /// - endian: Byte order for multi-byte values
+ /// - Throws: Network errors, insufficient data, or custom decoding errors
+ init(from socket: NetSocket, endian: Endian) async throws
+}
+
+public extension NetSocket {
+ /// Send an encodable value to the socket
+ ///
+ /// The type encodes itself to binary data, which is then sent in a single write operation.
+ ///
+ /// Example:
+ /// ```swift
+ /// struct MyMessage: NetSocketEncodable {
+ /// let id: UInt32
+ /// let name: String
+ ///
+ /// func encode(endian: Endian) throws -> Data {
+ /// var data = Data()
+ /// // Build binary message...
+ /// return data
+ /// }
+ /// }
+ ///
+ /// try await socket.send(message)
+ /// ```
+ ///
+ /// - Parameters:
+ /// - value: Value conforming to NetSocketEncodable
+ /// - endian: Byte order (default: big-endian)
+ /// - Throws: Encoding or network errors
+ func send<T: NetSocketEncodable>(_ value: T, endian: Endian = .big) async throws {
+ let data = try value.encode(endian: endian)
+ try await self.write(data)
+ }
+
+ /// Receive and decode a value directly from the socket stream (no length prefix)
+ ///
+ /// The type reads field-by-field from the socket as needed, enabling true streaming
+ /// without buffering entire messages. Useful for protocols where message size isn't
+ /// known upfront or for progressive decoding.
+ ///
+ /// Example:
+ /// ```swift
+ /// struct ServerEntry: NetSocketDecodable {
+ /// let id: UInt32
+ /// let name: String
+ ///
+ /// init(from socket: NetSocket, endian: Endian) async throws {
+ /// self.id = try await socket.read(UInt32.self, endian: endian)
+ /// // Read variable-length string...
+ /// }
+ /// }
+ ///
+ /// let entry = try await socket.receive(ServerEntry.self)
+ /// ```
+ ///
+ /// - Parameters:
+ /// - type: Type conforming to NetSocketDecodable
+ /// - endian: Byte order (default: big-endian)
+ /// - Returns: Decoded value
+ /// - Throws: Decoding or network errors
+ func receive<T: NetSocketDecodable>(_ type: T.Type, endian: Endian = .big) async throws -> T {
+ return try await T(from: self, endian: endian)
+ }
+}
diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift
new file mode 100644
index 0000000..61383ff
--- /dev/null
+++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift
@@ -0,0 +1,138 @@
+// NetSocket: TransferRateEstimator
+// Dustin Mierau • @mierau
+// MIT License
+
+import Foundation
+
+/// Transfer rate estimator using exponential moving average (EMA)
+///
+/// Tracks transfer speed and estimates time remaining. Designed to smooth out
+/// network jitter and provide stable estimates after collecting enough samples.
+///
+/// Example:
+/// ```swift
+/// var estimator = TransferRateEstimator(total: fileSize)
+///
+/// while transferring {
+/// let chunk = try await receiveData()
+/// let progress = estimator.update(bytes: chunk.count)
+/// print("Speed: \(progress.bytesPerSecond ?? 0) B/s, ETA: \(progress.estimatedTimeRemaining ?? 0)s")
+/// }
+/// ```
+public struct TransferRateEstimator {
+ /// Total bytes to transfer (nil if unknown)
+ public let total: Int?
+
+ /// Exponential moving average of transfer rate (bytes/second)
+ private var emaBytesPerSecond: Double = 0
+
+ /// Smoothing factor for EMA (0 < alpha ≤ 1)
+ /// Higher = more responsive to recent changes, lower = more smoothing
+ private let alpha: Double
+
+ /// Number of samples collected
+ private var sampleCount: Int = 0
+
+ /// Timestamp of first sample (for elapsed time calculation)
+ private var startTime: ContinuousClock.Instant?
+
+ /// Timestamp of last update (for calculating sample duration)
+ private var lastUpdateTime: ContinuousClock.Instant?
+
+ /// Minimum elapsed time before trusting estimates (seconds)
+ private let minElapsedTime: TimeInterval
+
+ /// Minimum number of samples before trusting estimates
+ private let minSamples: Int
+
+ /// Current number of bytes transferred
+ public private(set) var transferred: Int = 0
+
+ /// Create a new transfer rate estimator
+ ///
+ /// - Parameters:
+ /// - total: Total bytes to transfer (nil if unknown)
+ /// - alpha: EMA smoothing factor (default: 0.2). Range: 0.0-1.0
+ /// - minElapsedTime: Minimum elapsed time before estimates are reliable (default: 2.0s)
+ /// - minSamples: Minimum samples before estimates are reliable (default: 4)
+ public init(
+ total: Int? = nil,
+ alpha: Double = 0.2,
+ minElapsedTime: TimeInterval = 2.0,
+ minSamples: Int = 8
+ ) {
+ precondition(alpha > 0 && alpha <= 1, "alpha must be in range (0, 1]")
+ precondition(minSamples >= 0, "minSamples must be >= 0")
+
+ self.total = total
+ self.alpha = alpha
+ self.minElapsedTime = minElapsedTime
+ self.minSamples = minSamples
+ }
+
+ @discardableResult
+ public mutating func update(total: Int) -> NetSocket.FileProgress {
+ return self.update(bytes: max(0, total - self.transferred))
+ }
+
+ /// Update the estimator with a new data sample
+ ///
+ /// Automatically calculates the duration since the last update.
+ ///
+ /// - Parameter bytes: Number of bytes transferred in this sample
+ /// - Returns: Current progress with speed and ETA estimates
+ @discardableResult
+ public mutating func update(bytes: Int) -> NetSocket.FileProgress {
+ let clock = ContinuousClock()
+ let now = clock.now
+
+ // Record start time on first sample
+ if self.startTime == nil {
+ self.startTime = now
+ }
+
+ // Calculate duration since last update
+ let duration = self.lastUpdateTime.map { now - $0 } ?? .zero
+ self.lastUpdateTime = now
+
+ // Update transferred count
+ self.transferred += bytes
+
+ // Calculate instantaneous rate for this sample
+ let seconds: Double = duration / .seconds(1.0)
+ if seconds > 0 {
+ let instantRate = Double(bytes) / seconds
+ self.sampleCount += 1
+
+ // Update EMANetSocket
+ if self.emaBytesPerSecond == 0 {
+ self.emaBytesPerSecond = instantRate
+ } else {
+ self.emaBytesPerSecond += self.alpha * (instantRate - self.emaBytesPerSecond)
+ }
+ }
+
+ // Determine if we have enough data to trust the estimate
+ let elapsed = self.startTime.map { now - $0 } ?? .zero
+ let elapsedSeconds: Double = elapsed / .seconds(1.0)
+ let haveEstimate = (elapsedSeconds >= self.minElapsedTime || self.sampleCount >= self.minSamples) && self.emaBytesPerSecond > 0
+
+ // Calculate ETA if we have both an estimate and a known total
+ let eta: TimeInterval?
+ if haveEstimate, let total = self.total {
+ let remaining = total - self.transferred
+ eta = remaining > 0 ? TimeInterval(Double(remaining) / self.emaBytesPerSecond) : 0
+ } else {
+ eta = nil
+ }
+
+ return NetSocket.FileProgress(
+ sent: self.transferred,
+ total: self.total,
+ now: bytes,
+ bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil,
+ estimatedTimeRemaining: eta
+ )
+ }
+}
+
diff --git a/Hotline/Utility/RegularExpressions.swift b/Hotline/Library/RegularExpressions.swift
index db705ae..db705ae 100644
--- a/Hotline/Utility/RegularExpressions.swift
+++ b/Hotline/Library/RegularExpressions.swift
diff --git a/Hotline/Utility/DAKeychain.swift b/Hotline/Library/Utility/DAKeychain.swift
index aa71508..aa71508 100644
--- a/Hotline/Utility/DAKeychain.swift
+++ b/Hotline/Library/Utility/DAKeychain.swift
diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Library/Utility/NSWindowBridge.swift
index ad56105..181a035 100644
--- a/Hotline/Utility/NSWindowBridge.swift
+++ b/Hotline/Library/Utility/NSWindowBridge.swift
@@ -23,3 +23,24 @@ public struct NSWindowAccessor: NSViewRepresentable {
public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView(configCode) }
public func updateNSView(_ nsView: NSView, context: Context) {}
}
+
+
+//import SwiftUI
+
+ /// A helper view you can embed once per window to run a closure
+/// with the underlying NSWindow reference.
+struct WindowConfigurator: NSViewRepresentable {
+ let configure: (NSWindow) -> Void
+
+ func makeNSView(context: Context) -> NSView {
+ let view = NSView()
+ DispatchQueue.main.async {
+ if let window = view.window {
+ configure(window)
+ }
+ }
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) { }
+}
diff --git a/Hotline/Utility/ObservableScrollView.swift b/Hotline/Library/Utility/ObservableScrollView.swift
index 05688f1..05688f1 100644
--- a/Hotline/Utility/ObservableScrollView.swift
+++ b/Hotline/Library/Utility/ObservableScrollView.swift
diff --git a/Hotline/Utility/TextDocument.swift b/Hotline/Library/Utility/TextDocument.swift
index a3591fd..a3591fd 100644
--- a/Hotline/Utility/TextDocument.swift
+++ b/Hotline/Library/Utility/TextDocument.swift
diff --git a/Hotline/Shared/AsyncLinkPreview.swift b/Hotline/Library/Views/AsyncLinkPreview.swift
index 6f32abb..6f32abb 100644
--- a/Hotline/Shared/AsyncLinkPreview.swift
+++ b/Hotline/Library/Views/AsyncLinkPreview.swift
diff --git a/Hotline/Shared/TextView.swift b/Hotline/Library/Views/BetterTextEditor.swift
index 6361823..6361823 100644
--- a/Hotline/Shared/TextView.swift
+++ b/Hotline/Library/Views/BetterTextEditor.swift
diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift
new file mode 100644
index 0000000..10692e6
--- /dev/null
+++ b/Hotline/Library/Views/FileIconView.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct FolderIconView: View {
+ private var folderIcon: Image {
+#if os(iOS)
+ return Image(systemName: "folder.fill")
+#elseif os(macOS)
+ return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder))
+#endif
+ }
+
+ var body: some View {
+ self.folderIcon
+ .resizable()
+ .scaledToFit()
+ }
+}
+
+struct FileIconView: View {
+ let filename: String
+ let fileType: String?
+
+ #if os(iOS)
+ private var fileIcon: Image {
+ let fileExtension = (self.filename as NSString).pathExtension
+ if let fileType = UTType(filenameExtension: fileExtension) {
+ if fileType.isSubtype(of: .movie) {
+ return Image(systemName: "play.rectangle")
+ }
+ else if fileType.isSubtype(of: .image) {
+ return Image(systemName: "photo")
+ }
+ else if fileType.isSubtype(of: .archive) {
+ return Image(systemName: "doc.zipper")
+ }
+ else if fileType.isSubtype(of: .text) {
+ return Image(systemName: "doc.text")
+ }
+ else {
+ return Image(systemName: "doc")
+ }
+
+ return Image(systemName: "doc")
+ }
+ #elseif os(macOS)
+ private var fileIcon: Image {
+ let fileExtension = (self.filename as NSString).pathExtension
+
+ if !fileExtension.isEmpty,
+ let uttype = UTType(filenameExtension: fileExtension) {
+ return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
+ }
+ else if let fileType = self.fileType,
+ let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()],
+ let uttype = UTType(filenameExtension: fileTypeExtension) {
+ return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
+ }
+ else {
+ return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data))
+ }
+ }
+ #endif
+
+ var body: some View {
+ self.fileIcon
+ .resizable()
+ .scaledToFit()
+ }
+}
diff --git a/Hotline/Shared/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift
index 141a71b..141a71b 100644
--- a/Hotline/Shared/FileImageView.swift
+++ b/Hotline/Library/Views/FileImageView.swift
diff --git a/Hotline/Library/Views/GroupedIconView.swift b/Hotline/Library/Views/GroupedIconView.swift
new file mode 100644
index 0000000..313fac6
--- /dev/null
+++ b/Hotline/Library/Views/GroupedIconView.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct GroupedIconView: View {
+ let color: Color
+ let systemName: String
+ var padding: CGFloat = 6.0
+ var cornerRadius: CGFloat = 8.0
+
+ var body: some View {
+ RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
+ .fill(LinearGradient(colors: [self.color.mix(with: .white, by: 0.2), self.color], startPoint: .top, endPoint: .bottom))
+ .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 1, x: 0, y: 1)
+ .overlay {
+ Image(systemName: self.systemName)
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .white.opacity(0.4))
+ .padding(self.padding)
+ .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 0, x: 0, y: -1)
+ }
+ }
+}
diff --git a/Hotline/Shared/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift
index 6376a07..137bcbb 100644
--- a/Hotline/Shared/HotlinePanel.swift
+++ b/Hotline/Library/Views/HotlinePanel.swift
@@ -12,13 +12,13 @@ class HotlinePanel: NSPanel {
backing: .buffered, defer: false)
// Make sure that the panel is in front of almost all other windows
- self.isFloatingPanel = false
+ self.isFloatingPanel = true
self.level = .floating
self.hidesOnDeactivate = true
self.animationBehavior = .utilityWindow
-
- // Allow the panel to appear in a fullscreen space
- // self.collectionBehavior.insert(.fullScreenAuxiliary)
+
+ // Allow the panelto appear in a fullscreen space
+// self.collectionBehavior.insert(.fullScreenAuxiliary)
self.collectionBehavior.insert(.canJoinAllSpaces)
self.collectionBehavior.insert(.ignoresCycle)
@@ -27,7 +27,10 @@ class HotlinePanel: NSPanel {
// Don't delete panel state when it's closed.
self.isReleasedWhenClosed = false
- self.standardWindowButton(.closeButton)?.isHidden = true
+ // Disable state restoration for this utility panel
+ self.isRestorable = false
+
+ self.standardWindowButton(.closeButton)?.isHidden = false
self.standardWindowButton(.zoomButton)?.isHidden = true
self.standardWindowButton(.miniaturizeButton)?.isHidden = true
diff --git a/Hotline/Library/Views/QuickLookPreviewView.swift b/Hotline/Library/Views/QuickLookPreviewView.swift
new file mode 100644
index 0000000..6ba154e
--- /dev/null
+++ b/Hotline/Library/Views/QuickLookPreviewView.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+import Quartz
+
+/// Embeddable QuickLook preview view for macOS
+///
+/// This view uses QLPreviewView to display file previews inline, without showing a modal.
+/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.)
+struct QuickLookPreviewView: NSViewRepresentable {
+ let fileURL: URL
+
+ func makeNSView(context: Context) -> QLPreviewView {
+ let preview = QLPreviewView(frame: .zero, style: .normal)!
+ preview.autostarts = true
+ preview.shouldCloseWithWindow = true
+ preview.previewItem = fileURL as QLPreviewItem
+ return preview
+ }
+
+ func updateNSView(_ nsView: QLPreviewView, context: Context) {
+ nsView.previewItem = fileURL as QLPreviewItem
+ }
+}
diff --git a/Hotline/Library/Views/SpinningGlobeView.swift b/Hotline/Library/Views/SpinningGlobeView.swift
new file mode 100644
index 0000000..bccb969
--- /dev/null
+++ b/Hotline/Library/Views/SpinningGlobeView.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+
+/// An animated globe icon that cycles through different world regions
+///
+/// Displays a spinning globe effect by cycling through SF Symbol images showing
+/// different parts of the world. Useful for indicating network activity or global content.
+///
+/// Example:
+/// ```swift
+/// SpinningGlobeView()
+/// .frame(width: 16, height: 16)
+///
+/// SpinningGlobeView(frameDelay: 0.5)
+/// .frame(width: 24, height: 24)
+/// ```
+struct SpinningGlobeView: View {
+ /// Delay between frames in seconds (default: 0.3)
+ let frameDelay: TimeInterval
+
+ /// SF Symbol names for each frame of the globe animation
+ private let globeFrames = [
+ "globe.americas.fill",
+ "globe.europe.africa.fill",
+ "globe.central.south.asia.fill",
+ "globe.asia.australia.fill"
+ ]
+
+ @State private var currentFrameIndex = 0
+ @State private var animationTask: Task<Void, Never>?
+
+ init(frameDelay: TimeInterval = 0.25) {
+ self.frameDelay = frameDelay
+ }
+
+ var body: some View {
+ Image(systemName: globeFrames[currentFrameIndex])
+ .onAppear {
+ startAnimation()
+ }
+ .onDisappear {
+ stopAnimation()
+ }
+ }
+
+ private func startAnimation() {
+ animationTask = Task {
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000))
+
+ guard !Task.isCancelled else { break }
+
+ currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count
+ }
+ }
+ }
+
+ private func stopAnimation() {
+ animationTask?.cancel()
+ animationTask = nil
+ }
+}
+
+#Preview {
+ VStack(spacing: 20) {
+ HStack(spacing: 20) {
+ SpinningGlobeView()
+ .frame(width: 12, height: 12)
+
+ SpinningGlobeView()
+ .frame(width: 16, height: 16)
+
+ SpinningGlobeView()
+ .frame(width: 24, height: 24)
+ }
+
+ Text("Different frame delays:")
+
+ HStack(spacing: 20) {
+ SpinningGlobeView(frameDelay: 0.1)
+ .frame(width: 16, height: 16)
+
+ SpinningGlobeView(frameDelay: 0.3)
+ .frame(width: 16, height: 16)
+
+ SpinningGlobeView(frameDelay: 0.6)
+ .frame(width: 16, height: 16)
+ }
+ }
+ .padding()
+}
diff --git a/Hotline/Shared/VisualEffectView.swift b/Hotline/Library/Views/VisualEffectView.swift
index ec64111..ec64111 100644
--- a/Hotline/Shared/VisualEffectView.swift
+++ b/Hotline/Library/Views/VisualEffectView.swift
diff --git a/Hotline/Application-macOS.swift b/Hotline/MacApp.swift
index fe9d848..454e9fd 100644
--- a/Hotline/Application-macOS.swift
+++ b/Hotline/MacApp.swift
@@ -2,6 +2,7 @@ import CloudKit
import SwiftData
import SwiftUI
import UniformTypeIdentifiers
+import Darwin
@Observable
final class AppLaunchState {
@@ -29,7 +30,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// We mark CloudKit has available now since we're not waiting on
// a server sync or anything.
- ApplicationState.shared.cloudKitReady = true
+ AppState.shared.cloudKitReady = true
default:
print("iCloud Available")
@@ -38,13 +39,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
queue: OperationQueue.main
) { [weak self] note in
print("iCloud Changed!")
- ApplicationState.shared.cloudKitReady = true
-
+ AppState.shared.cloudKitReady = true
+
guard let token = self?.cloudKitObserverToken else { return }
NotificationCenter.default.removeObserver(token)
}
}
}
+
+ Task {
+ await AppUpdate.shared.checkForUpdatesOnLaunch()
+ }
}
func applicationWillTerminate(_ notification: Notification) {
@@ -61,20 +66,33 @@ struct Application: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var hotlinePanel: HotlinePanel? = nil
- @State private var selection: Bookmark? = nil
+ @State private var selection: TrackerSelection? = nil
+ @Bindable private var update = AppUpdate.shared
- @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline?
+ @FocusedValue(\.activeHotlineModel) private var activeHotline: HotlineState?
@FocusedValue(\.activeServerState) private var activeServerState: ServerState?
private var modelContainer: ModelContainer = {
let schema = Schema([
- Bookmark.self
+ Bookmark.self,
+ // ChatMessage.self
])
- let config = ModelConfiguration(
- schema: schema, isStoredInMemoryOnly: false,
- cloudKitDatabase: .private("iCloud.pizza.unlimited.hotline"))
- let modelContainer = try! ModelContainer(for: schema, configurations: [config])
-
+
+ // For records we want shared across devices.
+ let cloudKitConfiguration = ModelConfiguration(
+ schema: Schema([Bookmark.self]),
+ isStoredInMemoryOnly: false,
+ cloudKitDatabase: .private("iCloud.co.goodmake.hotline")
+ )
+
+ // For records we only need stored locally.
+// let localConfiguration = ModelConfiguration(
+// schema: Schema([ChatMessage.self]),
+// isStoredInMemoryOnly: false
+// )
+
+ let modelContainer = try! ModelContainer(for: schema, configurations: [cloudKitConfiguration])
+
// Print local SwiftData sqlite file.
// print(modelContainer.configurations.first?.url.path(percentEncoded: false))
@@ -94,19 +112,47 @@ struct Application: App {
.onChange(of: AppLaunchState.shared.launchState) {
if AppLaunchState.shared.launchState == .launched {
if Prefs.shared.showBannerToolbar {
- showBannerWindow()
+ self.showBannerWindow()
}
}
}
-
+ .onChange(of: self.update.showWindow) {
+ if self.update.showWindow {
+ self.openWindow(id: "update")
+ }
+ }
+
// MARK: About Box
Window("About", id: "about") {
AboutView()
- .ignoresSafeArea()
- .background(Color.hotlineRed)
+ .background(Color.hotlineRed, ignoresSafeAreaEdges: .all)
+ .windowFullScreenBehavior(.disabled)
+ .toolbar(removing: .title)
+ .gesture(WindowDragGesture())
+ .background(
+ WindowConfigurator { window in
+ window.titlebarAppearsTransparent = true
+ window.titlebarSeparatorStyle = .none
+ window.isMovableByWindowBackground = true
+
+ if let closeButton = window.standardWindowButton(.closeButton) {
+ closeButton.isHidden = false // make sure it’s visible
+ closeButton.isEnabled = true
+ }
+
+ if let btn = window.standardWindowButton(.zoomButton) {
+ btn.isHidden = true
+ }
+
+ if let btn = window.standardWindowButton(.miniaturizeButton) {
+ btn.isHidden = true
+ }
+ }
+ )
}
.windowResizability(.contentSize)
.windowStyle(.hiddenTitleBar)
+ .restorationBehavior(.disabled)
.defaultPosition(.center)
.commandsRemoved() // Remove About that was automatically added to Window menu.
.commands {
@@ -114,9 +160,26 @@ struct Application: App {
Button("About Hotline") {
openWindow(id: "about")
}
+
+ Button("Check for Updates...") {
+ Task {
+ await AppUpdate.shared.checkForUpdatesManually()
+ }
+ }
}
}
-
+
+ // MARK: Update Window
+ Window("New Update", id: "update") {
+ AppUpdateView()
+ .windowFullScreenBehavior(.disabled)
+ }
+ .windowResizability(.contentSize)
+ .windowStyle(.hiddenTitleBar)
+ .restorationBehavior(.disabled)
+ .defaultPosition(.center)
+ .commandsRemoved()
+
// MARK: Server Window
WindowGroup(id: "server", for: Server.self) { server in
ServerView(server: server)
@@ -124,62 +187,46 @@ struct Application: App {
} defaultValue: {
Server(name: nil, description: nil, address: "")
}
- .modelContainer(self.modelContainer)
- .defaultSize(width: 750, height: 700)
+ .windowToolbarStyle(.unified)
+// .windowStyle(.hiddenTitleBar)
+ .defaultSize(width: 780, height: 640)
.defaultPosition(.center)
+ .modelContainer(self.modelContainer)
.onChange(of: activeServerState) {
- ApplicationState.shared.activeServerState = activeServerState
- }
- .onChange(of: activeHotline) {
- ApplicationState.shared.activeHotline = activeHotline
- }
- .onChange(of: activeHotline?.serverTitle) {
- if let hotline = activeHotline {
- ApplicationState.shared.activeServerName = hotline.serverTitle
- }
- }
- .onChange(of: activeHotline?.bannerImage) {
- withAnimation {
- ApplicationState.shared.activeServerBanner = activeHotline?.bannerImage
- }
+ AppState.shared.activeServerState = self.activeServerState
}
.onChange(of: activeHotline) {
- ApplicationState.shared.activeHotline = activeHotline
- if let hotline = activeHotline {
- ApplicationState.shared.activeServerName = hotline.serverTitle
- }
+ AppState.shared.activeHotline = self.activeHotline
}
.commands {
CommandGroup(replacing: .newItem) {
Button("Connect to Server...") {
- openWindow(id: "server")
+ self.openWindow(id: "server")
}
.keyboardShortcut(.init("K"), modifiers: .command)
}
- CommandGroup(after: .singleWindowList) {
+ CommandGroup(before: .singleWindowList) {
Button("Toolbar") {
- toggleBannerWindow()
+ self.toggleBannerWindow()
}
.keyboardShortcut(.init("\\"), modifiers: [.shift, .command])
}
CommandGroup(after: .help) {
Divider()
Button("Request Feature...") {
- if let url = URL(
- string: "https://github.com/mierau/hotline/issues/new?labels=enhancement")
- {
- openURL(url)
+ if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") {
+ self.openURL(url)
}
}
Button("Report Bug...") {
if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") {
- openURL(url)
+ self.openURL(url)
}
}
Divider()
- Button("Download Latest...") {
+ Button("Open Latest Release Page...") {
if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") {
- openURL(url)
+ self.openURL(url)
}
}
}
@@ -188,49 +235,56 @@ struct Application: App {
guard let selection else {
return
}
- connect(to: selection)
+ self.connect(to: selection)
}
.disabled(selection == nil || selection?.server == nil)
.keyboardShortcut(.downArrow, modifiers: .command)
Button("Disconnect") {
- activeHotline?.disconnect()
+ if let hotline = activeHotline {
+ Task {
+ await hotline.disconnect()
+ }
+ }
}
.disabled(activeHotline?.status == .disconnected)
+
Divider()
+
Button("Broadcast Message...") {
- // TODO: Implement broadcast message when user is allowed.
+ activeServerState?.broadcastShown = true
}
- .disabled(true)
+ .disabled(activeHotline?.access?.contains(.canBroadcast) != true)
.keyboardShortcut(.init("B"), modifiers: .command)
+
Divider()
- Button("Show Chat") {
+
+ Button("Chat") {
activeServerState?.selection = .chat
}
.disabled(activeHotline?.status != .loggedIn)
.keyboardShortcut(.init("1"), modifiers: .command)
- Button("Show News") {
- activeServerState?.selection = .news
- }
- .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151)
- .keyboardShortcut(.init("2"), modifiers: .command)
- Button("Show Message Board") {
+ Button("Board") {
activeServerState?.selection = .board
}
.disabled(activeHotline?.status != .loggedIn)
+ .keyboardShortcut(.init("2"), modifiers: .command)
+ Button("News") {
+ activeServerState?.selection = .news
+ }
+ .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151)
.keyboardShortcut(.init("3"), modifiers: .command)
- Button("Show Files") {
+ Button("Files") {
activeServerState?.selection = .files
}
.disabled(activeHotline?.status != .loggedIn)
.keyboardShortcut(.init("4"), modifiers: .command)
- Button("Show Accounts") {
- activeServerState?.selection = .accounts
+
+ Divider()
+
+ Button("Manage Accounts...") {
+ activeServerState?.accountsShown = true
}
- .disabled(
- activeHotline?.status != .loggedIn
- || activeHotline?.access?.contains(.canOpenUsers) != true
- )
- .keyboardShortcut(.init("5"), modifiers: .command)
+ .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true)
}
}
@@ -239,6 +293,15 @@ struct Application: App {
SettingsView()
}
+ // MARK: Transfers Window
+ Window("Transfers", id: "transfers") {
+ TransfersView()
+ .frame(minWidth: 500, minHeight: 200)
+ }
+ .defaultSize(width: 500, height: 400)
+ .defaultPosition(.topTrailing)
+ .keyboardShortcut(.init("T"), modifiers: [.shift, .command])
+
// MARK: Image Preview Window
WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in
FilePreviewImageView(info: $info)
@@ -248,7 +311,8 @@ struct Application: App {
.windowToolbarStyle(.unifiedCompact(showsTitle: true))
.defaultSize(width: 350, height: 150)
.defaultPosition(.center)
-
+ .restorationBehavior(.disabled)
+
// MARK: Text Preview Window
WindowGroup(id: "preview-text", for: PreviewFileInfo.self) { $info in
FilePreviewTextView(info: $info)
@@ -258,11 +322,24 @@ struct Application: App {
.windowToolbarStyle(.unifiedCompact(showsTitle: true))
.defaultSize(width: 450, height: 550)
.defaultPosition(.center)
+ .restorationBehavior(.disabled)
+
+ // MARK: QuickLook Preview Window
+ WindowGroup(id: "preview-quicklook", for: PreviewFileInfo.self) { $info in
+ FilePreviewQuickLookView(info: $info)
+ }
+ .windowManagerRole(.associated)
+ .windowResizability(.automatic)
+ .windowStyle(.titleBar)
+ .windowToolbarStyle(.unifiedCompact(showsTitle: true))
+ .defaultSize(width: 450, height: 550)
+ .defaultPosition(.center)
+ .restorationBehavior(.disabled)
}
- func connect(to item: Bookmark) {
+ func connect(to item: TrackerSelection) {
if let server = item.server {
- openWindow(id: "server", value: server)
+ self.openWindow(id: "server", value: server)
}
}
@@ -291,3 +368,4 @@ struct Application: App {
}
}
}
+
diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift
new file mode 100644
index 0000000..59c9484
--- /dev/null
+++ b/Hotline/Managers/ChatStore.swift
@@ -0,0 +1,272 @@
+import Foundation
+import CryptoKit
+import Security
+
+actor ChatStore {
+ static let shared = ChatStore()
+ static let historyClearedNotification = Notification.Name("ChatStoreHistoryCleared")
+
+ struct SessionKey: Hashable {
+ let address: String
+ let port: Int
+
+ var identifier: String { "\(address):\(port)" }
+ }
+
+ struct Metadata: Codable {
+ let address: String
+ let port: Int
+ var serverName: String?
+ var createdAt: Date
+ var updatedAt: Date
+
+ mutating func update(serverName: String?, timestamp: Date) {
+ if let serverName, !serverName.isEmpty {
+ self.serverName = serverName
+ }
+ self.updatedAt = timestamp
+ }
+ }
+
+ struct EntryMetadata: Codable {
+ var images: [ImageMetadata]?
+
+ struct ImageMetadata: Codable {
+ let url: String
+ let width: CGFloat?
+ let height: CGFloat?
+ }
+ }
+
+ struct Entry: Codable {
+ let id: UUID
+ let body: String
+ let username: String?
+ let type: String
+ let date: Date
+ var metadata: EntryMetadata?
+ }
+
+ struct LoadResult {
+ let entries: [Entry]
+ let metadata: Metadata?
+ }
+
+ private struct LogFile: Codable {
+ var metadata: Metadata
+ var entries: [Entry]
+ }
+
+ private enum StoreError: Error {
+ case encryptionFailed
+ case invalidCombinedCiphertext
+ case keyGenerationFailed
+ }
+
+ private let keychainKey = "chatlog-encryption-key"
+ private let applicationFolderName = "Hotline"
+ private let logsFolderName = "ChatLogs"
+ private let fileExtension = "hlchat"
+ private let maxEntries = 2000
+
+ private var cache: [SessionKey: LogFile] = [:]
+ private var cachedDirectory: URL?
+ private var cachedKey: SymmetricKey?
+
+ func append(entry: Entry, for key: SessionKey, serverName: String?) async {
+ do {
+ var logFile = try loadLogFile(for: key) ?? newLogFile(for: key, serverName: serverName)
+
+ logFile.entries.append(entry)
+ if logFile.entries.count > maxEntries {
+ logFile.entries = Array(logFile.entries.suffix(maxEntries))
+ }
+
+ logFile.metadata.update(serverName: serverName, timestamp: entry.date)
+ cache[key] = logFile
+
+ try persist(logFile, for: key)
+ }
+ catch {
+ print("ChatStore: failed to append entry —", error)
+ }
+ }
+
+ func updateMetadata(_ metadata: EntryMetadata, for entryID: UUID, key: SessionKey) async {
+ do {
+ guard var logFile = try loadLogFile(for: key) else { return }
+
+ if let index = logFile.entries.firstIndex(where: { $0.id == entryID }) {
+ logFile.entries[index].metadata = metadata
+ cache[key] = logFile
+ try persist(logFile, for: key)
+ }
+ }
+ catch {
+ print("ChatStore: failed to update metadata —", error)
+ }
+ }
+
+ func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult {
+ do {
+ let logFile = try loadLogFile(for: key)
+ guard let logFile else {
+ return LoadResult(entries: [], metadata: nil)
+ }
+
+ let entries: [Entry]
+ if let limit, limit < logFile.entries.count {
+ entries = Array(logFile.entries.suffix(limit))
+ }
+ else {
+ entries = logFile.entries
+ }
+
+ return LoadResult(entries: entries, metadata: logFile.metadata)
+ }
+ catch {
+ print("ChatStore: failed to load history —", error)
+ return LoadResult(entries: [], metadata: nil)
+ }
+ }
+
+ func clearAll() async {
+ let fm = FileManager.default
+ if let dir = try? directoryURL(), fm.fileExists(atPath: dir.path) {
+ do {
+ try fm.removeItem(at: dir)
+ }
+ catch {
+ print("ChatStore: failed to clear chat logs —", error)
+ }
+ }
+
+ cache.removeAll()
+ cachedDirectory = nil
+
+ await MainActor.run {
+ NotificationCenter.default.post(name: Self.historyClearedNotification, object: nil)
+ }
+ }
+
+ static func digest(for string: String) -> String {
+ let hash = SHA256.hash(data: Data(string.utf8))
+ return hash.compactMap { String(format: "%02x", $0) }.joined()
+ }
+
+ private func newLogFile(for key: SessionKey, serverName: String?) -> LogFile {
+ let now = Date()
+ var metadata = Metadata(address: key.address, port: key.port, serverName: nil, createdAt: now, updatedAt: now)
+ metadata.update(serverName: serverName, timestamp: now)
+ let logFile = LogFile(metadata: metadata, entries: [])
+ cache[key] = logFile
+ return logFile
+ }
+
+ private func loadLogFile(for key: SessionKey) throws -> LogFile? {
+ if let cached = cache[key] {
+ return cached
+ }
+
+ let url = try fileURL(for: key)
+ let fm = FileManager.default
+ guard fm.fileExists(atPath: url.path) else {
+ return nil
+ }
+
+ let encryptedData = try Data(contentsOf: url)
+ let decryptedData = try decrypt(encryptedData)
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let logFile = try decoder.decode(LogFile.self, from: decryptedData)
+ cache[key] = logFile
+ return logFile
+ }
+
+ private func persist(_ logFile: LogFile, for key: SessionKey) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode(logFile)
+ let encrypted = try encrypt(data)
+ let url = try fileURL(for: key)
+ let fm = FileManager.default
+ let directory = url.deletingLastPathComponent()
+ if !fm.fileExists(atPath: directory.path) {
+ try fm.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+ try encrypted.write(to: url, options: .atomic)
+ }
+
+ private func directoryURL() throws -> URL {
+ if let cachedDirectory {
+ return cachedDirectory
+ }
+
+ let fm = FileManager.default
+ guard let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ throw StoreError.keyGenerationFailed
+ }
+
+ let appDirectory = base.appendingPathComponent(applicationFolderName, isDirectory: true)
+ let logsDirectory = appDirectory.appendingPathComponent(logsFolderName, isDirectory: true)
+
+ if !fm.fileExists(atPath: appDirectory.path) {
+ try fm.createDirectory(at: appDirectory, withIntermediateDirectories: true)
+ }
+ if !fm.fileExists(atPath: logsDirectory.path) {
+ try fm.createDirectory(at: logsDirectory, withIntermediateDirectories: true)
+ }
+
+ cachedDirectory = logsDirectory
+ return logsDirectory
+ }
+
+ private func fileURL(for key: SessionKey) throws -> URL {
+ let directory = try directoryURL()
+ let digest = Self.digest(for: key.identifier)
+ return directory.appendingPathComponent(digest).appendingPathExtension(fileExtension)
+ }
+
+ private func encrypt(_ data: Data) throws -> Data {
+ let key = try symmetricKey()
+ let sealedBox = try AES.GCM.seal(data, using: key)
+ guard let combined = sealedBox.combined else {
+ throw StoreError.encryptionFailed
+ }
+ return combined
+ }
+
+ private func decrypt(_ data: Data) throws -> Data {
+ let key = try symmetricKey()
+ let sealedBox = try AES.GCM.SealedBox(combined: data)
+ return try AES.GCM.open(sealedBox, using: key)
+ }
+
+ private func symmetricKey() throws -> SymmetricKey {
+ if let cachedKey {
+ return cachedKey
+ }
+
+ if let stored = DAKeychain.shared[keychainKey],
+ let storedData = Data(base64Encoded: stored),
+ storedData.count == 32 {
+ let key = SymmetricKey(data: storedData)
+ cachedKey = key
+ return key
+ }
+
+ var bytes = [UInt8](repeating: 0, count: 32)
+ let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ guard status == errSecSuccess else {
+ throw StoreError.keyGenerationFailed
+ }
+
+ let data = Data(bytes)
+ let key = SymmetricKey(data: data)
+ DAKeychain.shared[keychainKey] = data.base64EncodedString()
+ cachedKey = key
+ return key
+ }
+}
diff --git a/Hotline/Managers/SoundEffects.swift b/Hotline/Managers/SoundEffects.swift
new file mode 100644
index 0000000..85a1c0e
--- /dev/null
+++ b/Hotline/Managers/SoundEffects.swift
@@ -0,0 +1,40 @@
+import Foundation
+import AppKit
+
+enum SoundEffect: String {
+ case loggedIn = "logged-in"
+ case chatMessage = "chat-message"
+ case transferComplete = "transfer-complete"
+ case userLogin = "user-login"
+ case userLogout = "user-logout"
+ case newNews = "new-news"
+ case serverMessage = "server-message"
+ case error = "error"
+
+ static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error]
+}
+
+class SoundEffects {
+ static let shared = SoundEffects()
+
+ static func play(_ name: SoundEffect) {
+ Self.shared.play(name)
+ }
+
+ private var preloadedSounds: [SoundEffect: NSSound] = [:]
+
+ private init() {
+ // Preload sound effects
+ for effect in SoundEffect.all {
+ if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"),
+ let sound = NSSound(contentsOf: soundFileURL, byReference: true) {
+ sound.volume = 0.75
+ self.preloadedSounds[effect] = sound
+ }
+ }
+ }
+
+ func play(_ name: SoundEffect) {
+ self.preloadedSounds[name]?.play()
+ }
+}
diff --git a/Hotline/Models/ApplicationState.swift b/Hotline/Models/ApplicationState.swift
deleted file mode 100644
index 3e90ed9..0000000
--- a/Hotline/Models/ApplicationState.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-import SwiftUI
-
-@Observable
-final class ApplicationState {
- static let shared = ApplicationState()
-
- var activeHotline: Hotline? = nil
- var activeServerState: ServerState? = nil
-
- // Frontmost server window information
- var activeServerID: UUID? = nil
- var activeServerBanner: NSImage? = nil
- var activeServerName: String? = nil
-
- var cloudKitReady: Bool = false
-}
diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift
index 5afc2c0..4c2a28b 100644
--- a/Hotline/Models/Bookmark.swift
+++ b/Hotline/Models/Bookmark.swift
@@ -4,7 +4,27 @@ import SwiftData
enum BookmarkType: String, Codable {
case tracker = "tracker"
case server = "server"
- case temporary = "temporary"
+}
+
+struct BookmarkServer: Hashable, Identifiable {
+ let id = UUID()
+ let name: String?
+ let address: String
+ let port: Int
+ let description: String?
+ let users: Int
+
+ var server: Server {
+ Server(name: self.name, description: self.description, address: self.address, port: self.port, users: self.users)
+ }
+
+ init(server: Server) {
+ self.name = server.name
+ self.address = server.address
+ self.port = server.port
+ self.description = server.description
+ self.users = server.users
+ }
}
@Model
@@ -25,25 +45,6 @@ final class Bookmark {
@Attribute
var autoconnect: Bool = false
- @Attribute(.ephemeral)
- var expanded: Bool = false
-
- @Attribute(.ephemeral)
- var loading: Bool = false
-
- @Attribute(.ephemeral)
- var serverDescription: String? = nil
-
- @Attribute(.ephemeral)
- var serverUserCount: Int? = nil
-
- @Transient
- var servers: [Bookmark] = []
-
- func hash(into hasher: inout Hasher) {
-
- }
-
@Transient
var displayAddress: String {
switch self.type {
@@ -54,7 +55,7 @@ final class Bookmark {
return "\(self.address):\(String(self.port))"
}
- case .server, .temporary:
+ case .server:
if self.port == HotlinePorts.DefaultServerPort {
return self.address
} else {
@@ -69,23 +70,15 @@ final class Bookmark {
case .tracker:
return nil
- case .server, .temporary:
- return Server(
- name: self.name, description: self.serverDescription, address: self.address,
- port: self.port, login: self.login, password: self.password, autoconnect: self.autoconnect)
+ case .server:
+ return Server(name: self.name, description: nil, address: self.address, port: self.port, login: self.login, password: self.password)
}
}
static let DefaultBookmarks: [Bookmark] = [
- Bookmark(
- type: .server, name: "The Mobius Strip", address: "67.174.208.111",
- port: HotlinePorts.DefaultServerPort),
- Bookmark(
- type: .server, name: "System 7 Today", address: "hotline.system7today.com",
- port: HotlinePorts.DefaultServerPort),
- Bookmark(
- type: .tracker, name: "Featured Servers", address: "hltracker.com",
- port: HotlinePorts.DefaultTrackerPort),
+ Bookmark(type: .server, name: "The Mobius Strip", address: "mobius.trtphotl.com", port: HotlinePorts.DefaultServerPort),
+ Bookmark(type: .server, name: "MacDomain", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort),
+ Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort)
]
init(
@@ -103,17 +96,6 @@ final class Bookmark {
self.autoconnect = autoconnect
}
- init(temporaryServer server: Server) {
- self.type = .temporary
-
- self.name = server.name ?? server.address
- self.address = server.address
- self.port = server.port
-
- self.serverDescription = server.description
- self.serverUserCount = server.users
- }
-
init?(fileData: Data, name: String? = nil) {
guard fileData.count <= 2000 else {
return nil
@@ -264,11 +246,6 @@ final class Bookmark {
}
static func add(_ bookmark: Bookmark, context: ModelContext) {
- guard bookmark.type != .temporary else {
- print("Bookmark: Attempting to add temporary bookmark to store. Aborting.")
- return
- }
-
let existingBookmarks = Bookmark.fetchAll(context: context)
// Reindex bookmarks before insert.
@@ -361,38 +338,27 @@ final class Bookmark {
print("Bookmark: Failed to save bookmark reordering")
}
}
-
- func fetchServers() async {
+
+ func fetchServers() async -> [BookmarkServer] {
guard self.type == .tracker else {
- // self.loading = false
- return
- }
-
- DispatchQueue.main.sync {
- self.loading = true
+ return []
}
- var fetchedBookmarks: [Bookmark] = []
+ var fetchedBookmarks: [BookmarkServer] = []
let client = HotlineTrackerClient()
- if let fetchedServers: [HotlineServer] = try? await client.fetchServers(
- address: self.address, port: self.port)
- {
- for fetchedServer in fetchedServers {
+ do {
+ for try await fetchedServer in client.fetchServers(address: self.address, port: self.port) {
+ print("FETCHED SERVER", fetchedServer)
if let serverName = fetchedServer.name {
- let server = Server(
- name: serverName, description: fetchedServer.description,
- address: fetchedServer.address, port: Int(fetchedServer.port),
- users: Int(fetchedServer.users))
- fetchedBookmarks.append(Bookmark(temporaryServer: server))
+ let server = Server(name: serverName, description: fetchedServer.description, address: fetchedServer.address, port: Int(fetchedServer.port), users: Int(fetchedServer.users))
+ fetchedBookmarks.append(BookmarkServer(server: server))
}
}
+ } catch {
+ print("Failed to fetch servers from tracker: \(error)")
}
- let newServers = fetchedBookmarks
- DispatchQueue.main.sync {
- self.servers = newServers
- self.loading = false
- }
+ return fetchedBookmarks
}
}
diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift
index 36648d1..f7383ba 100644
--- a/Hotline/Models/ChatMessage.swift
+++ b/Hotline/Models/ChatMessage.swift
@@ -2,28 +2,71 @@ import SwiftUI
enum ChatMessageType {
case agreement
- case status
+ case joined
+ case left
case message
case server
+ case signOut
+}
+
+extension ChatMessageType {
+ var storageKey: String {
+ switch self {
+ case .agreement:
+ return "agreement"
+ case .joined:
+ return "joined"
+ case .left:
+ return "left"
+ case .message:
+ return "message"
+ case .server:
+ return "server"
+ case .signOut:
+ return "signOut"
+ }
+ }
+
+ init?(storageKey: String) {
+ switch storageKey {
+ case "agreement":
+ self = .agreement
+ case "joined":
+ self = .joined
+ case "left":
+ self = .left
+ case "message":
+ self = .message
+ case "server":
+ self = .server
+ case "signOut":
+ self = .signOut
+ default:
+ return nil
+ }
+ }
}
struct ChatMessage: Identifiable {
- let id = UUID()
+ let id: UUID
let text: String
let type: ChatMessageType
let date: Date
let username: String?
-
+ var metadata: ChatStore.EntryMetadata?
+
static let parser = /^\s*([^\:]+):\s*([\s\S]+)$/
init(text: String, type: ChatMessageType, date: Date) {
+ self.id = UUID()
self.type = type
self.date = date
+ self.metadata = nil
- if type == .message,
- let match = text.firstMatch(of: ChatMessage.parser)
- {
+ if
+ type == .message,
+ let match = text.firstMatch(of: ChatMessage.parser) {
self.username = String(match.1)
self.text = String(match.2)
} else {
diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift
index dc302e8..71ddb6d 100644
--- a/Hotline/Models/FileDetails.swift
+++ b/Hotline/Models/FileDetails.swift
@@ -1,7 +1,7 @@
import UniformTypeIdentifiers
-struct FileDetails: Identifiable {
- let id = UUID()
+public struct FileDetails: Identifiable {
+ public let id: UUID = UUID()
var name: String
var path: [String]
var size: Int
diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift
index 0eadd10..272dd7b 100644
--- a/Hotline/Models/FileInfo.swift
+++ b/Hotline/Models/FileInfo.swift
@@ -15,28 +15,66 @@ import UniformTypeIdentifiers
let isUnavailable: Bool
var isDropboxFolder: Bool {
- guard self.isFolder,
- (self.name.range(of: "upload", options: [.caseInsensitive]) != nil)
- || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil)
- else {
+ guard self.isFolder else {
return false
}
- return true
+
+ if self.name.range(of: "upload", options: [.caseInsensitive]) != nil {
+ return true
+ }
+
+ if self.name.range(of: "dropbox", options: [.caseInsensitive]) != nil {
+ return true
+ }
+
+ if self.name.range(of: "drop box", options: [.caseInsensitive]) != nil {
+ return true
+ }
+
+ return false
}
var isAdminDropboxFolder: Bool {
self.isDropboxFolder && (self.name.range(of: "admin", options: [.caseInsensitive]) != nil)
}
-
+
+ var isAppBundle: Bool {
+ guard self.isFolder else {
+ return false
+ }
+ return self.name.lowercased().hasSuffix(".app")
+ }
+
var expanded: Bool = false
var children: [FileInfo]? = nil
var isPreviewable: Bool {
- let fileExtension = (self.name as NSString).pathExtension
+ var fileExtension = (self.name as NSString).pathExtension.lowercased()
+ if fileExtension.isEmpty && !self.type.isEmpty {
+ let type = self.type.lowercased()
+ if let ext = FileManager.HFSTypeToExtension[type] {
+ fileExtension = ext
+ }
+ }
+
if let fileType = UTType(filenameExtension: fileExtension) {
+ if fileType.canBePreviewedByQuickLook {
+ return true
+ }
+
if fileType.isSubtype(of: .image) {
return true
- } else if fileType.isSubtype(of: .text) {
+ }
+ else if fileType.isSubtype(of: .pdf) || fileExtension == "pdf" {
+ return true
+ }
+ else if fileType.isSubtype(of: .audio) {
+ return true
+ }
+ else if fileType.isSubtype(of: .video) {
+ return true
+ }
+ else if fileType.isSubtype(of: .text) {
return true
}
}
diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift
index bba29fa..a142823 100644
--- a/Hotline/Models/FilePreview.swift
+++ b/Hotline/Models/FilePreview.swift
@@ -1,118 +1,115 @@
import SwiftUI
import UniformTypeIdentifiers
-
-enum FilePreviewState: Equatable {
- case unloaded
- case loading
- case loaded
- case failed
-}
-
-enum FilePreviewType: Equatable {
- case unknown
- case image
- case text
-}
-
-@Observable
-final class FilePreview: HotlineFilePreviewClientDelegate {
- @ObservationIgnored let info: PreviewFileInfo
- @ObservationIgnored var client: HotlineFilePreviewClient? = nil
-
- var state: FilePreviewState = .unloaded
- var progress: Double = 0.0
-
- var data: Data? = nil
-
- #if os(iOS)
- var image: UIImage? = nil
- #elseif os(macOS)
- var image: NSImage? = nil
- #endif
-
- var text: String? = nil
- var styledText: NSAttributedString? = nil
-
- var previewType: FilePreviewType {
- let fileExtension = (info.name as NSString).pathExtension
- if let fileType = UTType(filenameExtension: fileExtension) {
- if fileType.isSubtype(of: .image) {
- return .image
- } else if fileType.isSubtype(of: .text) {
- return .text
- }
- }
- return .unknown
- }
-
- init(info: PreviewFileInfo) {
- self.info = info
-
- self.client = HotlineFilePreviewClient(
- address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size))
- self.client?.delegate = self
- }
-
- func download() {
- self.client?.start()
- }
-
- func cancel() {
- self.client?.cancel()
- }
-
- func hotlineTransferStatusChanged(
- client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus,
- timeRemaining: TimeInterval
- ) {
- print("FilePreview: Download status changed:", status)
-
- switch status {
- case .unconnected:
- state = .unloaded
- progress = 0.0
- case .connecting:
- state = .loading
- progress = 0.0
- case .connected:
- state = .loading
- progress = 0.0
- case .progress(let p):
- state = .loading
- progress = p
- case .failed(_):
- state = .failed
- progress = 0.0
- case .completing:
- state = .loading
- progress = 1.0
- case .completed:
- state = .loaded
- progress = 1.0
- }
- }
-
- func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) {
- self.state = .loaded
- self.data = data
-
- switch self.previewType {
- case .image:
- #if os(iOS)
- self.image = UIImage(data: data)
- #elseif os(macOS)
- self.image = NSImage(data: data)
- #endif
- case .text:
- let encoding: UInt = NSString.stringEncoding(
- for: data, convertedString: nil, usedLossyConversion: nil)
- if encoding != 0 {
- self.text = String(data: data, encoding: String.Encoding(rawValue: encoding))
- } else {
- self.text = String(data: data, encoding: .utf8)
- }
- case .unknown:
- return
- }
- }
-}
+//
+//enum FilePreviewState: Equatable {
+// case unloaded
+// case loading
+// case loaded
+// case failed
+//}
+//
+//enum FilePreviewType: Equatable {
+// case unknown
+// case image
+// case text
+//}
+//
+//@Observable
+//final class FilePreview: HotlineFilePreviewClientDelegate {
+// @ObservationIgnored let info: PreviewFileInfo
+// @ObservationIgnored var client: HotlineFilePreviewClient? = nil
+//
+// var state: FilePreviewState = .unloaded
+// var progress: Double = 0.0
+//
+// var data: Data? = nil
+//
+// #if os(iOS)
+// var image: UIImage? = nil
+// #elseif os(macOS)
+// var image: NSImage? = nil
+// #endif
+//
+// var text: String? = nil
+// var styledText: NSAttributedString? = nil
+//
+// var previewType: FilePreviewType {
+// let fileExtension = (info.name as NSString).pathExtension
+// if let fileType = UTType(filenameExtension: fileExtension) {
+// if fileType.isSubtype(of: .image) {
+// return .image
+// }
+// else if fileType.isSubtype(of: .text) {
+// return .text
+// }
+// }
+// return .unknown
+// }
+//
+// init(info: PreviewFileInfo) {
+// self.info = info
+//
+// self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size))
+// self.client?.delegate = self
+// }
+//
+// func download() {
+// self.client?.start()
+// }
+//
+// func cancel() {
+// self.client?.cancel()
+// }
+//
+// func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) {
+// print("FilePreview: Download status changed:", status)
+//
+// switch status {
+// case .unconnected:
+// state = .unloaded
+// progress = 0.0
+// case .connecting:
+// state = .loading
+// progress = 0.0
+// case .connected:
+// state = .loading
+// progress = 0.0
+// case .progress(let p):
+// state = .loading
+// progress = p
+// case .failed(_):
+// state = .failed
+// progress = 0.0
+// case .completing:
+// state = .loading
+// progress = 1.0
+// case .completed:
+// state = .loaded
+// progress = 1.0
+// }
+// }
+//
+// func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) {
+// self.state = .loaded
+// self.data = data
+//
+// switch self.previewType {
+// case .image:
+// #if os(iOS)
+// self.image = UIImage(data: data)
+// #elseif os(macOS)
+// self.image = NSImage(data: data)
+// #endif
+// case .text:
+// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil)
+// if encoding != 0 {
+// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding))
+// }
+// else {
+// self.text = String(data: data, encoding: .utf8)
+// }
+// case .unknown:
+// return
+// }
+// }
+//}
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
deleted file mode 100644
index 1989d46..0000000
--- a/Hotline/Models/Hotline.swift
+++ /dev/null
@@ -1,1137 +0,0 @@
-import SwiftUI
-
-@Observable
-class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate,
- HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate
-{
- let id: UUID = UUID()
- let trackerClient: HotlineTrackerClient
- let client: HotlineClient
-
- static func == (lhs: Hotline, rhs: Hotline) -> Bool {
- return lhs.id == rhs.id
- }
-
- #if os(macOS)
- static func getClassicIcon(_ index: Int) -> NSImage? {
- return NSImage(named: "Classic/\(index)")
- }
- #elseif os(iOS)
- static func getClassicIcon(_ index: Int) -> UIImage? {
- return UIImage(named: "Classic/\(index)")
- }
- #endif
-
- // The icon ordering here was painsakenly pulled manually
- // from the original Hotline client to display the classic icons
- // in the same order as the original client.
- static let classicIconSet: [Int] = [
- 141, 149, 150, 151, 172, 184, 204,
- 2013, 2036, 2037, 2055, 2400, 2505, 2534,
- 2578, 2592, 4004, 4015, 4022, 4104, 4131,
- 4134, 4136, 4169, 4183, 4197, 4240, 4247,
- 128, 129, 130, 131, 132, 133, 134,
- 135, 136, 137, 138, 139, 140, 142,
- 143, 144, 145, 146, 147, 148, 152,
- 153, 154, 155, 156, 157, 158, 159,
- 160, 161, 162, 163, 164, 165, 166,
- 167, 168, 169, 170, 171, 173, 174,
- 175, 176, 177, 178, 179, 180, 181,
- 182, 183, 185, 186, 187, 188, 189,
- 190, 191, 192, 193, 194, 195, 196,
- 197, 198, 199, 200, 201, 202, 203,
- 205, 206, 207, 208, 209, 212, 214,
- 215, 220, 233, 236, 237, 243, 244,
- 277, 410, 414, 500, 666, 1250, 1251,
- 1968, 1969, 2000, 2001, 2002, 2003, 2004,
- 2006, 2007, 2008, 2009, 2010, 2011, 2012,
- 2014, 2015, 2016, 2017, 2018, 2019, 2020,
- 2021, 2022, 2023, 2024, 2025, 2026, 2027,
- 2028, 2029, 2030, 2031, 2032, 2033, 2034,
- 2035, 2038, 2040, 2041, 2042, 2043, 2044,
- 2045, 2046, 2047, 2048, 2049, 2050, 2051,
- 2052, 2053, 2054, 2056, 2057, 2058, 2059,
- 2060, 2061, 2062, 2063, 2064, 2065, 2066,
- 2067, 2070, 2071, 2072, 2073, 2075, 2079,
- 2098, 2100, 2101, 2102, 2103, 2104, 2105,
- 2106, 2107, 2108, 2109, 2110, 2112, 2113,
- 2115, 2116, 2117, 2118, 2119, 2120, 2121,
- 2122, 2123, 2124, 2125, 2126, 4150, 2223,
- 2401, 2402, 2403, 2404, 2500, 2501, 2502,
- 2503, 2504, 2506, 2507, 2528, 2529, 2530,
- 2531, 2532, 2533, 2535, 2536, 2537, 2538,
- 2539, 2540, 2541, 2542, 2543, 2544, 2545,
- 2546, 2547, 2548, 2549, 2550, 2551, 2552,
- 2553, 2554, 2555, 2556, 2557, 2558, 2559,
- 2560, 2561, 2562, 2563, 2564, 2565, 2566,
- 2567, 2568, 2569, 2570, 2571, 2572, 2573,
- 2574, 2575, 2576, 2577, 2579, 2580, 2581,
- 2582, 2583, 2584, 2585, 2586, 2587, 2588,
- 2589, 2590, 2591, 2593, 2594, 2595, 2596,
- 2597, 2598, 2599, 2600, 4000, 4001, 4002,
- 4003, 4005, 4006, 4007, 4008, 4009, 4010,
- 4011, 4012, 4013, 4014, 4016, 4017, 4018,
- 4019, 4020, 4021, 4023, 4024, 4025, 4026,
- 4027, 4028, 4029, 4030, 4031, 4032, 4033,
- 4034, 4035, 4036, 4037, 4038, 4039, 4040,
- 4041, 4042, 4043, 4044, 4045, 4046, 4047,
- 4048, 4049, 4050, 4051, 4052, 4053, 4054,
- 4055, 4056, 4057, 4058, 4059, 4060, 4061,
- 4062, 4063, 4064, 4065, 4066, 4067, 4068,
- 4069, 4070, 4071, 4072, 4073, 4074, 4075,
- 4076, 4077, 4078, 4079, 4080, 4081, 4082,
- 4083, 4084, 4085, 4086, 4087, 4088, 4089,
- 4090, 4091, 4092, 4093, 4094, 4095, 4096,
- 4097, 4098, 4099, 4100, 4101, 4102, 4103,
- 4105, 4106, 4107, 4108, 4109, 4110, 4111,
- 4112, 4113, 4114, 4115, 4116, 4117, 4118,
- 4119, 4120, 4121, 4122, 4123, 4124, 4125,
- 4126, 4127, 4128, 4129, 4130, 4132, 4133,
- 4135, 4137, 4138, 4139, 4140, 4141, 4142,
- 4143, 4144, 4145, 4146, 4147, 4148, 4149,
- 4151, 4152, 4153, 4154, 4155, 4156, 4157,
- 4158, 4159, 4160, 4161, 4162, 4163, 4164,
- 4165, 4166, 4167, 4168, 4170, 4171, 4172,
- 4173, 4174, 4175, 4176, 4177, 4178, 4179,
- 4180, 4181, 4182, 4184, 4185, 4186, 4187,
- 4188, 4189, 4190, 4191, 4192, 4193, 4194,
- 4195, 4196, 4198, 4199, 4200, 4201, 4202,
- 4203, 4204, 4205, 4206, 4207, 4208, 4209,
- 4210, 4211, 4212, 4213, 4214, 4215, 4216,
- 4217, 4218, 4219, 4220, 4221, 4222, 4223,
- 4224, 4225, 4226, 4227, 4228, 4229, 4230,
- 4231, 4232, 4233, 4234, 4235, 4236, 4238,
- 4241, 4242, 4243, 4244, 4245, 4246, 4248,
- 4249, 4250, 4251, 4252, 4253, 4254, 31337,
- 6001, 6002, 6003, 6004, 6005, 6008, 6009,
- 6010, 6011, 6012, 6013, 6014, 6015, 6016,
- 6017, 6018, 6023, 6025, 6026, 6027, 6028,
- 6029, 6030, 6031, 6032, 6033, 6034, 6035,
- ]
-
- var status: HotlineClientStatus = .disconnected
- var server: Server? {
- didSet {
- self.updateServerTitle()
- }
- }
- var serverVersion: UInt16 = 123
- var serverName: String? {
- didSet {
- self.updateServerTitle()
- }
- }
- var serverTitle: String = "Server"
- var username: String = "guest"
- var iconID: Int = 414
- var access: HotlineUserAccessOptions?
- var agreed: Bool = false
- var users: [User] = []
- var accounts: [HotlineAccount] = []
- var chat: [ChatMessage] = []
- var messageBoard: [String] = []
- var messageBoardLoaded: Bool = false
- var files: [FileInfo] = []
- var filesLoaded: Bool = false
- var news: [NewsInfo] = []
- private var newsLookup: [String: NewsInfo] = [:]
- var newsLoaded: Bool = false
- var accountsLoaded: Bool = false
- var instantMessages: [UInt16: [InstantMessage]] = [:]
- var transfers: [TransferInfo] = []
- var downloads: [HotlineTransferClient] = []
- var unreadInstantMessages: [UInt16: UInt16] = [:]
- var unreadPublicChat: Bool = false
- var errorDisplayed: Bool = false
- var errorMessage: String? = nil
-
- @ObservationIgnored var bannerClient: HotlineFilePreviewClient?
- #if os(macOS)
- var bannerImage: NSImage? = nil
- #elseif os(iOS)
- var bannerImage: UIImage? = nil
- #endif
-
- // MARK: -
-
- init(trackerClient: HotlineTrackerClient, client: HotlineClient) {
- self.trackerClient = trackerClient
- self.client = client
- self.client.delegate = self
- }
-
- // MARK: -
-
- @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async
- -> [Server]
- {
- var servers: [Server] = []
-
- if let fetchedServers: [HotlineServer] = try? await self.trackerClient.fetchServers(
- address: tracker, port: port)
- {
- for s in fetchedServers {
- if let serverName = s.name {
- servers.append(
- Server(
- name: serverName, description: s.description, address: s.address, port: Int(s.port),
- users: Int(s.users)))
- }
- }
- }
-
- return servers
- }
-
- @MainActor func disconnectTracker() {
- self.trackerClient.close()
- }
-
- @MainActor func login(
- server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil
- ) {
- self.server = server
- self.serverName = server.name
- self.username = username
- self.iconID = iconID
-
- self.client.login(
- address: server.address, port: server.port, login: server.login, password: server.password,
- username: username, iconID: UInt16(iconID)
- ) { [weak self] err, serverName, serverVersion in
- self?.serverVersion = serverVersion ?? 123
- if serverName != nil {
- self?.serverName = serverName
- }
-
- callback?(err == nil)
- }
- }
-
- @MainActor func sendUserInfo(
- username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil
- ) {
- self.username = username
- self.iconID = iconID
-
- self.client.sendSetClientUserInfo(
- username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse)
- }
-
- @MainActor func getUserList() {
- self.client.sendGetUserList()
- }
-
- @MainActor func disconnect() {
- self.client.disconnect()
- self.bannerClient?.cancel()
- }
-
- @MainActor func sendAgree() {
- self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none)
- }
-
- @MainActor func sendInstantMessage(_ text: String, userID: UInt16) {
- let message = InstantMessage(
- direction: .outgoing, text: text.convertingLinksToMarkdown(), type: .message, date: Date())
-
- if self.instantMessages[userID] == nil {
- self.instantMessages[userID] = [message]
- } else {
- self.instantMessages[userID]!.append(message)
- }
-
- self.client.sendInstantMessage(message: text, userID: userID)
-
- if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound {
- SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
- }
- }
-
- func markPublicChatAsRead() {
- self.unreadPublicChat = false
- }
-
- func hasUnreadInstantMessages(userID: UInt16) -> Bool {
- return self.unreadInstantMessages[userID] != nil
- }
-
- func markInstantMessagesAsRead(userID: UInt16) {
- self.unreadInstantMessages.removeValue(forKey: userID)
- }
-
- @MainActor func sendChat(_ text: String, announce: Bool = false) {
- self.client.sendChat(message: text, announce: announce)
- }
-
- @MainActor func getMessageBoard() async -> [String] {
- self.messageBoard = await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetMessageBoard { err, messages in
- continuation.resume(returning: (err != nil ? [] : messages))
- }
- }
-
- self.messageBoardLoaded = true
-
- return self.messageBoard
- }
-
- @MainActor func postToMessageBoard(text: String) {
- self.client.sendPostMessageBoard(text: text)
- }
-
- @MainActor func getFileList(path: [String] = []) async -> [FileInfo] {
- return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetFileList(path: path) { [weak self] files in
- let parentFile = self?.findFile(in: self?.files ?? [], at: path)
-
- var newFiles: [FileInfo] = []
- for f in files {
- newFiles.append(FileInfo(hotlineFile: f))
- }
-
- DispatchQueue.main.async {
- if let parent = parentFile {
- parent.children = newFiles
- } else if path.isEmpty {
- self?.filesLoaded = true
- self?.files = newFiles
- }
-
- continuation.resume(returning: newFiles)
- }
- }
- }
- }
-
- @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async
- -> String?
- {
- return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) {
- articleText in
- // let parentNews = self?.findNews(in: self?.news ?? [], at: path)
-
- // var newCategories: [NewsInfo] = []
- // for category in categories {
- // newCategories.append(NewsInfo(hotlineNewsCategory: category))
- // }
- //
- // if let parent = existingNewsItem {
- // parent.children = newCategories
- // }
- // else if path.isEmpty {
- // self?.news = newCategories
- // }
-
- continuation.resume(returning: articleText)
- }
- }
-
- }
-
- @MainActor func getAccounts() async -> [HotlineAccount] {
- return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetAccounts { articles in
- continuation.resume(returning: articles)
- }
- }
- }
-
- @MainActor func getNewsList(at path: [String] = []) async {
- return await withCheckedContinuation { [weak self] continuation in
- let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path)
-
- // Send a categories request for bundle paths or root (empty path)
- if path.isEmpty || parentNewsGroup?.type == .bundle {
- print("Hotline: Requesting categories at: /\(path.joined(separator: "/"))")
-
- self?.client.sendGetNewsCategories(path: path) { @MainActor [weak self] categories in
- // Create info for each category returned.
- var newCategoryInfos: [NewsInfo] = []
-
- // Transform hotline categories into NewsInfo objects.
- for category in categories {
- var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category)
-
- if let lookupPath = newsCategoryInfo.lookupPath {
- // Merge returned category info with existing category info.
- if let existingCategoryInfo = self?.newsLookup[lookupPath] {
- print("Hotline: Merging category into existing category at \(lookupPath)")
-
- existingCategoryInfo.count = newsCategoryInfo.count
- existingCategoryInfo.name = newsCategoryInfo.name
- existingCategoryInfo.path = newsCategoryInfo.path
- existingCategoryInfo.categoryID = newsCategoryInfo.categoryID
- newsCategoryInfo = existingCategoryInfo
- } else {
- print("Hotline: New category added at \(lookupPath)")
- self?.newsLookup[lookupPath] = newsCategoryInfo
- }
- }
-
- newCategoryInfos.append(newsCategoryInfo)
- }
-
- if let parent = parentNewsGroup {
- parent.children = newCategoryInfos
- } else if path.isEmpty {
- self?.newsLoaded = true
- self?.news = newCategoryInfos
- }
-
- continuation.resume()
- }
- } else {
- print("Hotline: Requesting articles at: /\(path.joined(separator: "/"))")
-
- self?.client.sendGetNewsArticles(path: path) { @MainActor [weak self] articles in
- print("Hotline: Organizing news at \(path.joined(separator: "/"))")
-
- // Create info for each article returned.
- var newArticleInfos: [NewsInfo] = []
-
- for article in articles {
- var newsArticleInfo = NewsInfo(hotlineNewsArticle: article)
-
- if let lookupPath = newsArticleInfo.lookupPath {
- // Merge returned category info with existing category info.
- if let existingArticleInfo = self?.newsLookup[lookupPath] {
- print("Hotline: Merging article into existing article at \(lookupPath)")
-
- existingArticleInfo.count = newsArticleInfo.count
- existingArticleInfo.name = newsArticleInfo.name
- existingArticleInfo.path = newsArticleInfo.path
- existingArticleInfo.articleUsername = newsArticleInfo.articleUsername
- existingArticleInfo.articleDate = newsArticleInfo.articleDate
- existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors
- existingArticleInfo.articleID = newsArticleInfo.articleID
- newsArticleInfo = existingArticleInfo
- } else {
- print("Hotline: New article added at \(lookupPath)")
- self?.newsLookup[lookupPath] = newsArticleInfo
- }
- }
-
- newArticleInfos.append(newsArticleInfo)
- }
-
- let organizedNewsArticles: [NewsInfo] = self?.organizeNewsArticles(newArticleInfos) ?? []
- if let parent = parentNewsGroup {
- parent.children = organizedNewsArticles
- }
-
- continuation.resume()
- }
- }
- }
- }
-
- func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] {
- // Place articles under their parent.
- var organized: [NewsInfo] = []
- for article in flatArticles {
- if let parentLookupPath = article.parentArticleLookupPath,
- let parentArticle = self.newsLookup[parentLookupPath]
- {
- // article.expanded = true
- if parentArticle.children.firstIndex(of: article) == nil {
- article.expanded = true
- parentArticle.children.append(article)
- }
- } else {
- organized.append(article)
- }
- }
-
- return organized
- }
-
- @MainActor func postNewsArticle(
- title: String, body: String, at path: [String], parentID: UInt32 = 0
- ) async -> Bool {
-
- return await withCheckedContinuation { [weak self] continuation in
- guard let client = self?.client else {
- continuation.resume(returning: false)
- return
- }
-
- client.postNewsArticle(
- title: title, text: body, path: path, parentID: parentID,
- callback: { success in
- print("Hotline: News article posted? \(success)")
- continuation.resume(returning: success)
- })
- }
- }
-
- // @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] {
- // return await withCheckedContinuation { [weak self] continuation in
- // guard let client = self?.client else {
- // continuation.resume(returning: [])
- // return
- // }
- //
- // client.sendGetNewsCategories(path: path) { [weak self] categories in
- // let parentNews = self?.findNews(in: self?.news ?? [], at: path)
- //
- // var newCategories: [NewsInfo] = []
- // for category in categories {
- // let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category)
- // newCategories.append(categoryInfo)
- // self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo
- // }
- //
- // DispatchQueue.main.async {
- // if let parent = parentNews {
- // parent.children = newCategories
- // }
- // else if path.isEmpty {
- // self?.news = newCategories
- // }
- //
- // continuation.resume(returning: newCategories)
- // }
- // }
- // }
- // }
-
- @MainActor func getArticles(at path: [String]) async -> [NewsInfo] {
- return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetNewsArticles(path: path) { articles in
- continuation.resume(returning: [])
- }
- }
- }
-
- @MainActor func downloadFile(
- _ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil
- ) {
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count - 1])
- }
-
- self.client.sendDownloadFile(name: fileName, path: fullPath) {
- [weak self]
- success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount
- in
- print("GOT DOWNLOAD REPLY:")
- print("\tSUCCESS?", success)
- print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)")
- print("\tFILE SIZE: \(downloadFileSize.debugDescription)")
- print("\tREFERENCE NUM: \(downloadReferenceNumber.debugDescription)")
- print("\tWAITING COUNT: \(downloadWaitingCount.debugDescription)")
-
- if let self = self,
- // let server = self.server,
- let address = self.server?.address,
- let port = self.server?.port,
- let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize
- {
-
- let fileClient = HotlineFileDownloadClient(
- address: address, port: UInt16(port), reference: referenceNumber,
- size: UInt32(transferSize))
- // let previewClient = HotlineFilePreviewClient(server: self.server, reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload)
- // let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload)
- fileClient.delegate = self
- self.downloads.append(fileClient)
-
- let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize))
- transfer.downloadCallback = callback
- self.transfers.append(transfer)
-
- fileClient.start()
- }
- }
- }
-
- @MainActor func downloadFileTo(
- url fileURL: URL, fileName: String, path: [String],
- progress progressCallback: ((TransferInfo, Double) -> Void)? = nil,
- complete callback: ((TransferInfo, URL) -> Void)? = nil
- ) {
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count - 1])
- }
-
- self.client.sendDownloadFile(name: fileName, path: fullPath) {
- [weak self]
- success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount
- in
- print("GOT DOWNLOAD REPLY:")
- print("\tSUCCESS?", success)
- print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)")
- print("\tFILE SIZE: \(downloadFileSize.debugDescription)")
- print("\tREFERENCE NUM: \(downloadReferenceNumber.debugDescription)")
- print("\tWAITING COUNT: \(downloadWaitingCount.debugDescription)")
-
- if let self = self,
- let address = self.server?.address,
- let port = self.server?.port,
- let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize
- {
-
- let fileClient = HotlineFileDownloadClient(
- address: address, port: UInt16(port), reference: referenceNumber,
- size: UInt32(transferSize))
- fileClient.delegate = self
- self.downloads.append(fileClient)
-
- let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize))
- transfer.downloadCallback = callback
- transfer.progressCallback = progressCallback
- self.transfers.append(transfer)
-
- fileClient.start(to: fileURL)
- }
- }
- }
-
- @MainActor func uploadFile(
- url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil
- ) {
- let fileName = fileURL.lastPathComponent
-
- guard fileURL.isFileURL, !fileName.isEmpty else {
- print("NOT A FILE URL?")
- return
- }
-
- let filePath = fileURL.path(percentEncoded: false)
-
- var fileIsDirectory: ObjCBool = false
- guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory),
- fileIsDirectory.boolValue == false
- else {
- print("FILE IS A DIRECTORY?")
- return
- }
-
- var fileSize: UInt = 0
-
- // Data size
- let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath)
- if let sizeAttribute = fileAttributes?[.size] as? NSNumber {
- print("DATA SIZE \(sizeAttribute.uintValue)")
- fileSize += sizeAttribute.uintValue
- }
-
- // Resource size
- let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc")
-
- print("RESOURCE PATH \(resourceURL)")
- let resourceAttributes = try? FileManager.default.attributesOfItem(
- atPath: resourceURL.path(percentEncoded: false))
- if let sizeAttribute = resourceAttributes?[.size] as? NSNumber {
- print("RESOURCE SIZE \(sizeAttribute.uintValue)")
- fileSize += sizeAttribute.uintValue
- }
-
- print("FILE SIZE? \(fileSize)")
-
- guard fileSize > 0 else {
- print("FILE IS EMPTY??")
- return
- }
-
- print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)")
-
- self.client.sendUploadFile(name: fileName, path: path) {
- [weak self] success, uploadReferenceNumber in
- print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))")
-
- if let self = self,
- let address = self.server?.address,
- let port = self.server?.port,
- let referenceNumber = uploadReferenceNumber,
- let fileClient = HotlineFileUploadClient(
- upload: fileURL, address: address, port: UInt16(port), reference: referenceNumber)
- {
-
- print("GOING TO UPLOAD")
-
- fileClient.delegate = self
- self.downloads.append(fileClient)
-
- let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize)
- transfer.uploadCallback = callback
- self.transfers.append(transfer)
-
- fileClient.start()
- }
- }
-
- }
-
- @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? {
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count - 1])
- }
-
- return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetFileInfo(name: fileName, path: fullPath) { info in
- continuation.resume(returning: info)
- }
- }
- }
-
- @MainActor func deleteFile(_ fileName: String, path: [String]) async -> Bool {
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count - 1])
- }
-
- return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendDeleteFile(name: fileName, path: fullPath) { success in
- continuation.resume(returning: success)
- }
- }
- }
-
- @MainActor func previewFile(
- _ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil
- ) {
- var fullPath: [String] = []
- if path.count > 1 {
- fullPath = Array(path[0..<path.count - 1])
- }
-
- self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true) {
- [weak self]
- success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount
- in
- guard success else {
- callback?(nil)
- return
- }
-
- print("GOT DOWNLOAD REPLY:")
- print("SUCCESS?", success)
- print("TRANSFER SIZE: \(downloadTransferSize.debugDescription)")
- print("FILE SIZE: \(downloadFileSize.debugDescription)")
- print("REFERENCE NUM: \(downloadReferenceNumber.debugDescription)")
- print("WAITING COUNT: \(downloadWaitingCount.debugDescription)")
-
- var info: PreviewFileInfo? = nil
-
- if let address = self?.server?.address,
- let port = self?.server?.port,
- let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize
- {
-
- info = PreviewFileInfo(
- id: referenceNumber, address: address, port: port, size: transferSize, name: fileName)
- }
-
- callback?(info)
- }
- }
-
- @MainActor func deleteTransfer(id: UInt32) {
- if let b = self.bannerClient, b.referenceNumber == id {
- b.cancel()
- self.bannerClient = nil
- return
- }
-
- if let i = self.transfers.firstIndex(where: { $0.id == id }) {
- self.transfers.remove(at: i)
- }
-
- if let i = self.downloads.firstIndex(where: { $0.referenceNumber == id }) {
- let fileClient = self.downloads.remove(at: i)
- fileClient.cancel()
- }
- }
-
- @MainActor func deleteAllTransfers() {
- self.transfers = []
-
- let downloads = self.downloads
- self.downloads = []
-
- for fileClient in downloads {
- fileClient.cancel()
- }
- }
-
- @MainActor func downloadBanner(force: Bool = false) {
- guard self.serverVersion >= 150 else {
- return
- }
-
- if self.bannerClient != nil || force {
- self.bannerClient?.delegate = nil
- self.bannerClient?.cancel()
- self.bannerClient = nil
-
- if force {
- self.bannerImage = nil
- }
- }
-
- if self.bannerImage != nil {
- return
- }
-
- self.client.sendDownloadBanner {
- [weak self] success, downloadReferenceNumber, downloadTransferSize in
- if !success {
- return
- }
-
- if let self = self,
- let address = self.server?.address,
- let port = self.server?.port,
- let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize
- {
- self.bannerClient = HotlineFilePreviewClient(
- address: address, port: UInt16(port), reference: referenceNumber,
- size: UInt32(transferSize))
- self.bannerClient?.delegate = self
- self.bannerClient?.start()
- }
- }
- }
-
- // MARK: - Hotline Delegate
-
- @MainActor func hotlineStatusChanged(status: HotlineClientStatus) {
- print("Hotline: Connection status changed to: \(status)")
-
- if status == .disconnected {
- self.serverVersion = 123
- self.serverName = nil
- self.access = nil
-
- self.users = []
- self.chat = []
- self.messageBoard = []
- self.messageBoardLoaded = false
- self.files = []
- self.filesLoaded = false
- self.news = []
- self.newsLoaded = false
-
- self.bannerImage = nil
- if let b = self.bannerClient {
- b.cancel()
- self.bannerClient = nil
- }
-
- self.deleteAllTransfers()
- } else if status == .loggedIn {
- if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound {
- SoundEffectPlayer.shared.playSoundEffect(.loggedIn)
- }
- }
-
- self.status = status
- }
-
- func hotlineGetUserInfo() -> (String, UInt16) {
- return (self.username, UInt16(self.iconID))
- }
-
- func hotlineReceivedAgreement(text: String) {
- self.chat.append(ChatMessage(text: text, type: .agreement, date: Date()))
- }
-
- func hotlineReceivedNewsPost(message: String) {
- let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/
- let matches = message.matches(of: messageBoardRegex)
-
- if matches.count == 1 {
- let range = matches[0].range
- self.messageBoard.insert(String(message[message.startIndex..<range.lowerBound]), at: 0)
- } else {
- self.messageBoard.insert(message, at: 0)
- }
-
- SoundEffectPlayer.shared.playSoundEffect(.newNews)
- }
-
- func hotlineReceivedServerMessage(message: String) {
- if Prefs.shared.playChatSound && Prefs.shared.playChatSound {
- SoundEffectPlayer.shared.playSoundEffect(.serverMessage)
- }
-
- print("Hotline: received server message:\n\(message)")
- self.chat.append(ChatMessage(text: message, type: .server, date: Date()))
- }
-
- func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {
- if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
- let user = self.users[existingUserIndex]
- print("Hotline: received private message from \(user.name): \(message)")
-
- if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound {
- if self.unreadInstantMessages[userID] == nil {
- SoundEffectPlayer.shared.playSoundEffect(.serverMessage)
- } else {
- SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
- }
- }
-
- let instantMessage = InstantMessage(
- direction: .incoming, text: message.convertingLinksToMarkdown(), type: .message,
- date: Date())
- if self.instantMessages[userID] == nil {
- self.instantMessages[userID] = [instantMessage]
- } else {
- self.instantMessages[userID]!.append(instantMessage)
- }
- self.unreadInstantMessages[userID] = userID
- }
- }
-
- func hotlineReceivedChatMessage(message: String) {
- if Prefs.shared.playSounds && Prefs.shared.playChatSound {
- SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
- }
- self.chat.append(ChatMessage(text: message, type: .message, date: Date()))
- self.unreadPublicChat = true
- }
-
- func hotlineReceivedUserList(users: [HotlineUser]) {
- var existingUserIDs: [UInt16] = []
- var userList: [User] = []
-
- for u in users {
- if let i = self.users.firstIndex(where: { $0.id == u.id }) {
- // If a user is already in the user list we have to assume
- // they changed somehow before we received the user list
- // which means let's keep their existing info.
- existingUserIDs.append(u.id)
- userList.append(self.users[i])
- } else {
- userList.append(User(hotlineUser: u))
- }
- }
-
- if !existingUserIDs.isEmpty {
- self.users = self.users.filter { !existingUserIDs.contains($0.id) }
- }
-
- self.users = userList + self.users
- }
-
- func hotlineUserChanged(user: HotlineUser) {
- self.addOrUpdateHotlineUser(user)
- }
-
- func hotlineUserDisconnected(userID: UInt16) {
- if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
- let user = self.users.remove(at: existingUserIndex)
-
- if Prefs.shared.showJoinLeaveMessages {
- self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date()))
- }
-
- if Prefs.shared.playSounds && Prefs.shared.playLeaveSound {
- SoundEffectPlayer.shared.playSoundEffect(.userLogout)
- }
- }
- }
-
- func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {
- print("Hotline: got access options")
- HotlineUserAccessOptions.printAccessOptions(options)
-
- self.access = options
- }
-
- func hotlineReceivedErrorMessage(code: UInt32, message: String?) {
- print("Hotline: received error message \(code)", message.debugDescription)
-
- self.errorDisplayed = (message != nil) // Show error if there is a message to display.
- self.errorMessage = message
-
- if self.errorDisplayed,
- Prefs.shared.playSounds && Prefs.shared.playErrorSound
- {
- SoundEffectPlayer.shared.playSoundEffect(.error)
- }
- }
-
- // MARK: - Hotline Transfer Delegate
-
- func hotlineTransferStatusChanged(
- client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus,
- timeRemaining: TimeInterval
- ) {
- switch status {
- case .unconnected:
- break
- case .connecting:
- break
- case .connected:
- break
- case .progress(let progress):
- if let transfer = self.transfers.first(where: { $0.id == reference }) {
- transfer.progress = progress
- transfer.timeRemaining = timeRemaining
- transfer.progressCallback?(transfer, progress)
- }
- case .failed(_):
- if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
- self.downloads.remove(at: i)
- }
- if let transfer = self.transfers.first(where: { $0.id == reference }) {
- transfer.failed = true
- transfer.timeRemaining = 0.0
- }
- if let b = self.bannerClient, b.referenceNumber == reference {
- b.delegate = nil
- self.bannerClient = nil
- }
- case .completing:
- break
- case .completed:
- if let transfer = self.transfers.first(where: { $0.id == reference }) {
- transfer.completed = true
- transfer.timeRemaining = 0.0
- }
- }
- }
-
- func hotlineFileDownloadReceivedInfo(
- client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork
- ) {
- if let transfer = self.transfers.first(where: { $0.id == reference }) {
- transfer.title = info.name
- }
- }
-
- func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) {
- if let b = self.bannerClient, b.referenceNumber == reference {
- #if os(macOS)
- self.bannerImage = NSImage(data: data)
- #elseif os(iOS)
- self.bannerImage = UIImage(data: data)
- #endif
- } else if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
- let transfer = self.transfers[i]
- transfer.previewCallback?(transfer, data)
- self.transfers.remove(at: i)
- }
-
- if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
- self.downloads.remove(at: i)
- }
- }
-
- func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) {
- if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
- let transfer = self.transfers[i]
- transfer.fileURL = at
- transfer.downloadCallback?(transfer, at)
- if Prefs.shared.playSounds && Prefs.shared.playFileTransferCompleteSound {
- SoundEffectPlayer.shared.playSoundEffect(.transferComplete)
- }
- }
-
- if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
- self.downloads.remove(at: i)
- }
- }
-
- func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) {
- if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
- let transfer = self.transfers[i]
- transfer.uploadCallback?(transfer)
- if Prefs.shared.playSounds && Prefs.shared.playFileTransferCompleteSound {
- SoundEffectPlayer.shared.playSoundEffect(.transferComplete)
- }
- }
-
- if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
- self.downloads.remove(at: i)
- }
- }
-
- // MARK: - Utilities
-
- func updateServerTitle() {
- self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server"
- }
-
- private func addOrUpdateHotlineUser(_ user: HotlineUser) {
- print("Hotline: users: \n\(self.users)")
- if let i = self.users.firstIndex(where: { $0.id == user.id }) {
- print("Hotline: updating user \(self.users[i].name)")
- self.users[i] = User(hotlineUser: user)
- } else {
- if !self.users.isEmpty {
- if Prefs.shared.playSounds && Prefs.shared.playJoinSound {
- SoundEffectPlayer.shared.playSoundEffect(.userLogin)
- }
- }
-
- print("Hotline: added user: \(user.name)")
- self.users.append(User(hotlineUser: user))
- if Prefs.shared.showJoinLeaveMessages {
- self.chat.append(ChatMessage(text: "\(user.name) joined", type: .status, date: Date()))
- }
- }
- }
-
- private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? {
- guard !path.isEmpty, !filesToSearch.isEmpty else { return nil }
-
- let currentName = path[0]
-
- for file in filesToSearch {
- if file.name == currentName {
- if path.count == 1 {
- return file
- } else if let subfiles = file.children {
- let remainingPath = Array(path[1...])
- return self.findFile(in: subfiles, at: remainingPath)
- }
- }
- }
-
- return nil
- }
-
- private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? {
- guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil }
-
- for news in newsToSearch {
- if news.name == currentName {
- if path.count == 1 {
- return news
- } else if !news.children.isEmpty {
- let remainingPath = Array(path[1...])
- return self.findNews(in: news.children, at: remainingPath)
- }
- }
- }
-
- return nil
- }
-
- private func findNewsArticle(id articleID: UInt32, at path: [String]) -> NewsInfo? {
- guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else {
- return nil
- }
-
- return parent.children.first { child in
- guard let childArticleID = child.articleID else {
- return false
- }
-
- return child.type == .article && child.articleID == childArticleID
- }
- }
-}
diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift
index 06a2966..52c45a0 100644
--- a/Hotline/Models/PreviewFileInfo.swift
+++ b/Hotline/Models/PreviewFileInfo.swift
@@ -1,18 +1,15 @@
import UniformTypeIdentifiers
-enum PreviewFileType: Equatable {
- case unknown
- case image
- case text
-}
-
struct PreviewFileInfo: Identifiable, Codable {
var id: UInt32
var address: String
var port: Int
var size: Int
var name: String
-
+
+ var type: String? = nil
+ var creator: String? = nil
+
var previewType: FilePreviewType {
let fileExtension = (self.name as NSString).pathExtension
if let fileType = UTType(filenameExtension: fileExtension) {
diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift
index a3e3dde..bcac81f 100644
--- a/Hotline/Models/Server.swift
+++ b/Hotline/Models/Server.swift
@@ -9,13 +9,22 @@ struct Server: Codable {
var port: Int
var login: String
var password: String
- var autoconnect: Bool = false
-
- init(
- name: String?, description: String?, address: String,
- port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil,
- password: String? = nil, autoconnect: Bool? = false
- ) {
+
+ var displayAddress: String {
+ if self.port == HotlinePorts.DefaultServerPort {
+ return self.address
+ }
+ else {
+ // Wrap IPv6 addresses in brackets when displaying with port
+ if self.address.contains(":") {
+ return "[\(self.address)]:\(String(self.port))"
+ } else {
+ return "\(self.address):\(String(self.port))"
+ }
+ }
+ }
+
+ init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, password: String? = nil) {
self.name = name
self.description = description
self.address = address.lowercased()
@@ -47,10 +56,49 @@ struct Server: Codable {
}
static func parseServerAddressAndPort(_ address: String) -> (String, Int) {
- let url = URL(string: "hotline://\(address)")
+ let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Check if this looks like an IPv6 address (contains colons but no port delimiter)
+ // IPv6 addresses can be:
+ // - fe80::1234
+ // - [fe80::1234]:5500 (with port)
+ // - 2001:db8::1
+ // - [2001:db8::1]:6500 (with port)
+
+ // If it starts with [, it's bracketed IPv6 with optional port
+ if trimmed.hasPrefix("[") {
+ // Find the closing bracket
+ if let closeBracketIndex = trimmed.firstIndex(of: "]") {
+ let hostEndIndex = trimmed.index(after: closeBracketIndex)
+ let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closeBracketIndex])
+
+ // Check if there's a port after the bracket
+ if hostEndIndex < trimmed.endIndex && trimmed[closeBracketIndex...].contains(":") {
+ let portString = trimmed[trimmed.index(after: hostEndIndex)...]
+ if let port = Int(portString) {
+ return (host.lowercased(), port)
+ }
+ }
+
+ return (host.lowercased(), HotlinePorts.DefaultServerPort)
+ }
+ }
+
+ // Check if it's an IPv6 address without brackets
+ // IPv6 addresses have multiple colons, IPv4:port has only one
+ // Note: IPv6 may also have a zone identifier like fe80::1%en1
+ let colonCount = trimmed.filter { $0 == ":" }.count
+ if colonCount > 1 {
+ // This is likely an IPv6 address without a port
+ // Keep it as-is, including any zone identifier (e.g., %en1 for link-local)
+ return (trimmed.lowercased(), HotlinePorts.DefaultServerPort)
+ }
+
+ // Otherwise use URL parsing for IPv4 or hostnames
+ let url = URL(string: "hotline://\(trimmed)")
let port = url?.port ?? HotlinePorts.DefaultServerPort
let host = url?.host(percentEncoded: false) ?? ""
- return (host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), port)
+ return (host.lowercased(), port)
}
}
diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift
index 8aacf1d..eb0ce7f 100644
--- a/Hotline/Models/TransferInfo.swift
+++ b/Hotline/Models/TransferInfo.swift
@@ -2,27 +2,53 @@ import SwiftUI
@Observable
class TransferInfo: Identifiable, Equatable, Hashable {
- var id: UInt32
-
+ var id: UUID = UUID()
+
+ var referenceNumber: UInt32
var title: String
var size: UInt
- var progress: Double = 0.0
- var timeRemaining: TimeInterval = 0.0
+
+ // Status
var completed: Bool = false
var failed: Bool = false
+ var cancelled: Bool = false
+ var done: Bool {
+ self.completed || self.failed || self.cancelled
+ }
+
+ var progress: Double = 0.0
+ var speed: Double? = nil
+ var timeRemaining: TimeInterval? = nil
+
+ var isUpload: Bool = false
+ var isDownload: Bool {
+ get { !self.isUpload }
+ set { self.isUpload = !newValue }
+ }
+
+ // Folder transfers
+ var isFolder: Bool = false
+ var folderName: String? = nil
+ var fileName: String? = nil
- // For file based transfers (i.e. not previews)
+ // Server association - tracks which HotlineState this transfer belongs to
+ var serverID: UUID
+ var serverName: String?
+
+ // For file based transfers
var fileURL: URL? = nil
- var progressCallback: ((TransferInfo, Double) -> Void)? = nil
- var downloadCallback: ((TransferInfo, URL) -> Void)? = nil
+ var progressCallback: ((TransferInfo) -> Void)? = nil
+ var downloadCallback: ((TransferInfo) -> Void)? = nil
var uploadCallback: ((TransferInfo) -> Void)? = nil
var previewCallback: ((TransferInfo, Data) -> Void)? = nil
- init(id: UInt32, title: String, size: UInt) {
- self.id = id
+ init(reference: UInt32, title: String, size: UInt, serverID: UUID, serverName: String? = nil) {
+ self.referenceNumber = reference
self.title = title
self.size = size
+ self.serverID = serverID
+ self.serverName = serverName
}
static func == (lhs: TransferInfo, rhs: TransferInfo) -> Bool {
@@ -32,4 +58,47 @@ class TransferInfo: Identifiable, Equatable, Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
+
+// var formatSize(_ bytes: UInt) -> String {
+// let formatter = ByteCountFormatter()
+// formatter.countStyle = .file
+// formatter.allowedUnits = [.useKB, .useMB, .useGB]
+// return formatter.string(fromByteCount: Int64(bytes))
+// }
+
+ var displaySize: String {
+ let formatter = ByteCountFormatter()
+ formatter.countStyle = .file
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ return formatter.string(fromByteCount: Int64(self.size))
+ }
+
+ var displaySpeed: String? {
+ guard let speed = self.speed else {
+ return nil
+ }
+
+ let formatter = ByteCountFormatter()
+ formatter.countStyle = .file
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ return "\(formatter.string(fromByteCount: Int64(speed)))/s"
+ }
+
+ var displayTimeRemaining: String? {
+ guard let timeRemaining = self.timeRemaining else {
+ return nil
+ }
+
+ if timeRemaining < 60 {
+ return "\(Int(timeRemaining))s"
+ } else if timeRemaining < 3600 {
+ let minutes = Int(timeRemaining / 60)
+ let secs = Int(timeRemaining.truncatingRemainder(dividingBy: 60))
+ return "\(minutes)m \(secs)s"
+ } else {
+ let hours = Int(timeRemaining / 3600)
+ let minutes = Int((timeRemaining.truncatingRemainder(dividingBy: 3600)) / 60)
+ return "\(hours)h \(minutes)m"
+ }
+ }
}
diff --git a/Hotline/Shared/DataAdditions.swift b/Hotline/Shared/DataAdditions.swift
deleted file mode 100644
index 1eb7f6f..0000000
--- a/Hotline/Shared/DataAdditions.swift
+++ /dev/null
@@ -1,24 +0,0 @@
-import Foundation
-
-extension Data {
- func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool {
- let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
- let filePath = folderURL.generateUniqueFilePath(filename: filename)
- if FileManager.default.createFile(atPath: filePath, contents: nil) {
- if let h = FileHandle(forWritingAtPath: filePath) {
- try? h.write(contentsOf: self)
- try? h.close()
- if bounceDock {
- #if os(macOS)
- var downloadURL = URL(filePath: filePath)
- downloadURL.resolveSymlinksInPath()
- DistributedNotificationCenter.default().post(
- name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
- #endif
- }
- return true
- }
- }
- return false
- }
-}
diff --git a/Hotline/Shared/FileIconView.swift b/Hotline/Shared/FileIconView.swift
deleted file mode 100644
index d39ff1e..0000000
--- a/Hotline/Shared/FileIconView.swift
+++ /dev/null
@@ -1,69 +0,0 @@
-import SwiftUI
-import UniformTypeIdentifiers
-
-struct FolderIconView: View {
- private func folderIcon() -> Image {
- #if os(iOS)
- return Image(systemName: "folder.fill")
- #elseif os(macOS)
- return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder))
- #endif
- }
-
- var body: some View {
- folderIcon()
- .resizable()
- .scaledToFit()
- }
-}
-
-struct FileIconView: View {
- let filename: String
- let fileType: String?
-
- #if os(iOS)
- private func fileIcon() -> Image {
- let fileExtension = (self.filename as NSString).pathExtension
- if let fileType = UTType(filenameExtension: fileExtension) {
- if fileType.isSubtype(of: .movie) {
- return Image(systemName: "play.rectangle")
- } else if fileType.isSubtype(of: .image) {
- return Image(systemName: "photo")
- } else if fileType.isSubtype(of: .archive) {
- return Image(systemName: "doc.zipper")
- } else if fileType.isSubtype(of: .text) {
- return Image(systemName: "doc.text")
- } else {
- return Image(systemName: "doc")
- }
- }
-
- return Image(systemName: "doc")
- }
- #elseif os(macOS)
- private func fileIcon() -> Image {
- let fileExtension = (self.filename as NSString).pathExtension
-
- if !fileExtension.isEmpty,
- let uttype = UTType(filenameExtension: fileExtension)
- {
- return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
- } else if let fileType = self.fileType,
- let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()],
- let uttype = UTType(filenameExtension: fileTypeExtension)
- {
- return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
- } else {
- return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data))
- }
-
- // Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content))
- }
- #endif
-
- var body: some View {
- fileIcon()
- .resizable()
- .scaledToFit()
- }
-}
diff --git a/Hotline/Shared/NetSocket.swift b/Hotline/Shared/NetSocket.swift
deleted file mode 100644
index 59c26af..0000000
--- a/Hotline/Shared/NetSocket.swift
+++ /dev/null
@@ -1,272 +0,0 @@
-// NetSocket.swift
-// A simple delegate based buffered read/write TCP socket.
-// Created by Dustin Mierau
-
-import Foundation
-
-protocol NetSocketDelegate: AnyObject {
- @MainActor func netsocketConnected(socket: NetSocket)
- @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?)
- @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8])
- func netsocketSent(socket: NetSocket, count: Int)
-}
-
-extension NetSocketDelegate {
- func netsocketConnected(socket: NetSocket) {}
- func netsocketDisconnected(socket: NetSocket, error: Error?) {}
- func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {}
- func netsocketSent(socket: NetSocket, count: Int) {}
-}
-
-enum NetSocketStatus {
- case disconnected
- case connecting
- case connected
-}
-
-final class NetSocket: NSObject, StreamDelegate {
- weak var delegate: NetSocketDelegate? = nil
-
- private var output: OutputStream? = nil
- private var input: InputStream? = nil
-
- private var outputBuffer: [UInt8] = []
- private var inputBuffer: [UInt8] = []
-
- private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024)
-
- public func peek() -> [UInt8] { self.inputBuffer }
- public var available: Int { self.inputBuffer.count }
-
- private var status: NetSocketStatus = .disconnected
-
- @MainActor public func has(_ length: Int) -> Bool {
- return (self.available >= length)
- }
-
- override init() {}
-
- @MainActor public func connect(host: String, port: Int) {
- self.close()
-
- var outputStream: OutputStream? = nil
- var inputStream: InputStream? = nil
-
- self.status = .connecting
-
- Stream.getStreamsToHost(
- withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream)
-
- self.input = inputStream
- self.output = outputStream
-
- inputStream?.delegate = self
- outputStream?.delegate = self
-
- inputStream?.schedule(in: .current, forMode: .default)
- outputStream?.schedule(in: .current, forMode: .default)
-
- inputStream?.open()
- outputStream?.open()
- }
-
- @MainActor public func close(_ err: Error? = nil) {
- print("NetSocket: Closed")
-
- let disconnected = (self.status != .disconnected)
-
- self.status = .disconnected
-
- self.input?.delegate = nil
- self.output?.delegate = nil
- self.input?.close()
- self.output?.close()
- self.input?.remove(from: .current, forMode: .default)
- self.output?.remove(from: .current, forMode: .default)
- self.input = nil
- self.output = nil
- self.inputBuffer = []
- self.outputBuffer = []
-
- if disconnected {
- self.delegate?.netsocketDisconnected(socket: self, error: err)
- }
- }
-
- @MainActor public func write(_ data: Data) {
- guard let output = self.output else {
- return
- }
-
- self.outputBuffer.append(contentsOf: data)
-
- if output.hasSpaceAvailable {
- self.writeBufferToStream()
- }
- }
-
- @MainActor public func write(_ data: [UInt8]) {
- guard let output = self.output else {
- return
- }
-
- self.outputBuffer.append(contentsOf: data)
-
- if output.hasSpaceAvailable {
- self.writeBufferToStream()
- }
- }
-
- @MainActor public func read(count: Int) -> [UInt8] {
- guard self.inputBuffer.count > 0, count > 0 else {
- return []
- }
-
- let amountToRead = min(count, self.inputBuffer.count)
- let dataRead: [UInt8] = Array(self.inputBuffer[0..<amountToRead])
- self.inputBuffer.removeFirst(amountToRead)
-
- return dataRead
- }
-
- @MainActor public func read(count: Int) -> Data {
- guard self.inputBuffer.count > 0, count > 0 else {
- return Data()
- }
-
- let amountToRead = min(count, self.inputBuffer.count)
-
- let dataRead: Data = Data(self.inputBuffer[0..<amountToRead])
- self.inputBuffer.removeFirst(amountToRead)
-
- return dataRead
- }
-
- @MainActor public func readAll() -> [UInt8] {
- guard self.inputBuffer.count > 0 else {
- return []
- }
-
- let dataRead: [UInt8] = Array(self.inputBuffer)
- self.inputBuffer = []
-
- return dataRead
- }
-
- @MainActor public func readAll() -> Data {
- guard self.inputBuffer.count > 0 else {
- return Data()
- }
-
- let dataRead: Data = Data(self.inputBuffer)
- self.inputBuffer = []
-
- return dataRead
- }
-
- @MainActor private func writeBufferToStream() {
- guard let output = self.output, self.outputBuffer.count > 0 else {
- return
- }
-
- let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count)
- print("NetSocket => \(bytesWritten) bytes")
- if bytesWritten > 0 {
- self.outputBuffer.removeFirst(bytesWritten)
- self.delegate?.netsocketSent(socket: self, count: bytesWritten)
- } else if bytesWritten == -1 {
- self.close(output.streamError)
- }
- }
-
- @MainActor private func readStreamToBuffer() {
- guard let input = self.input else {
- return
- }
-
- let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024)
- print("NetSocket <= \(bytesRead) bytes")
- if bytesRead > 0 {
- self.inputBuffer.append(contentsOf: self.readBuffer[0..<bytesRead])
- self.delegate?.netsocketReceived(socket: self, bytes: self.inputBuffer)
- } else if bytesRead == -1 {
- self.close(input.streamError)
- }
- }
-
- // MARK: -
-
- @MainActor func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
- guard let input = self.input, let output = self.output else {
- return
- }
-
- switch eventCode {
- case .openCompleted:
- if aStream == input {
- self.setupStreamOptions()
- }
- if input.streamStatus == .open && output.streamStatus == .open {
- if self.status == .connecting {
- print("NetSocket: Connected")
- self.status = .connected
- self.delegate?.netsocketConnected(socket: self)
- }
- }
- break
- case .hasBytesAvailable:
- print("NetSocket: Data available")
- self.readStreamToBuffer()
- case .hasSpaceAvailable:
- print("NetSocket: Space available")
- self.writeBufferToStream()
- case .endEncountered:
- print("NetSocket: End encounterd")
- self.close()
- case .errorOccurred:
- print(
- "NetSocket: Error encounterd", input.streamError.debugDescription,
- output.streamError.debugDescription)
- let err = input.streamError ?? output.streamError
- self.close(err)
- default:
- break
- }
- }
-
- // MARK: -
-
- private func setupStreamOptions() {
- if let input = self.input {
- let socketData: Data =
- CFReadStreamCopyProperty(input as CFReadStream, CFStreamPropertyKey.socketNativeHandle)
- as! Data
- var socketHandle: CFSocketNativeHandle = 0
- (socketData as NSData).getBytes(
- &socketHandle, length: MemoryLayout.size(ofValue: socketHandle))
-
- var value: Int = 0
- let size = UInt32(MemoryLayout.size(ofValue: value))
-
- value = 1
- if setsockopt(socketHandle, IPPROTO_TCP, TCP_NODELAY, &value, size) != 0 {
- print("NetSocket: failed to set TCP_NODELAY")
- }
- // Enable keepalive
- value = 1
- if setsockopt(socketHandle, SOL_SOCKET, SO_KEEPALIVE, &value, size) != 0 {
- print("NetSocket: failed to set SO_KEEPALIVE")
- }
- // // Number of keepalives before close (including first keepalive packet)
- // value = 5
- // if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPCNT, &value, size) != 0 {
- // print("NetSocket: failed to set TCP_KEEPCNT");
- // }
- // // Idle time used when SO_KEEPALIVE is enabled. Sets how long connection must be idle before keepalive is sent.
- // value = 60
- // if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPALIVE, &value, size) != 0 {
- // print("NetSocket: failed to set TCP_KEEPALIVE")
- // }
- }
- }
-}
diff --git a/Hotline/Shared/URLAdditions.swift b/Hotline/Shared/URLAdditions.swift
deleted file mode 100644
index 5e6a450..0000000
--- a/Hotline/Shared/URLAdditions.swift
+++ /dev/null
@@ -1,27 +0,0 @@
-import Foundation
-
-extension URL {
- func generateUniqueFilePath(filename base: String) -> String {
- let fileManager = FileManager.default
- var finalName = base
- var counter = 2
-
- // Helper function to generate a new filename with a counter
- func makeFileName() -> String {
- let baseName = (base as NSString).deletingPathExtension
- let extensionName = (base as NSString).pathExtension
- return extensionName.isEmpty
- ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)"
- }
-
- // Check if file exists and append counter until a unique name is found
- var filePath = self.appending(component: finalName).path(percentEncoded: false)
- while fileManager.fileExists(atPath: filePath) {
- finalName = makeFileName()
- filePath = self.appending(component: finalName).path(percentEncoded: false)
- counter += 1
- }
-
- return filePath
- }
-}
diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift
new file mode 100644
index 0000000..e268ba2
--- /dev/null
+++ b/Hotline/State/AppState.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+
+extension EnvironmentValues {
+ @Entry var appState: AppState = AppState.shared
+}
+
+@Observable
+final class AppState {
+ static let shared = AppState()
+
+ private init() {
+
+ }
+
+ var bonjourState = BonjourState()
+
+ var activeHotline: HotlineState? = nil
+ var activeServerState: ServerState? = nil
+
+ var cloudKitReady: Bool = false
+
+ // MARK: - Transfers
+
+ /// All active transfers across all servers
+ /// Transfers persist even if you disconnect from the server
+ var transfers: [TransferInfo] = []
+
+ @ObservationIgnored private var transferClients: [UUID: HotlineTransferClient] = [:]
+
+ /// Track download tasks by reference number for cancellation
+ @ObservationIgnored private var transferTasks: [UUID: Task<Void, Never>] = [:]
+
+ /// Add a transfer to the transfer list
+ @MainActor
+ func addTransfer(_ transfer: TransferInfo) {
+ self.transfers.append(transfer)
+ }
+
+ /// Cancel a transfer by transfer ID
+ @MainActor
+ func cancelTransfer(id: UUID) {
+ guard let transferIndex = self.transfers.firstIndex(where: { $0.id == id }) else {
+ return
+ }
+
+ // Cancel the task if it exists
+ if let task = self.transferTasks[id] {
+ task.cancel()
+ self.transferTasks.removeValue(forKey: id)
+ }
+
+ if let client = self.transferClients[id] {
+ client.cancel()
+ self.transferClients.removeValue(forKey: id)
+ }
+
+ // Remove from transfers list
+ self.transfers.remove(at: transferIndex)
+ }
+
+ /// Cancel specified transfers
+ @MainActor
+ func cancelTransfers(ids: [UUID]) {
+ for transferID in ids {
+ self.cancelTransfer(id: transferID)
+ }
+ }
+
+ /// Cancel all active transfers
+ @MainActor
+ func cancelAllTransfers() {
+ for (_, task) in self.transferTasks {
+ task.cancel()
+ }
+ self.transferTasks.removeAll()
+
+ for (_, client) in self.transferClients {
+ client.cancel()
+ }
+ self.transferClients.removeAll()
+
+ // Clear transfers
+ self.transfers.removeAll()
+ }
+
+ /// Remove all completed transfers
+ @MainActor
+ func sweepTransfers() {
+ for t in self.transfers {
+ if t.done {
+ self.cancelTransfer(id: t.id)
+ }
+ }
+ }
+
+ /// Register a transfer task
+ @MainActor
+ func registerTransferTask(_ task: Task<Void, Never>, transferID: UUID) {
+ self.transferTasks[transferID] = task
+ }
+
+ @MainActor
+ func registerTransferTask(_ task: Task<Void, Never>, transferID: UUID, client: HotlineTransferClient) {
+ self.transferTasks[transferID] = task
+ self.transferClients[transferID] = client
+ }
+
+ /// Unregister a download task (called on completion/failure)
+ @MainActor
+ func unregisterTransferTask(for transferID: UUID) {
+ self.transferTasks.removeValue(forKey: transferID)
+ self.transferClients.removeValue(forKey: transferID)
+ }
+}
diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift
new file mode 100644
index 0000000..83006a2
--- /dev/null
+++ b/Hotline/State/AppUpdate.swift
@@ -0,0 +1,346 @@
+import Foundation
+import Observation
+import AppKit
+
+struct UpdateReleaseInfo: Equatable {
+ let tagName: String
+ let displayVersion: String
+ let versionNumber: Double
+ let buildNumber: Int
+ let notes: String
+ let downloadURL: URL
+ let assetName: String
+}
+
+struct AppUpdateMessage: Equatable {
+ enum Kind {
+ case info
+ case success
+ case error
+ }
+
+ let title: String
+ let detail: String
+ let kind: Kind
+}
+
+@Observable
+final class AppUpdate {
+ static let shared = AppUpdate()
+
+ private init() {}
+
+ private enum CheckTrigger {
+ case automatic
+ case manual
+ }
+
+ var isChecking = false
+ var isDownloading = false
+ var showWindow = false
+ var release: UpdateReleaseInfo?
+ var releases: [UpdateReleaseInfo] = []
+ var message: AppUpdateMessage?
+ var userInitiated = false
+ var releaseNotesCombined: String?
+
+ // MARK: - Configuration
+
+ private let releasesURL = URL(string: "https://api.github.com/repos/mierau/hotline/releases?per_page=100")!
+ private let remindInterval: TimeInterval = 60 * 60 * 24 * 14
+
+ private let defaults = UserDefaults.standard
+ private let remindDateKey = "update.remind.date"
+ private let lastPromptedVersionKey = "update.last.prompt.version"
+
+ // MARK: - API
+
+ func checkForUpdatesOnLaunch() async {
+ await checkForUpdates(trigger: .automatic)
+ }
+
+ func checkForUpdatesManually() async {
+ await checkForUpdates(trigger: .manual)
+ }
+
+ @MainActor
+ func startDownload() {
+ guard let release, isDownloading == false else { return }
+
+ isDownloading = true
+ message = nil
+
+ Task(priority: .userInitiated) {
+ await self.downloadRelease(release)
+ }
+ }
+
+ @MainActor
+ func remindLater() {
+ guard let release else {
+ resetAndCloseWindow()
+ return
+ }
+
+ recordPrompt(for: release, remindLater: true)
+ resetAndCloseWindow()
+ }
+
+ @MainActor
+ func acknowledgeMessage() {
+ resetAndCloseWindow()
+ }
+
+ @MainActor
+ func handleWindowDismissed() {
+ guard showWindow else { return }
+
+ resetAndCloseWindow()
+ }
+
+ // MARK: - Implementation
+
+ private func checkForUpdates(trigger: CheckTrigger) async {
+ await MainActor.run {
+ self.isChecking = true
+ self.userInitiated = (trigger == .manual)
+ self.message = nil
+ self.releases = []
+ self.release = nil
+ self.releaseNotesCombined = nil
+ self.isDownloading = false
+ if trigger == .manual {
+ self.showWindow = false
+ }
+ }
+
+ do {
+ let newerReleases = try await fetchNewerReleases()
+ let latestRelease = newerReleases.first
+ let shouldShow: Bool
+ switch trigger {
+ case .manual:
+ shouldShow = latestRelease != nil
+ case .automatic:
+ if let latestRelease {
+ shouldShow = shouldPrompt(for: latestRelease)
+ } else {
+ shouldShow = false
+ }
+ }
+
+ await MainActor.run {
+ self.isChecking = false
+ if shouldShow, let latestRelease {
+ self.release = latestRelease
+ self.releases = newerReleases
+ self.releaseNotesCombined = combinedReleaseNotes(from: newerReleases)
+ self.isDownloading = false
+ self.message = nil
+ self.showWindow = true
+ } else {
+ self.release = nil
+ self.releases = []
+ self.releaseNotesCombined = nil
+ self.isDownloading = false
+ if trigger == .manual {
+ self.showUpToDateAlert()
+// self.message = AppUpdateMessage(
+// title: "Hotline is up to date",
+// detail: "You're running the latest and greatest.",
+// kind: .success
+// )
+// self.showWindow = true
+ } else {
+ self.message = nil
+ self.showWindow = false
+ }
+ }
+ }
+ } catch {
+ await MainActor.run {
+ self.isChecking = false
+ self.release = nil
+ self.releases = []
+ self.releaseNotesCombined = nil
+ self.isDownloading = false
+ if trigger == .manual {
+ self.message = AppUpdateMessage(
+ title: "Unable to Check for Updates",
+ detail: error.localizedDescription,
+ kind: .error
+ )
+ self.showWindow = true
+ }
+ }
+ }
+ }
+
+ private func showUpToDateAlert() {
+ let alert = NSAlert()
+ alert.messageText = "No Update Available"
+ alert.informativeText = "You are already using the latest version of Hotline!"
+ alert.runModal()
+ }
+
+ private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] {
+ let (data, _) = try await URLSession.shared.data(from: releasesURL)
+ guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else {
+ throw NSError(domain: "AppUpdate", code: -10, userInfo: [NSLocalizedDescriptionKey: "Malformed GitHub releases response."])
+ }
+
+ let parsed = jsonArray.compactMap(parseRelease)
+ let newer = parsed.filter(isReleaseNewer)
+ return newer
+ }
+
+ private func parseRelease(_ json: [String: Any]) -> UpdateReleaseInfo? {
+ guard let tagName = json["tag_name"] as? String else {
+ return nil
+ }
+
+ let notes = (json["body"] as? String) ?? ""
+ guard
+ let assets = json["assets"] as? [[String: Any]],
+ let asset = assets.first,
+ let downloadString = asset["browser_download_url"] as? String,
+ let downloadURL = URL(string: downloadString)
+ else {
+ return nil
+ }
+
+ let assetName = (asset["name"] as? String) ?? downloadURL.lastPathComponent
+
+ let versionPattern = #"^([0-9\.]+)beta([0-9]+)"#
+ guard let regex = try? NSRegularExpression(pattern: versionPattern, options: []) else {
+ return nil
+ }
+
+ let range = NSRange(location: 0, length: tagName.utf16.count)
+ guard let match = regex.firstMatch(in: tagName, options: [], range: range),
+ match.numberOfRanges >= 3,
+ let versionRange = Range(match.range(at: 1), in: tagName),
+ let buildRange = Range(match.range(at: 2), in: tagName),
+ let versionNumber = Double(String(tagName[versionRange])),
+ let buildNumber = Int(String(tagName[buildRange]))
+ else {
+ return nil
+ }
+
+ let versionComponent = String(tagName[versionRange])
+ let buildComponent = String(tagName[buildRange])
+ let displayVersion = "\(versionComponent)b\(buildComponent)"
+
+ return UpdateReleaseInfo(
+ tagName: tagName,
+ displayVersion: displayVersion,
+ versionNumber: versionNumber,
+ buildNumber: buildNumber,
+ notes: notes,
+ downloadURL: downloadURL,
+ assetName: assetName
+ )
+ }
+
+ private func combinedReleaseNotes(from releases: [UpdateReleaseInfo]) -> String? {
+ guard let firstRelease = releases.first else { return nil }
+
+ let firstNotes = firstRelease.notes.trimmingCharacters(in: .whitespacesAndNewlines)
+ let firstBody = firstNotes.isEmpty ? "_No release notes provided._" : firstNotes
+
+ guard releases.count > 1 else {
+ return firstBody
+ }
+
+ let olderSections = releases.dropFirst().map { release -> String in
+ let trimmed = release.notes.trimmingCharacters(in: .whitespacesAndNewlines)
+ let body = trimmed.isEmpty ? "_No release notes provided._" : trimmed
+ return "## Hotline \(release.displayVersion)\n\n\(body)"
+ }
+
+ let olderCombined = olderSections.joined(separator: "\n\n---\n\n")
+ return "\(firstBody)\n\n---\n\n\(olderCombined)"
+ }
+
+ private func downloadRelease(_ release: UpdateReleaseInfo) async {
+ do {
+ let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL)
+ let destinationURL = URL.downloadsDirectory.appendingPathComponent(release.assetName)
+
+ if FileManager.default.fileExists(atPath: destinationURL.path) {
+ try? FileManager.default.removeItem(at: destinationURL)
+ }
+
+ try FileManager.default.moveItem(at: temporaryURL, to: destinationURL)
+
+ await MainActor.run {
+ self.isDownloading = false
+ NSWorkspace.shared.activateFileViewerSelecting([destinationURL])
+ self.resetAndCloseWindow()
+ }
+ } catch {
+ await MainActor.run {
+ self.isDownloading = false
+ self.message = AppUpdateMessage(
+ title: "Download Failed",
+ detail: error.localizedDescription,
+ kind: .error
+ )
+ self.showWindow = true
+ }
+ }
+ }
+
+ private func currentApplicationVersion() -> (version: Double, build: Int) {
+ let info = Bundle.main.infoDictionary ?? [:]
+ let versionString = info["CFBundleShortVersionString"] as? String ?? "0"
+ let buildString = info["CFBundleVersion"] as? String ?? "0"
+ let version = Double(versionString) ?? 0
+ let build = Int(buildString) ?? 0
+ return (version, build)
+ }
+
+ private func isReleaseNewer(_ release: UpdateReleaseInfo) -> Bool {
+ let current = currentApplicationVersion()
+ if release.versionNumber > current.version {
+ return true
+ }
+ if release.versionNumber == current.version {
+ return release.buildNumber > current.build
+ }
+ return false
+ }
+
+ private func shouldPrompt(for release: UpdateReleaseInfo) -> Bool {
+ if defaults.string(forKey: lastPromptedVersionKey) != release.tagName {
+ return true
+ }
+ guard let remindDate = defaults.object(forKey: remindDateKey) as? Date else {
+ return true
+ }
+ return remindDate <= Date()
+ }
+
+ @MainActor
+ private func recordPrompt(for release: UpdateReleaseInfo, remindLater: Bool) {
+ defaults.set(release.tagName, forKey: lastPromptedVersionKey)
+ if remindLater {
+ let nextReminder = Date().addingTimeInterval(remindInterval)
+ defaults.set(nextReminder, forKey: remindDateKey)
+ } else {
+ defaults.removeObject(forKey: remindDateKey)
+ }
+ }
+
+ @MainActor
+ private func resetAndCloseWindow() {
+ isChecking = false
+ isDownloading = false
+ release = nil
+ releases = []
+ releaseNotesCombined = nil
+ message = nil
+ userInitiated = false
+ showWindow = false
+ }
+}
diff --git a/Hotline/State/BonjourState.swift b/Hotline/State/BonjourState.swift
new file mode 100644
index 0000000..b0bb42c
--- /dev/null
+++ b/Hotline/State/BonjourState.swift
@@ -0,0 +1,258 @@
+import Foundation
+import Network
+
+@Observable
+class BonjourState {
+ var isExpanded: Bool = false
+ var isBrowsing: Bool = false
+ var discoveredServers: [BonjourServer] = []
+
+ private var browser: NWBrowser?
+ private var resolutionTasks: [UUID: Task<Void, Never>] = [:]
+
+ private actor ConnectionResolverState {
+ var completed = false
+ func markComplete() {
+ self.completed = true
+ }
+ }
+
+ struct BonjourServer: Identifiable, Hashable {
+ let id = UUID()
+ let serviceName: String
+ let name: String
+ let address: String?
+ let port: UInt16?
+ let txtRecords: [String: String]
+
+ var displayName: String {
+ // Use the advertised name, fall back to service name
+ self.name.isEmpty ? self.serviceName : self.name
+ }
+
+ var server: Server? {
+ guard let address = self.address,
+ let port = self.port else {
+ return nil
+ }
+ return Server(name: self.displayName, description: nil, address: address, port: Int(port))
+ }
+
+ var isLoopback: Bool {
+ guard let address = self.address else { return false }
+ return address.hasPrefix("127.") || address.hasPrefix("::1")
+ }
+
+ static func == (lhs: BonjourServer, rhs: BonjourServer) -> Bool {
+ lhs.address == rhs.address && lhs.port == rhs.port
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(self.id)
+ }
+ }
+
+ func startBrowsing() {
+ guard !self.isBrowsing else {
+ return
+ }
+
+ self.isBrowsing = true
+ self.discoveredServers.removeAll()
+
+ let parameters = NWParameters()
+ parameters.includePeerToPeer = true
+
+ self.browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_hotline._tcp", domain: nil), using: parameters)
+
+ self.browser?.stateUpdateHandler = { [weak self] newState in
+ Task { @MainActor in
+ switch newState {
+ case .ready:
+ print("BonjourState: Browser ready")
+ case .failed(let error):
+ print("BonjourState: Browser failed: \(error)")
+ self?.stopBrowsing()
+ case .cancelled:
+ print("BonjourState: Browser cancelled")
+ self?.isBrowsing = false
+ default:
+ break
+ }
+ }
+ }
+
+ self.browser?.browseResultsChangedHandler = { [weak self] results, changes in
+ guard let self = self else {
+ return
+ }
+
+ Task { @MainActor in
+ print("BonjourState: Browse results changed, found \(results.count) services")
+
+ // Handle removed services
+ for change in changes {
+ if case .removed(let result) = change {
+ if case .service(let name, _, _, _) = result.endpoint {
+ self.discoveredServers.removeAll { $0.serviceName == name }
+ print("BonjourState: Removed service: \(name)")
+ }
+ }
+ }
+
+ // Handle added/updated services
+ for change in changes {
+ if case .added(let result) = change, case .service = result.endpoint {
+ await self.resolveService(result)
+ } else if case .changed(_, let new, _) = change, case .service = new.endpoint {
+ await self.resolveService(new)
+ }
+ }
+ }
+ }
+
+ self.browser?.start(queue: .main)
+ }
+
+ private func cleanAddress(_ addressString: String) -> String {
+ // For link-local IPv6 addresses (fe80::), we MUST keep the zone identifier
+ // because it tells the system which network interface to use for routing
+ // For all other addresses (global IPv6, IPv4), strip the zone identifier
+ if addressString.hasPrefix("fe80:") {
+ return addressString
+ }
+
+ return addressString.components(separatedBy: "%").first ?? addressString
+ }
+
+ private func resolveService(_ result: NWBrowser.Result) async {
+ guard case .service(let name, _, _, _) = result.endpoint else {
+ return
+ }
+
+ // Create a connection to resolve the service
+ let connection = NWConnection(to: result.endpoint, using: .tcp)
+ let resolver = ConnectionResolverState()
+
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
+ connection.stateUpdateHandler = { [weak self] state in
+ Task { @MainActor in
+ if Task.isCancelled {
+ await resolver.markComplete()
+ return
+ }
+
+ if await resolver.completed {
+ return
+ }
+
+ switch state {
+ case .ready:
+ await resolver.markComplete()
+
+ // Extract address and port
+ var address: String?
+ var port: UInt16?
+
+ guard let path = connection.currentPath, let endpoint = path.remoteEndpoint else {
+ return
+ }
+
+ var isLoopback: Bool = false
+ if path.usesInterfaceType(.loopback) {
+ isLoopback = true
+ }
+
+ if case .hostPort(let host, let nwPort) = endpoint {
+ switch host {
+ case .ipv4(let ipv4):
+ address = self?.cleanAddress(ipv4.debugDescription)
+ case .ipv6(let ipv6):
+ let ipv6String = ipv6.debugDescription
+ address = self?.cleanAddress(ipv6String)
+ case .name(let hostname, _):
+ address = hostname
+ @unknown default:
+ break
+ }
+
+ if isLoopback {
+ address = "127.0.0.1"
+ }
+ port = nwPort.rawValue
+ }
+
+ // Parse TXT records
+ var txtRecords: [String: String] = [:]
+ if case .bonjour(let txtRecord) = result.metadata {
+ for (key, value) in txtRecord.dictionary {
+ txtRecords[key] = value
+ }
+ }
+
+ let server = BonjourServer(
+ serviceName: name,
+ name: name,
+ address: address,
+ port: port,
+ txtRecords: txtRecords
+ )
+
+ // Update or add server
+ if let index = self?.discoveredServers.firstIndex(where: { $0.serviceName ==
+ name }) {
+ self?.discoveredServers[index] = server
+ } else {
+ self?.discoveredServers.append(server)
+ }
+
+ connection.cancel()
+ continuation.resume()
+
+ case .failed(let error):
+ await resolver.markComplete()
+
+ print("BonjourState: Failed to resolve \(name): \(error)")
+ connection.cancel()
+ continuation.resume()
+
+ default:
+ break
+ }
+ }
+ }
+
+ connection.start(queue: .main)
+
+ // Timeout after 5 seconds
+ Task {
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+
+ if await resolver.completed == false {
+ await resolver.markComplete()
+ connection.cancel()
+ continuation.resume()
+ }
+ }
+ }
+ }
+
+ func stopBrowsing() {
+ guard self.isBrowsing else {
+ return
+ }
+
+ print("BonjourState: Stopping Bonjour browsing")
+
+ // Cancel all resolution tasks
+ for (_, task) in self.resolutionTasks {
+ task.cancel()
+ }
+ self.resolutionTasks.removeAll()
+
+ self.browser?.cancel()
+ self.browser = nil
+ self.isBrowsing = false
+ self.discoveredServers.removeAll()
+ }
+}
diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift
new file mode 100644
index 0000000..ec79f66
--- /dev/null
+++ b/Hotline/State/FilePreviewState.swift
@@ -0,0 +1,185 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+enum FilePreviewType: Equatable {
+ case unknown
+ case image
+ case text
+}
+
+/// State for a file preview download
+@MainActor
+@Observable
+final class FilePreviewState {
+ enum LoadState: Equatable {
+ case unloaded
+ case loading
+ case loaded
+ case failed
+ }
+
+ let info: PreviewFileInfo
+
+ var state: LoadState = .unloaded
+ var progress: Double = 0.0
+
+ var fileURL: URL? = nil
+
+ #if os(iOS)
+ var image: UIImage? = nil
+ #elseif os(macOS)
+ var image: NSImage? = nil
+ #endif
+
+ var text: String? = nil
+ var styledText: NSAttributedString? = nil
+
+ @ObservationIgnored private var previewClient: HotlineFilePreviewClient?
+ @ObservationIgnored private var previewTask: Task<Void, Never>?
+
+ var previewType: FilePreviewType {
+ self.info.previewType
+ }
+
+ init(info: PreviewFileInfo) {
+ self.info = info
+ }
+
+ // MARK: - API
+
+ func download() {
+ // Cancel any existing download
+ self.previewTask?.cancel()
+ self.previewClient?.cleanup()
+
+ let task = Task { @MainActor in
+ do {
+ let client = HotlineFilePreviewClient(
+ fileName: self.info.name,
+ address: self.info.address,
+ port: UInt16(self.info.port),
+ reference: self.info.id,
+ size: UInt32(self.info.size),
+ fileType: self.info.type,
+ fileCreator: self.info.creator
+ )
+ self.previewClient = client
+
+ self.state = .loading
+ self.progress = 0.0
+
+ let url = try await client.preview { [weak self] progress in
+ guard let self else { return }
+
+ Task { @MainActor in
+ switch progress {
+ case .preparing:
+ self.state = .loading
+ self.progress = 0.0
+
+ case .connecting:
+ self.state = .loading
+ self.progress = 0.0
+
+ case .connected:
+ self.state = .loading
+ self.progress = 0.0
+
+ case .transfer(name: _, size: _, total: _, progress: let p, speed: _, estimate: _):
+ self.state = .loading
+ self.progress = p
+
+ case .completed(url: let url):
+ self.state = .loaded
+ self.progress = 1.0
+ self.fileURL = url
+// self.loadPreview(from: url)
+
+ case .error(let error):
+ self.state = .failed
+ self.progress = 0.0
+ print("FilePreviewState: Download failed: \(error)")
+
+ case .unconnected:
+ break
+ }
+ }
+ }
+
+ // Final load if not already loaded
+ if self.state != .loaded {
+ self.state = .loaded
+ self.progress = 1.0
+ self.fileURL = url
+// self.loadPreview(from: url)
+ }
+
+ } catch is CancellationError {
+ // Cancelled, do nothing
+ return
+ } catch {
+ self.state = .failed
+ self.progress = 0.0
+ print("FilePreviewState: Download error: \(error)")
+ }
+ }
+
+ self.previewTask = task
+ }
+
+ func cancel() {
+ self.previewTask?.cancel()
+ self.previewTask = nil
+ self.previewClient?.cancel()
+ }
+
+ func cleanup() {
+ self.previewClient?.cleanup()
+ self.previewClient = nil
+ self.fileURL = nil
+ self.image = nil
+ self.text = nil
+ self.styledText = nil
+ }
+
+ // MARK: - Utility
+
+// private func loadPreview(from url: URL) {
+// guard let data = try? Data(contentsOf: url) else {
+// self.state = .failed
+// print("FilePreviewState: Failed to read preview data from \(url.path)")
+// return
+// }
+//
+// switch self.previewType {
+// case .image:
+// #if os(iOS)
+// self.image = UIImage(data: data)
+// #elseif os(macOS)
+// self.image = NSImage(data: data)
+// #endif
+//
+// if self.image == nil {
+// self.state = .failed
+// print("FilePreviewState: Failed to create image from data")
+// }
+//
+// case .text:
+// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil)
+// if encoding != 0 {
+// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding))
+// } else {
+// self.text = String(data: data, encoding: .utf8)
+// }
+//
+// if self.text == nil {
+// self.state = .failed
+// print("FilePreviewState: Failed to decode text data")
+// }
+//
+// case .unknown:
+// print("FilePreviewState: Unknown preview type for \(info.name)")
+// break
+// }
+// }
+}
diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift
new file mode 100644
index 0000000..df8434d
--- /dev/null
+++ b/Hotline/State/HotlineState.swift
@@ -0,0 +1,2500 @@
+import SwiftUI
+
+// MARK: - Connection Status
+
+enum HotlineConnectionStatus: Equatable {
+ case disconnected
+ case connecting
+ case connected
+ case loggedIn
+ case failed(String)
+
+ var isLoggingIn: Bool {
+ self == .connecting || self == .connected
+ }
+
+ var isConnected: Bool {
+ return self == .connected || self == .loggedIn
+ }
+}
+
+struct FileSearchConfig: Equatable {
+ /// Number of folders we process before we start applying delay backoff.
+ var initialBurstCount: Int = 15
+ /// Base delay applied between folder requests during the backoff phase.
+ var initialDelay: TimeInterval = 0.02
+ /// Multiplier used to increase the delay after each processed folder in backoff.
+ var backoffMultiplier: Double = 1.1
+ /// Maximum delay cap so searches don't stall out during long walks.
+ var maxDelay: TimeInterval = 1.0
+ /// Maximum recursion depth allowed during file search.
+ var maxDepth: Int = 40
+ /// Limit for repeated folder loops (guards against circular server listings).
+ var loopRepetitionLimit: Int = 4
+ /// Number of child folders that get prioritized after a matching parent is found.
+ var hotBurstLimit: Int = 2
+ /// Maximum age, in seconds, that a cached folder listing is treated as fresh.
+ var cacheTTL: TimeInterval = 60 * 15
+ /// Upper bound on the number of folder listings retained in the cache.
+ var maxCachedFolders: Int = 1024 * 3
+}
+
+enum FileSearchStatus: Equatable {
+ case idle
+ case searching(processed: Int, pending: Int)
+ case completed(processed: Int)
+ case cancelled(processed: Int)
+ case failed(String)
+
+ var isActive: Bool {
+ if case .searching = self {
+ return true
+ }
+ return false
+ }
+}
+
+// MARK: - HotlineState
+
+@Observable @MainActor
+class HotlineState: Equatable {
+ let id: UUID = UUID()
+
+ nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool {
+ return lhs.id == rhs.id
+ }
+
+ // MARK: - Static Icon Data
+
+ #if os(macOS)
+ static func getClassicIcon(_ index: Int) -> NSImage? {
+ return NSImage(named: "Classic/\(index)")
+ }
+ #elseif os(iOS)
+ static func getClassicIcon(_ index: Int) -> UIImage? {
+ return UIImage(named: "Classic/\(index)")
+ }
+ #endif
+
+ static let classicIconSet: [Int] = [
+ 141, 149, 150, 151, 172, 184, 204,
+ 2013, 2036, 2037, 2055, 2400, 2505, 2534,
+ 2578, 2592, 4004, 4015, 4022, 4104, 4131,
+ 4134, 4136, 4169, 4183, 4197, 4240, 4247,
+ 128, 129, 130, 131, 132, 133, 134,
+ 135, 136, 137, 138, 139, 140, 142,
+ 143, 144, 145, 146, 147, 148, 152,
+ 153, 154, 155, 156, 157, 158, 159,
+ 160, 161, 162, 163, 164, 165, 166,
+ 167, 168, 169, 170, 171, 173, 174,
+ 175, 176, 177, 178, 179, 180, 181,
+ 182, 183, 185, 186, 187, 188, 189,
+ 190, 191, 192, 193, 194, 195, 196,
+ 197, 198, 199, 200, 201, 202, 203,
+ 205, 206, 207, 208, 209, 212, 214,
+ 215, 220, 233, 236, 237, 243, 244,
+ 277, 410, 414, 500, 666, 1250, 1251,
+ 1968, 1969, 2000, 2001, 2002, 2003, 2004,
+ 2006, 2007, 2008, 2009, 2010, 2011, 2012,
+ 2014, 2015, 2016, 2017, 2018, 2019, 2020,
+ 2021, 2022, 2023, 2024, 2025, 2026, 2027,
+ 2028, 2029, 2030, 2031, 2032, 2033, 2034,
+ 2035, 2038, 2040, 2041, 2042, 2043, 2044,
+ 2045, 2046, 2047, 2048, 2049, 2050, 2051,
+ 2052, 2053, 2054, 2056, 2057, 2058, 2059,
+ 2060, 2061, 2062, 2063, 2064, 2065, 2066,
+ 2067, 2070, 2071, 2072, 2073, 2075, 2079,
+ 2098, 2100, 2101, 2102, 2103, 2104, 2105,
+ 2106, 2107, 2108, 2109, 2110, 2112, 2113,
+ 2115, 2116, 2117, 2118, 2119, 2120, 2121,
+ 2122, 2123, 2124, 2125, 2126, 4150, 2223,
+ 2401, 2402, 2403, 2404, 2500, 2501, 2502,
+ 2503, 2504, 2506, 2507, 2528, 2529, 2530,
+ 2531, 2532, 2533, 2535, 2536, 2537, 2538,
+ 2539, 2540, 2541, 2542, 2543, 2544, 2545,
+ 2546, 2547, 2548, 2549, 2550, 2551, 2552,
+ 2553, 2554, 2555, 2556, 2557, 2558, 2559,
+ 2560, 2561, 2562, 2563, 2564, 2565, 2566,
+ 2567, 2568, 2569, 2570, 2571, 2572, 2573,
+ 2574, 2575, 2576, 2577, 2579, 2580, 2581,
+ 2582, 2583, 2584, 2585, 2586, 2587, 2588,
+ 2589, 2590, 2591, 2593, 2594, 2595, 2596,
+ 2597, 2598, 2599, 2600, 4000, 4001, 4002,
+ 4003, 4005, 4006, 4007, 4008, 4009, 4010,
+ 4011, 4012, 4013, 4014, 4016, 4017, 4018,
+ 4019, 4020, 4021, 4023, 4024, 4025, 4026,
+ 4027, 4028, 4029, 4030, 4031, 4032, 4033,
+ 4034, 4035, 4036, 4037, 4038, 4039, 4040,
+ 4041, 4042, 4043, 4044, 4045, 4046, 4047,
+ 4048, 4049, 4050, 4051, 4052, 4053, 4054,
+ 4055, 4056, 4057, 4058, 4059, 4060, 4061,
+ 4062, 4063, 4064, 4065, 4066, 4067, 4068,
+ 4069, 4070, 4071, 4072, 4073, 4074, 4075,
+ 4076, 4077, 4078, 4079, 4080, 4081, 4082,
+ 4083, 4084, 4085, 4086, 4087, 4088, 4089,
+ 4090, 4091, 4092, 4093, 4094, 4095, 4096,
+ 4097, 4098, 4099, 4100, 4101, 4102, 4103,
+ 4105, 4106, 4107, 4108, 4109, 4110, 4111,
+ 4112, 4113, 4114, 4115, 4116, 4117, 4118,
+ 4119, 4120, 4121, 4122, 4123, 4124, 4125,
+ 4126, 4127, 4128, 4129, 4130, 4132, 4133,
+ 4135, 4137, 4138, 4139, 4140, 4141, 4142,
+ 4143, 4144, 4145, 4146, 4147, 4148, 4149,
+ 4151, 4152, 4153, 4154, 4155, 4156, 4157,
+ 4158, 4159, 4160, 4161, 4162, 4163, 4164,
+ 4165, 4166, 4167, 4168, 4170, 4171, 4172,
+ 4173, 4174, 4175, 4176, 4177, 4178, 4179,
+ 4180, 4181, 4182, 4184, 4185, 4186, 4187,
+ 4188, 4189, 4190, 4191, 4192, 4193, 4194,
+ 4195, 4196, 4198, 4199, 4200, 4201, 4202,
+ 4203, 4204, 4205, 4206, 4207, 4208, 4209,
+ 4210, 4211, 4212, 4213, 4214, 4215, 4216,
+ 4217, 4218, 4219, 4220, 4221, 4222, 4223,
+ 4224, 4225, 4226, 4227, 4228, 4229, 4230,
+ 4231, 4232, 4233, 4234, 4235, 4236, 4238,
+ 4241, 4242, 4243, 4244, 4245, 4246, 4248,
+ 4249, 4250, 4251, 4252, 4253, 4254, 31337,
+ 6001, 6002, 6003, 6004, 6005, 6008, 6009,
+ 6010, 6011, 6012, 6013, 6014, 6015, 6016,
+ 6017, 6018, 6023, 6025, 6026, 6027, 6028,
+ 6029, 6030, 6031, 6032, 6033, 6034, 6035
+ ]
+
+ // MARK: - Observable State
+
+ var status: HotlineConnectionStatus = .disconnected
+ var server: Server? {
+ didSet {
+ self.updateServerTitle()
+ }
+ }
+ var serverVersion: UInt16 = 123
+ var serverName: String? {
+ didSet {
+ self.updateServerTitle()
+ }
+ }
+ var serverTitle: String = ""
+ var username: String = "guest"
+ var iconID: Int = 414
+ var access: HotlineUserAccessOptions?
+ var agreed: Bool = false
+
+ // Users
+ var users: [User] = []
+
+ // Chat
+ var broadcastMessage: String = ""
+ var chat: [ChatMessage] = []
+ var chatInput: String = ""
+ var unreadPublicChat: Bool = false
+
+ // Instant Messages
+ var instantMessages: [UInt16:[InstantMessage]] = [:]
+ var unreadInstantMessages: [UInt16:UInt16] = [:]
+
+ // Message Board
+ var messageBoard: [String] = []
+ var messageBoardLoaded: Bool = false
+
+ // News
+ var news: [NewsInfo] = []
+ var newsLoaded: Bool = false
+ private var newsLookup: [String:NewsInfo] = [:]
+
+ // Files
+ var files: [FileInfo] = []
+ var filesLoaded: Bool = false
+
+ // Accounts
+ var accounts: [HotlineAccount] = []
+ var accountsLoaded: Bool = false
+
+ // Banner
+ var bannerFileURL: URL? = nil
+ var bannerImageFormat: Data.ImageFormat = .unknown
+ #if os(macOS)
+ var bannerImage: Image? = nil
+ var bannerColors: ColorArt? = nil
+ #elseif os(iOS)
+ var bannerImage: UIImage? = nil
+ #endif
+
+ // Transfers (now stored globally in AppState)
+ /// Returns all transfers associated with this server
+ var transfers: [TransferInfo] {
+ AppState.shared.transfers.filter { $0.serverID == self.id }
+ }
+
+ // Legacy transfer tracking (for old delegate-based downloads)
+// @ObservationIgnored private var downloads: [HotlineTransferClient] = []
+ @ObservationIgnored private var bannerDownloadTask: Task<Void, Never>? = nil
+
+ // File Search
+ var fileSearchResults: [FileInfo] = []
+ var fileSearchStatus: FileSearchStatus = .idle
+ var fileSearchQuery: String = ""
+ var fileSearchConfig = FileSearchConfig()
+ var fileSearchScannedFolders: Int = 0
+ var fileSearchCurrentPath: [String]? = nil
+ @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil
+ @ObservationIgnored private var fileSearchResultKeys: Set<String> = []
+
+ // File List Cache
+ private struct FileListCacheEntry {
+ let files: [FileInfo]
+ let timestamp: Date
+ }
+ @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:]
+
+ // Error Display
+ var errorDisplayed: Bool = false
+ var errorMessage: String? = nil
+
+ // MARK: - Private State
+
+ @ObservationIgnored private var client: HotlineClient?
+ @ObservationIgnored private var eventTask: Task<Void, Never>?
+ @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey?
+ @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey?
+ @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol?
+ @ObservationIgnored private var lastPersistedMessageType: ChatMessageType?
+
+ // MARK: - Initialization
+
+ init() {
+ self.chatHistoryObserver = NotificationCenter.default.addObserver(
+ forName: ChatStore.historyClearedNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ Task { @MainActor [weak self] in
+ self?.handleChatHistoryCleared()
+ }
+ }
+ }
+
+ deinit {
+ if let observer = self.chatHistoryObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ }
+
+ // MARK: - Connection
+
+ @MainActor
+ func login(server: Server, username: String, iconID: Int) async throws {
+ print("HotlineState.login(): Starting login to \(server.address):\(server.port)")
+ self.server = server
+ self.username = username
+ self.iconID = iconID
+ self.status = .connecting
+ print("HotlineState.login(): Status set to connecting")
+
+ // Set up chat session
+ let key = self.sessionKey(for: server)
+ self.chatSessionKey = key
+ self.restoredChatSessionKey = nil
+ self.lastPersistedMessageType = nil
+ self.chat = []
+ self.restoreChatHistory(for: key)
+ print("HotlineState.login(): Chat session set up")
+
+ do {
+ // Connect and login
+ let loginInfo = HotlineLogin(
+ login: server.login,
+ password: server.password,
+ username: username,
+ iconID: UInt16(iconID)
+ )
+
+ print("HotlineState.login(): Calling HotlineClient.connect()...")
+ let client = try await HotlineClient.connect(
+ host: server.address,
+ port: UInt16(server.port),
+ login: loginInfo
+ )
+ print("HotlineState.login(): HotlineClient.connect() returned")
+
+ self.client = client
+ print("HotlineState.login(): Client stored")
+
+ // Get server info
+ print("HotlineState.login(): Getting server info...")
+ if let serverInfo = await client.server {
+ self.serverVersion = serverInfo.version
+ if let name = serverInfo.name {
+ self.serverName = name
+ }
+ print("HotlineState.login(): Server info retrieved: \(self.serverTitle) v\(serverInfo.version)")
+ }
+
+ self.status = .connected
+ print("HotlineState.login(): Status set to connected")
+
+ // Request initial data before starting event loop
+ print("HotlineState.login(): Requesting user list...")
+ try await self.getUserList()
+
+ self.status = .loggedIn
+ print("HotlineState.login(): Status set to loggedIn")
+
+ if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound {
+ SoundEffects.play(.loggedIn)
+ }
+
+ print("HotlineState.login(): Connected to \(self.serverTitle)")
+ print("HotlineState.login(): Scheduling post-login tasks...")
+
+ // Defer event loop and post-login work to avoid layout recursion
+ // This allows login() to return and SwiftUI to complete its layout pass
+ // before we start receiving events that trigger state changes
+ Task { @MainActor in
+ print("HotlineState: Post-login: Starting event loop...")
+ self.startEventLoop()
+
+ print("HotlineState: Post-login: Sending preferences...")
+ try? await self.sendUserPreferences()
+
+ print("HotlineState: Post-login: Downloading banner...")
+ self.downloadBanner()
+ }
+
+ }
+ catch let clientError as HotlineClientError {
+ switch clientError {
+ case .connectionFailed(_):
+ self.displayError(clientError, message: "This server appears to be offline.")
+ case .loginFailed(let msg):
+ self.displayError(clientError, message: msg)
+ case .serverError(_, let msg):
+ self.displayError(clientError, message: msg)
+ default:
+ self.displayError(clientError)
+ }
+ if let client = self.client {
+ await client.disconnect()
+ self.client = nil
+ }
+ self.status = .disconnected
+ throw clientError
+ }
+ catch {
+ print("HotlineState.login(): Login failed with error: \(error)")
+ if let client = self.client {
+ await client.disconnect()
+ self.client = nil
+ }
+ self.status = .disconnected
+ self.displayError(error)
+ throw error
+ }
+ }
+
+ private func displayError(_ error: Error, message: String? = nil) {
+ self.errorDisplayed = true
+ self.errorMessage = message ?? error.localizedDescription
+ }
+
+ /// Disconnect from the server (user-initiated)
+ func disconnect() async {
+ print("HotlineState.disconnect(): Called")
+ guard let client = self.client else {
+ print("HotlineState.disconnect(): No client, returning")
+ return
+ }
+
+ // Stop event loop
+ print("HotlineState.disconnect(): Cancelling event task...")
+ self.eventTask?.cancel()
+ self.eventTask = nil
+ print("HotlineState.disconnect(): Event task cancelled")
+
+ // Explicitly close the connection
+ print("HotlineState.disconnect(): Calling client.disconnect()...")
+ await client.disconnect()
+ print("HotlineState.disconnect(): client.disconnect() returned")
+
+ // Clean up state
+ print("HotlineState.disconnect(): Calling handleConnectionClosed()...")
+ self.handleConnectionClosed()
+ print("HotlineState.disconnect(): disconnect() complete")
+ }
+
+ /// Handle connection closure (server-initiated or after user disconnect)
+ private func handleConnectionClosed() {
+ print("HotlineState: handleConnectionClosed() entered")
+ guard self.client != nil else {
+ print("HotlineState: handleConnectionClosed() - client already nil, returning")
+ return
+ }
+
+ print("HotlineState: Handling connection closure - recording chat...")
+
+ // Record disconnect in chat history
+ if self.status == .loggedIn {
+ let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date())
+ self.recordChatMessage(message, persist: true, display: false)
+ }
+
+ print("HotlineState: Cancelling banner and downloads...")
+
+ self.bannerDownloadTask?.cancel()
+ self.bannerDownloadTask = nil
+
+ // Cancel all downloads (both old delegate-based and new async downloads)
+// self.downloads = []
+
+ // Cancel all transfers for this server
+// self.cancelAllDownloads()
+
+ // Cancel file search
+ self.fileSearchSession?.cancel()
+ self.fileSearchSession = nil
+
+ // Clear client reference
+ self.client = nil
+
+ print("HotlineState: Resetting state properties...")
+
+ // Reset state immediately (constraint loop was caused by something else)
+ self.status = .disconnected
+ self.serverVersion = 123
+ self.serverName = nil
+ self.access = nil
+ self.agreed = false
+ self.users = []
+ self.chat = []
+ self.instantMessages = [:]
+ self.unreadInstantMessages = [:]
+ self.unreadPublicChat = false
+ self.messageBoard = []
+ self.messageBoardLoaded = false
+ self.news = []
+ self.newsLoaded = false
+ self.newsLookup = [:]
+ self.files = []
+ self.filesLoaded = false
+ self.accounts = []
+ self.accountsLoaded = false
+ self.bannerImage = nil
+ self.bannerColors = nil
+
+ print("HotlineState: Resetting file search...")
+ self.resetFileSearchState()
+
+ self.chatSessionKey = nil
+ self.restoredChatSessionKey = nil
+ self.lastPersistedMessageType = nil
+
+ print("HotlineState: Disconnected")
+ }
+
+ @MainActor
+ func downloadBanner(force: Bool = false) {
+ guard self.serverVersion >= 150 else {
+ return
+ }
+
+ if force {
+ self.bannerDownloadTask?.cancel()
+ self.bannerDownloadTask = nil
+ self.bannerImage = nil
+ self.bannerImageFormat = .unknown
+ self.bannerFileURL = nil
+ self.bannerColors = nil
+ } else if self.bannerDownloadTask != nil || self.bannerFileURL != nil {
+ return
+ }
+
+ let task = Task { @MainActor [weak self] in
+ defer {
+ self?.bannerDownloadTask = nil
+ }
+
+ guard let self else { return }
+ guard let client = self.client,
+ let server = self.server,
+ let result = try? await client.downloadBanner(),
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ return
+ }
+
+ do {
+ print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)")
+
+ let previewClient = HotlineFilePreviewClient(
+ fileName: "banner",
+ address: address,
+ port: UInt16(port),
+ reference: result.referenceNumber,
+ size: UInt32(result.transferSize)
+ )
+
+ let fileURL = try await previewClient.preview()
+
+ if let oldFileURL = self.bannerFileURL {
+ try? FileManager.default.removeItem(at: oldFileURL)
+ }
+
+ guard self.client != nil else { return }
+
+ let data = try Data(contentsOf: fileURL)
+ self.bannerImageFormat = data.detectedImageFormat
+
+ print("HotlineState: Banner download complete, data size: \(data.count) bytes")
+
+#if os(macOS)
+ guard let image = NSImage(data: data) else {
+ print("HotlineState: Failed to create NSImage from banner data")
+ return
+ }
+ let blah = Image(nsImage: image)
+#elseif os(iOS)
+ guard let image = UIImage(data: data) else {
+ print("HotlineState: Failed to create UIImage from banner data")
+ return
+ }
+ self.bannerImage = Image(uiImage: image)
+#endif
+ self.bannerFileURL = fileURL
+ self.bannerImage = blah
+ self.bannerColors = ColorArt.analyze(image: image)
+
+ } catch {
+ print("HotlineState: Banner download failed: \(error)")
+ }
+ }
+
+ self.bannerDownloadTask = task
+ }
+
+ // MARK: - Event Loop
+
+ private func startEventLoop() {
+ print("HotlineState.startEventLoop(): Called")
+ guard let client = self.client else {
+ print("HotlineState.startEventLoop(): No client, returning")
+ return
+ }
+
+ print("HotlineState.startEventLoop(): Creating event loop task")
+ self.eventTask = Task { @MainActor [weak self, client] in
+ guard let self else {
+ print("HotlineState.startEventLoop(): Self is nil in task, exiting")
+ return
+ }
+
+ print("HotlineState.startEventLoop(): Event loop started, awaiting events...")
+ for await event in client.events {
+ print("HotlineState.startEventLoop(): Received event: \(event)")
+ self.handleEvent(event)
+ }
+
+ // Event stream ended - server disconnected us
+ print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...")
+ self.handleConnectionClosed()
+ print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete")
+ }
+ print("HotlineState.startEventLoop(): Event loop task created")
+ }
+
+ @MainActor
+ private func handleEvent(_ event: HotlineEvent) {
+ switch event {
+ case .chatMessage(let text):
+ self.handleChatMessage(text)
+
+ case .userChanged(let user):
+ self.handleUserChanged(user)
+
+ case .userDisconnected(let userID):
+ self.handleUserDisconnected(userID)
+
+ case .serverMessage(let message):
+ self.handleServerMessage(message)
+
+ case .privateMessage(let userID, let message):
+ self.handlePrivateMessage(userID: userID, message: message)
+
+ case .newsPost(let message):
+ self.handleNewsPost(message)
+
+ case .agreementRequired(let text):
+ let message = ChatMessage(text: text, type: .agreement, date: Date())
+ self.recordChatMessage(message, persist: false)
+
+ case .userAccess(let options):
+ self.access = options
+ print("HotlineState: Got access options")
+ HotlineUserAccessOptions.printAccessOptions(options)
+ }
+ }
+
+ // MARK: - Chat
+
+ @MainActor
+ func sendBroadcast(_ message: String) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.sendBroadcast(message)
+ }
+
+ @MainActor
+ func sendChat(_ text: String, announce: Bool = false) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.sendChat(text, announce: announce)
+ }
+
+ @MainActor
+ func sendInstantMessage(_ text: String, userID: UInt16) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ let message = InstantMessage(
+ direction: .outgoing,
+ text: text.convertingLinksToMarkdown(),
+ type: .message,
+ date: Date()
+ )
+
+ if self.instantMessages[userID] == nil {
+ self.instantMessages[userID] = [message]
+ } else {
+ self.instantMessages[userID]!.append(message)
+ }
+
+ try await client.sendInstantMessage(text, to: userID)
+
+ if Prefs.shared.playPrivateMessageSound {
+ SoundEffects.play(.chatMessage)
+ }
+ }
+
+ @MainActor
+ func sendAgree() async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.sendAgree()
+ self.agreed = true
+ }
+
+ @MainActor
+ /// Send current user preferences from Prefs to the server
+ func sendUserPreferences() async throws {
+ var options: HotlineUserOptions = []
+
+ if Prefs.shared.refusePrivateMessages {
+ options.update(with: .refusePrivateMessages)
+ }
+
+ if Prefs.shared.refusePrivateChat {
+ options.update(with: .refusePrivateChat)
+ }
+
+ if Prefs.shared.enableAutomaticMessage {
+ options.update(with: .automaticResponse)
+ }
+
+ print("HotlineState.sendUserPreferences(): Updating user info with server")
+
+ try await self.sendUserInfo(
+ username: Prefs.shared.username,
+ iconID: Prefs.shared.userIconID,
+ options: options,
+ autoresponse: Prefs.shared.automaticMessage
+ )
+ }
+
+ func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ self.username = username
+ self.iconID = iconID
+
+ try await client.setClientUserInfo(
+ username: username,
+ iconID: UInt16(iconID),
+ options: options,
+ autoresponse: autoresponse
+ )
+ }
+
+ func markPublicChatAsRead() {
+ self.unreadPublicChat = false
+ }
+
+ func hasUnreadInstantMessages(userID: UInt16) -> Bool {
+ return self.unreadInstantMessages[userID] != nil
+ }
+
+ func markInstantMessagesAsRead(userID: UInt16) {
+ self.unreadInstantMessages.removeValue(forKey: userID)
+ }
+
+ @MainActor
+ func searchChat(query: String) -> [ChatMessage] {
+ guard !query.isEmpty else {
+ return []
+ }
+
+ // Create a map of all messages by ID to deduplicate
+ var messageMap: [UUID: ChatMessage] = [:]
+
+ // Add current in-memory messages
+ for message in self.chat {
+ messageMap[message.id] = message
+ }
+
+ // Filter messages based on query
+ let filteredMessages = messageMap.values.filter { message in
+ // Never include agreement messages
+ if message.type == .agreement {
+ return false
+ }
+
+ // Always include disconnect messages to show session boundaries
+ let isDisconnect = message.type == .signOut
+
+ // Search in text and username
+ let matchesText = message.text.localizedCaseInsensitiveContains(query)
+ let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true
+ let matchesQuery = matchesText || matchesUsername
+
+ return isDisconnect || matchesQuery
+ }
+
+ // Sort by date to maintain chronological order
+ let sortedMessages = filteredMessages.sorted { $0.date < $1.date }
+
+ // Remove consecutive disconnect messages to avoid visual clutter
+ var deduplicated: [ChatMessage] = []
+ var lastWasDisconnect = false
+
+ for message in sortedMessages {
+ let isDisconnect = message.type == .signOut
+
+ if isDisconnect && lastWasDisconnect {
+ continue
+ }
+
+ deduplicated.append(message)
+ lastWasDisconnect = isDisconnect
+ }
+
+ // Remove leading disconnect message
+ if deduplicated.first?.type == .signOut {
+ deduplicated.removeFirst()
+ }
+
+ // Remove trailing disconnect message
+ if deduplicated.last?.type == .signOut {
+ deduplicated.removeLast()
+ }
+
+ return deduplicated
+ }
+
+ // MARK: - Users
+
+ func getUserList() async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ let hotlineUsers = try await client.getUserList()
+ self.users = hotlineUsers.map { User(hotlineUser: $0) }
+ }
+
+ func getClientInfoText(id userID: UInt16) async throws -> HotlineUserClientInfo? {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ do {
+ return try await client.getClientInfoText(for: userID)
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ }
+
+ return nil
+ }
+
+ func disconnectUser(id userID: UInt16, options: HotlineUserDisconnectOptions?) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ do {
+ try await client.disconnectUser(userID: userID, options: options)
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ }
+ }
+
+ // MARK: - Files
+
+ @discardableResult
+ func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo]? {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ // Check cache first if preferred
+ if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) {
+ return cached.items
+ }
+
+ let hotlineFiles: [HotlineFile]
+ do {
+ hotlineFiles = try await client.getFileList(path: path)
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ self.filesLoaded = true
+ return nil
+ }
+
+ let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) }
+
+ // Update UI state
+ if path.isEmpty {
+ self.filesLoaded = true
+ self.files = newFiles
+ } else {
+ // Update parent's children
+ let parentFile = self.findFile(in: self.files, at: path)
+ parentFile?.children = newFiles
+ }
+
+ // Cache the result
+ self.storeFileListInCache(newFiles, for: path)
+
+ return newFiles
+ }
+
+ func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+
+ return try await client.getFileInfo(name: fileName, path: fullPath)
+ }
+
+ @discardableResult
+ func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?) async throws -> Bool {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ do {
+ try await client.setFileInfo(name: fileName, path: filePath, newName: fileNewName, comment: comment)
+ self.invalidateFileListCache(for: filePath, includingAncestors: true)
+ return true
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ }
+
+ return false
+ }
+
+ @discardableResult
+ func newFolder(name: String, parentPath: [String]) async throws -> Bool {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ do {
+ try await client.newFolder(name: name, path: parentPath)
+ self.invalidateFileListCache(for: parentPath, includingAncestors: true)
+ return true
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ }
+
+ return false
+ }
+
+ @discardableResult
+ @MainActor
+ func deleteFile(_ fileName: String, path: [String]) async throws -> Bool {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+
+ do {
+ try await client.deleteFile(name: fileName, path: fullPath)
+ self.invalidateFileListCache(for: fullPath, includingAncestors: true)
+ return true
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ }
+
+ return false
+ }
+
+ /// Download a file from the server.
+ ///
+ /// - Parameters:
+ /// - fileName: Name of the file to download
+ /// - path: Path components to the file (includes the filename as last component)
+ /// - destination: Optional destination URL. If nil, downloads to Downloads folder.
+ /// - progressCallback: Optional callback for progress updates (receives TransferInfo and progress 0.0-1.0)
+ /// - callback: Optional completion callback (receives TransferInfo and final file URL)
+ @MainActor
+ func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) {
+ guard let client = self.client else { return }
+
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ // Request download from server
+ let result: (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)?
+ do {
+ result = try await client.downloadFile(name: fileName, path: fullPath)
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ return
+ }
+
+ guard let result,
+ let server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ return
+ }
+
+ let referenceNumber = result.referenceNumber
+
+ // Create transfer info for tracking (stored globally in AppState)
+ let transfer = TransferInfo(
+ reference: referenceNumber,
+ title: fileName,
+ size: UInt(result.transferSize),
+ serverID: self.id,
+ serverName: self.serverName ?? self.serverTitle
+ )
+ transfer.isUpload = false
+ transfer.downloadCallback = callback
+ transfer.progressCallback = progressCallback
+ AppState.shared.addTransfer(transfer)
+
+ // Create download client
+ let downloadClient = HotlineFileDownloadClient(
+ address: address,
+ port: UInt16(port),
+ reference: referenceNumber,
+ size: UInt32(result.transferSize)
+ )
+
+ // Create and store the download task
+ let downloadTask = Task { @MainActor [weak self] in
+ guard self != nil else { return }
+
+ do {
+ // Download file with progress tracking
+ let location: HotlineDownloadLocation = if let destination {
+ .url(destination)
+ } else {
+ .downloads(fileName)
+ }
+
+ let fileURL: URL = try await downloadClient.download(to: location) { progress in
+ switch progress {
+ case .preparing: break
+ case .unconnected, .connected, .connecting:
+ transfer.progressCallback?(transfer)
+ case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
+ transfer.timeRemaining = estimate
+ transfer.speed = speed
+ transfer.progress = progress
+ transfer.progressCallback?(transfer)
+ case .error(_):
+ transfer.failed = true
+ case .completed(url: let url):
+ transfer.completed = true
+ transfer.fileURL = url
+ }
+ }
+
+ // Mark as completed
+ transfer.progress = 1.0
+
+ // Call completion callback
+ transfer.downloadCallback?(transfer)
+ fileURL.notifyDownloadFinished()
+
+ print("HotlineState: Download complete - \(fileURL.path)")
+
+ } catch is CancellationError {
+ // Download was cancelled
+ transfer.cancelled = true
+ print("HotlineState: Download cancelled")
+
+ } catch {
+ // Mark as failed
+ transfer.failed = true
+ print("HotlineState: Download failed - \(error)")
+ }
+
+ AppState.shared.unregisterTransferTask(for: transfer.id)
+ }
+
+ // Store the task in AppState so it can be cancelled later
+ AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id, client: downloadClient)
+ }
+ }
+
+ /// Download a folder and its contents from the server.
+ ///
+ /// - Parameters:
+ /// - folderName: Name of the folder to download
+ /// - path: Path components to the folder (includes the foldername as last component)
+ /// - destination: Optional destination URL. If nil, downloads to Downloads folder.
+ /// - progressCallback: Optional callback for progress updates (receives TransferInfo)
+ /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info)
+ /// - callback: Optional completion callback (receives TransferInfo and final folder URL)
+ @MainActor
+ func downloadFolder(
+ _ folderName: String,
+ path: [String],
+ to destination: URL? = nil,
+ progress progressCallback: ((TransferInfo) -> Void)? = nil,
+// itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil,
+ complete callback: ((TransferInfo) -> Void)? = nil
+ ) {
+ guard let client = self.client else { return }
+
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ // Request folder download from server
+ guard let result = try? await client.downloadFolder(name: folderName, path: fullPath),
+ let server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ return
+ }
+
+ let referenceNumber = result.referenceNumber
+
+ // Create transfer info for tracking (stored globally in AppState)
+ let transfer = TransferInfo(
+ reference: referenceNumber,
+ title: folderName,
+ size: UInt(result.transferSize),
+ serverID: self.id,
+ serverName: self.serverName ?? self.serverTitle
+ )
+ transfer.isFolder = true
+ transfer.folderName = folderName
+ transfer.isUpload = false
+ transfer.downloadCallback = callback
+ transfer.progressCallback = progressCallback
+ AppState.shared.addTransfer(transfer)
+
+ // Create download client
+ let downloadClient = HotlineFolderDownloadClient(
+ address: address,
+ port: UInt16(port),
+ reference: referenceNumber,
+ size: UInt32(result.transferSize),
+ itemCount: result.itemCount
+ )
+
+ // Create and store the download task
+ let downloadTask = Task { @MainActor [weak self] in
+ guard self != nil else { return }
+
+ do {
+ // Download folder with progress tracking
+ let location: HotlineDownloadLocation = if let destination {
+ .url(destination)
+ } else {
+ .downloads(folderName)
+ }
+
+ let folderURL = try await downloadClient.download(to: location, progress: { progress in
+ switch progress {
+ case .preparing:
+ break
+ case .unconnected, .connected, .connecting:
+ transfer.progressCallback?(transfer)
+ case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
+ transfer.timeRemaining = estimate
+ transfer.speed = speed
+ transfer.progress = progress
+ transfer.progressCallback?(transfer)
+ case .error(_):
+ transfer.failed = true
+ case .completed(url: let url):
+ transfer.completed = true
+ transfer.fileURL = url
+ }
+ }, items: { item in
+ transfer.title = item.fileName
+ transfer.fileName = item.fileName
+ })
+
+ // Mark as completed
+ transfer.progress = 1.0
+ transfer.fileName = nil
+ transfer.title = folderName // Reset title to folder name
+
+ // Call completion callback
+ transfer.downloadCallback?(transfer)
+
+ folderURL.notifyDownloadFinished()
+
+ print("HotlineState: Folder download complete - \(folderURL.path)")
+
+ } catch is CancellationError {
+ // Download was cancelled
+ print("HotlineState: Folder download cancelled")
+ } catch {
+ // Mark as failed
+ transfer.failed = true
+ print("HotlineState: Folder download failed - \(error)")
+ }
+
+ AppState.shared.unregisterTransferTask(for: transfer.id)
+ }
+
+ // Store transfer
+ AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id)
+ }
+ }
+
+ /// Upload a folder to the server.
+ ///
+ /// - Parameters:
+ /// - folderURL: URL to the folder on disk to upload
+ /// - path: Destination path on the server where the folder should be uploaded
+ /// - progressCallback: Optional callback for progress updates (receives TransferInfo)
+ /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info)
+ /// - callback: Optional completion callback (receives TransferInfo when upload is complete)
+ @MainActor
+ func uploadFolder(
+ url folderURL: URL,
+ path: [String],
+ progress progressCallback: ((TransferInfo) -> Void)? = nil,
+ itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil,
+ complete callback: ((TransferInfo) -> Void)? = nil
+ ) {
+ guard let client = self.client else { return }
+
+ let folderName = folderURL.lastPathComponent
+
+ guard folderURL.isFileURL, !folderName.isEmpty else {
+ print("HotlineState: Not a valid folder URL")
+ return
+ }
+
+ let folderPath = folderURL.path(percentEncoded: false)
+
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory),
+ isDirectory.boolValue == true else {
+ print("HotlineState: URL is not a folder")
+ return
+ }
+
+ // Get the total size of the folder (all files)
+ guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else {
+ print("HotlineState: Could not determine folder size")
+ return
+ }
+
+ print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total")
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ // Request folder upload from server.
+ // The enumerator already omits the root folder, so report the full item count the server should expect.
+ let reportedItemCount = fileCount
+ print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)")
+ guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)),
+ let server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ print("HotlineState: Failed to get upload reference from server")
+ return
+ }
+
+ // Invalidate cache for the upload destination
+ self.invalidateFileListCache(for: path, includingAncestors: true)
+
+ print("HotlineState: Got folder upload reference: \(referenceNumber)")
+
+ // Create upload client
+ guard let uploadClient = HotlineFolderUploadClient(
+ folderURL: folderURL,
+ address: address,
+ port: UInt16(port),
+ reference: referenceNumber
+ ) else {
+ print("HotlineState: Failed to create folder upload client")
+ return
+ }
+
+ // Create transfer info for tracking (stored globally in AppState)
+ let transfer = TransferInfo(
+ reference: referenceNumber,
+ title: folderName,
+ size: UInt(folderSize),
+ serverID: self.id,
+ serverName: self.serverName ?? self.serverTitle
+ )
+ transfer.isFolder = true
+ transfer.isUpload = true
+ transfer.uploadCallback = callback
+ transfer.progressCallback = progressCallback
+ AppState.shared.addTransfer(transfer)
+
+ // Create and store the upload task
+ let uploadTask = Task { @MainActor [weak self] in
+ guard self != nil else { return }
+
+ do {
+ // Upload folder with progress tracking
+ try await uploadClient.upload(progress: { progress in
+ switch progress {
+ case .preparing:
+ break
+ case .unconnected, .connected, .connecting:
+ break
+ case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
+ transfer.timeRemaining = estimate
+ transfer.speed = speed
+ transfer.progress = progress
+ transfer.progressCallback?(transfer)
+ case .error(_):
+ transfer.failed = true
+ case .completed(url: _):
+ transfer.completed = true
+ }
+ }, itemProgress: { itemInfo in
+ // Update transfer title with current file being uploaded
+ transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))"
+ itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems)
+ })
+
+ // Mark as completed
+ transfer.progress = 1.0
+ transfer.title = folderName // Reset title to folder name
+
+ // Call completion callback
+ transfer.uploadCallback?(transfer)
+
+ print("HotlineState: Folder upload complete - \(folderName)")
+
+ } catch is CancellationError {
+ // Upload was cancelled
+ print("HotlineState: Folder upload cancelled")
+ } catch {
+ // Mark as failed
+ transfer.failed = true
+ print("HotlineState: Folder upload failed - \(error)")
+ }
+
+ AppState.shared.unregisterTransferTask(for: transfer.id)
+ }
+
+ // Store the task in AppState so it can be cancelled later
+ AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id)
+ }
+ }
+
+ func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) {
+ guard let client = self.client else { return }
+
+ let fileName = fileURL.lastPathComponent
+
+ print("UPLOAD FILE: \(fileName) \(fileURL)")
+
+ guard fileURL.isFileURL, !fileName.isEmpty else {
+ print("HotlineState: Not a valid file URL")
+ return
+ }
+
+ let filePath = fileURL.path(percentEncoded: false)
+
+ var fileIsDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory),
+ fileIsDirectory.boolValue == false else {
+ print("HotlineState: File is a directory")
+ return
+ }
+
+ // Get the flattened file size (includes all forks and headers)
+ guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else {
+ print("HotlineState: Could not determine file size")
+ return
+ }
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ let referenceNumber: UInt32?
+
+ do {
+ referenceNumber = try await client.uploadFile(name: fileName, path: path)
+ }
+ catch let error as HotlineClientError {
+ self.displayError(error, message: error.userMessage)
+ return
+ }
+
+
+ // Request upload from server
+ guard let referenceNumber,
+ let server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ print("HotlineState: Failed to get upload reference from server")
+ return
+ }
+
+ // Invalidate cache for the upload destination
+ self.invalidateFileListCache(for: path, includingAncestors: true)
+
+ print("HotlineState: Got upload reference: \(referenceNumber)")
+
+ // Create upload client
+ guard let uploadClient = HotlineFileUploadClient(
+ fileURL: fileURL,
+ address: address,
+ port: UInt16(port),
+ reference: referenceNumber
+ ) else {
+ print("HotlineState: Failed to create upload client")
+ return
+ }
+
+ // Create transfer info for tracking (stored globally in AppState)
+ let transfer = TransferInfo(
+ reference: referenceNumber,
+ title: fileName,
+ size: UInt(payloadSize),
+ serverID: self.id,
+ serverName: self.serverName ?? self.serverTitle
+ )
+ transfer.isUpload = true
+ transfer.uploadCallback = callback
+ AppState.shared.addTransfer(transfer)
+
+ // Create and store the upload task
+ let uploadTask = Task { @MainActor [weak self] in
+ guard self != nil else { return }
+
+ do {
+ // Upload file with progress tracking
+ try await uploadClient.upload { progress in
+ switch progress {
+ case .preparing:
+ break
+ case .unconnected, .connected, .connecting:
+ break
+ case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate):
+ transfer.timeRemaining = estimate
+ transfer.speed = speed
+ transfer.progress = progress
+ case .error(_):
+ transfer.failed = true
+ case .completed(url: _):
+ transfer.completed = true
+ }
+ }
+
+ // Mark as completed
+ transfer.progress = 1.0
+
+ // Call completion callback
+ transfer.uploadCallback?(transfer)
+
+ print("HotlineState: Upload complete - \(fileName)")
+
+ } catch is CancellationError {
+ // Upload was cancelled
+ print("HotlineState: Upload cancelled")
+ } catch {
+ // Mark as failed
+ transfer.failed = true
+ print("HotlineState: Upload failed - \(error)")
+ }
+
+ AppState.shared.unregisterTransferTask(for: transfer.id)
+ }
+
+ // Store the transfer
+ AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id)
+ }
+ }
+
+ @MainActor
+ func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) {
+ guard let client = self.client else {
+ callback?(nil)
+ return
+ }
+
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+
+ Task { @MainActor in
+ guard let result = try? await client.downloadFile(name: fileName, path: fullPath, preview: true),
+ let server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ callback?(nil)
+ return
+ }
+
+ let info = PreviewFileInfo(
+ id: result.referenceNumber,
+ address: address,
+ port: port,
+ size: result.transferSize,
+ name: fileName
+ )
+
+ callback?(info)
+ }
+ }
+
+ // MARK: - User Administration
+
+ @MainActor
+ func getAccounts() async throws -> [HotlineAccount] {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ self.accounts = try await client.getAccounts()
+ self.accountsLoaded = true
+ return self.accounts
+ }
+
+ @MainActor
+ func createUser(name: String, login: String, password: String?, access: UInt64) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.createUser(name: name, login: login, password: password, access: access)
+
+ // Refresh accounts list
+ self.accounts = try await client.getAccounts()
+ }
+
+ @MainActor
+ func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access)
+
+ // Refresh accounts list
+ self.accounts = try await client.getAccounts()
+ }
+
+ @MainActor
+ func deleteUser(login: String) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.deleteUser(login: login)
+
+ // Refresh accounts list
+ self.accounts = try await client.getAccounts()
+ }
+
+ // MARK: - Message Board
+
+ @MainActor
+ func getMessageBoard() async throws -> [String] {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ self.messageBoard = try await client.getMessageBoard()
+ self.messageBoardLoaded = true
+ return self.messageBoard
+ }
+
+ @MainActor
+ func postToMessageBoard(text: String) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.postMessageBoard(text)
+ }
+
+ // MARK: - News
+
+ @MainActor
+ func getNewsList(at path: [String] = []) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ let parentNewsGroup = self.findNews(in: self.news, at: path)
+
+ // Send a categories request for bundle paths or root (empty path)
+ if path.isEmpty || parentNewsGroup?.type == .bundle {
+ print("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))")
+
+ let categories = try await client.getNewsCategories(path: path)
+
+ // Create info for each category returned
+ var newCategoryInfos: [NewsInfo] = []
+
+ // Transform hotline categories into NewsInfo objects
+ for category in categories {
+ var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category)
+
+ if let lookupPath = newsCategoryInfo.lookupPath {
+ // Merge returned category info with existing category info
+ if let existingCategoryInfo = self.newsLookup[lookupPath] {
+ print("HotlineState: Merging category into existing category at \(lookupPath)")
+
+ existingCategoryInfo.count = newsCategoryInfo.count
+ existingCategoryInfo.name = newsCategoryInfo.name
+ existingCategoryInfo.path = newsCategoryInfo.path
+ existingCategoryInfo.categoryID = newsCategoryInfo.categoryID
+ newsCategoryInfo = existingCategoryInfo
+ } else {
+ print("HotlineState: New category added at \(lookupPath)")
+ self.newsLookup[lookupPath] = newsCategoryInfo
+ }
+ }
+
+ newCategoryInfos.append(newsCategoryInfo)
+ }
+
+ if let parent = parentNewsGroup {
+ parent.children = newCategoryInfos
+ } else if path.isEmpty {
+ self.newsLoaded = true
+ self.news = newCategoryInfos
+ }
+ } else {
+ print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))")
+
+ let articles = try await client.getNewsArticles(path: path)
+
+ print("HotlineState: Organizing news at \(path.joined(separator: "/"))")
+
+ // Create info for each article returned
+ var newArticleInfos: [NewsInfo] = []
+
+ for article in articles {
+ var newsArticleInfo = NewsInfo(hotlineNewsArticle: article)
+
+ if let lookupPath = newsArticleInfo.lookupPath {
+ // Merge returned category info with existing category info
+ if let existingArticleInfo = self.newsLookup[lookupPath] {
+ print("HotlineState: Merging article into existing article at \(lookupPath)")
+
+ existingArticleInfo.count = newsArticleInfo.count
+ existingArticleInfo.name = newsArticleInfo.name
+ existingArticleInfo.path = newsArticleInfo.path
+ existingArticleInfo.articleUsername = newsArticleInfo.articleUsername
+ existingArticleInfo.articleDate = newsArticleInfo.articleDate
+ existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors
+ existingArticleInfo.articleID = newsArticleInfo.articleID
+ newsArticleInfo = existingArticleInfo
+ } else {
+ print("HotlineState: New article added at \(lookupPath)")
+ self.newsLookup[lookupPath] = newsArticleInfo
+ }
+ }
+
+ newArticleInfos.append(newsArticleInfo)
+ }
+
+ let organizedNewsArticles: [NewsInfo] = self.organizeNewsArticles(newArticleInfos)
+ if let parent = parentNewsGroup {
+ parent.children = organizedNewsArticles
+ }
+ }
+ }
+
+ @MainActor
+ func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor)
+ }
+
+ @MainActor
+ func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID)
+ print("HotlineState: News article posted")
+ }
+
+ // MARK: - File Search
+
+ @MainActor
+ func startFileSearch(query: String) {
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ self.cancelFileSearch()
+ return
+ }
+
+ self.fileSearchSession?.cancel()
+ self.resetFileSearchState()
+ self.fileSearchQuery = trimmed
+ self.fileSearchStatus = .searching(processed: 0, pending: 0)
+ self.fileSearchScannedFolders = 0
+ self.fileSearchCurrentPath = []
+
+ let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig)
+ self.fileSearchSession = session
+
+ Task { await session.start() }
+ }
+
+ @MainActor
+ func cancelFileSearch(clearResults: Bool = true) {
+ guard let session = self.fileSearchSession else {
+ if clearResults {
+ self.resetFileSearchState()
+ } else if !self.fileSearchResults.isEmpty {
+ self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders)
+ self.fileSearchCurrentPath = nil
+ }
+ return
+ }
+
+ session.cancel()
+ self.fileSearchSession = nil
+ self.fileSearchCurrentPath = nil
+
+ if clearResults {
+ self.resetFileSearchState()
+ } else {
+ self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders)
+ }
+ }
+
+ @MainActor
+ func clearFileListCache() {
+ guard !self.fileListCache.isEmpty else {
+ return
+ }
+
+ self.fileListCache.removeAll(keepingCapacity: false)
+ }
+
+ @MainActor
+ fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) {
+ guard self.fileSearchSession === session else {
+ return
+ }
+
+ var appended: [FileInfo] = []
+ for match in matches {
+ let key = self.searchPathKey(for: match.path)
+ if self.fileSearchResultKeys.insert(key).inserted {
+ appended.append(match)
+ }
+ }
+
+ if !appended.isEmpty {
+ self.fileSearchResults.append(contentsOf: appended)
+ }
+
+ self.fileSearchScannedFolders = processed
+ self.fileSearchStatus = .searching(processed: processed, pending: pending)
+ }
+
+ @MainActor
+ fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) {
+ guard self.fileSearchSession === session else {
+ return
+ }
+
+ self.fileSearchCurrentPath = path
+ }
+
+ @MainActor
+ fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) {
+ guard self.fileSearchSession === session else {
+ return
+ }
+
+ self.fileSearchScannedFolders = processed
+ self.fileSearchSession = nil
+ self.fileSearchCurrentPath = nil
+
+ if completed {
+ self.fileSearchStatus = .completed(processed: processed)
+ } else {
+ self.fileSearchStatus = .cancelled(processed: processed)
+ }
+ }
+
+ fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? {
+ self.cachedFileList(for: path, ttl: ttl, allowStale: true)
+ }
+
+ // MARK: - Event Handlers
+
+ private func handleChatMessage(_ text: String) {
+ if Prefs.shared.playSounds && Prefs.shared.playChatSound {
+ SoundEffects.play(.chatMessage)
+ }
+
+ let chatMessage = ChatMessage(text: text, type: .message, date: Date())
+ self.recordChatMessage(chatMessage)
+ self.unreadPublicChat = true
+ }
+
+ private func handleUserChanged(_ user: HotlineUser) {
+ self.addOrUpdateHotlineUser(user)
+ }
+
+ private func handleUserDisconnected(_ userID: UInt16) {
+ if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
+ let user = self.users.remove(at: existingUserIndex)
+
+ if Prefs.shared.showJoinLeaveMessages {
+ let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date())
+ self.recordChatMessage(chatMessage)
+ }
+
+ if Prefs.shared.playSounds && Prefs.shared.playLeaveSound {
+ SoundEffects.play(.userLogout)
+ }
+ }
+ }
+
+ private func handleServerMessage(_ message: String) {
+ if Prefs.shared.playSounds && Prefs.shared.playChatSound {
+ SoundEffects.play(.serverMessage)
+ }
+
+ print("HotlineState: received server message:\n\(message)")
+ let chatMessage = ChatMessage(text: message, type: .server, date: Date())
+ self.recordChatMessage(chatMessage)
+ }
+
+ private func handlePrivateMessage(userID: UInt16, message: String) {
+ if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
+ let user = self.users[existingUserIndex]
+ print("HotlineState: received private message from \(user.name): \(message)")
+
+ if Prefs.shared.playPrivateMessageSound {
+ if self.unreadInstantMessages[userID] == nil {
+ SoundEffects.play(.serverMessage)
+ } else {
+ SoundEffects.play(.chatMessage)
+ }
+ }
+
+ let instantMessage = InstantMessage(
+ direction: .incoming,
+ text: message.convertingLinksToMarkdown(),
+ type: .message,
+ date: Date()
+ )
+
+ if self.instantMessages[userID] == nil {
+ self.instantMessages[userID] = [instantMessage]
+ } else {
+ self.instantMessages[userID]!.append(instantMessage)
+ }
+
+ self.unreadInstantMessages[userID] = userID
+ }
+ }
+
+ private func handleNewsPost(_ message: String) {
+ let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/
+ let matches = message.matches(of: messageBoardRegex)
+
+ if matches.count == 1 {
+ let range = matches[0].range
+ self.messageBoard.insert(String(message[message.startIndex..<range.lowerBound]), at: 0)
+ } else {
+ self.messageBoard.insert(message, at: 0)
+ }
+
+ SoundEffects.play(.newNews)
+ }
+
+ // MARK: - User Management
+
+ private func addOrUpdateHotlineUser(_ user: HotlineUser) {
+ print("HotlineState: users: \n\(self.users)")
+
+ if let i = self.users.firstIndex(where: { $0.id == user.id }) {
+ print("HotlineState: updating user \(self.users[i].name)")
+ self.users[i] = User(hotlineUser: user)
+ } else {
+ if !self.users.isEmpty {
+ if Prefs.shared.playSounds && Prefs.shared.playJoinSound {
+ SoundEffects.play(.userLogin)
+ }
+ }
+
+ print("HotlineState: added user: \(user.name)")
+ self.users.append(User(hotlineUser: user))
+
+ if Prefs.shared.showJoinLeaveMessages {
+ let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date())
+ self.recordChatMessage(chatMessage)
+ }
+ }
+ }
+
+ // MARK: - Chat Persistence
+
+ private func sessionKey(for server: Server) -> ChatStore.SessionKey {
+ ChatStore.SessionKey(address: server.address.lowercased(), port: server.port)
+ }
+
+ private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) {
+ let shouldPersist = persist && message.type != .agreement
+ if shouldPersist,
+ message.type == .signOut,
+ self.lastPersistedMessageType == .signOut {
+ return
+ }
+
+ if display {
+ self.chat.append(message)
+ }
+
+ guard shouldPersist, let key = self.chatSessionKey else { return }
+ self.lastPersistedMessageType = message.type
+
+ let entry = ChatStore.Entry(
+ id: message.id,
+ body: message.text,
+ username: message.username,
+ type: message.type.storageKey,
+ date: message.date
+ )
+ let serverName = self.serverName ?? self.server?.name
+
+ Task {
+ await ChatStore.shared.append(entry: entry, for: key, serverName: serverName)
+ }
+ }
+
+ private func restoreChatHistory(for key: ChatStore.SessionKey) {
+ if self.restoredChatSessionKey == key {
+ return
+ }
+
+ Task { [weak self] in
+ guard let self else { return }
+ let result = await ChatStore.shared.loadHistory(for: key)
+
+ await MainActor.run {
+ guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return }
+
+ let currentMessages = self.chat
+ let historyMessages = result.entries.compactMap { entry -> ChatMessage? in
+ guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil }
+
+ let renderedText: String
+ if chatType == .message, let username = entry.username, !username.isEmpty {
+ renderedText = "\(username): \(entry.body)"
+ } else {
+ renderedText = entry.body
+ }
+
+ var message = ChatMessage(text: renderedText, type: chatType, date: entry.date)
+ message.metadata = entry.metadata
+ return message
+ }
+
+ self.chat = historyMessages + currentMessages
+ self.lastPersistedMessageType = historyMessages.last?.type
+ self.unreadPublicChat = false
+ self.restoredChatSessionKey = key
+ }
+ }
+ }
+
+ private func handleChatHistoryCleared() {
+ self.chat = []
+ self.unreadPublicChat = false
+ self.restoredChatSessionKey = nil
+ self.lastPersistedMessageType = nil
+ }
+
+ // MARK: - Utilities
+
+ func updateServerTitle() {
+ self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Hotline"
+ }
+
+ // News helpers
+ func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] {
+ // Place articles under their parent
+ var organized: [NewsInfo] = []
+ for article in flatArticles {
+ if let parentLookupPath = article.parentArticleLookupPath,
+ let parentArticle = self.newsLookup[parentLookupPath] {
+ if parentArticle.children.firstIndex(of: article) == nil {
+ article.expanded = true
+ parentArticle.children.append(article)
+ }
+ } else {
+ organized.append(article)
+ }
+ }
+
+ return organized
+ }
+
+ private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? {
+ guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil }
+
+ for news in newsToSearch {
+ if news.name == currentName {
+ if path.count == 1 {
+ return news
+ } else if !news.children.isEmpty {
+ let remainingPath = Array(path[1...])
+ return self.findNews(in: news.children, at: remainingPath)
+ }
+ }
+ }
+
+ return nil
+ }
+
+ // File helpers
+ private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? {
+ guard !path.isEmpty, !filesToSearch.isEmpty else { return nil }
+
+ let currentName = path[0]
+
+ for file in filesToSearch {
+ if file.name == currentName {
+ if path.count == 1 {
+ return file
+ } else if let subfiles = file.children {
+ let remainingPath = Array(path[1...])
+ return self.findFile(in: subfiles, at: remainingPath)
+ }
+ }
+ }
+
+ return nil
+ }
+
+ // File search helpers
+ private func searchPathKey(for path: [String]) -> String {
+ path.joined(separator: "\u{001F}")
+ }
+
+ private func resetFileSearchState() {
+ self.fileSearchResults = []
+ self.fileSearchResultKeys.removeAll(keepingCapacity: true)
+ self.fileSearchStatus = .idle
+ self.fileSearchQuery = ""
+ self.fileSearchScannedFolders = 0
+ self.fileSearchCurrentPath = nil
+ }
+
+ // File cache helpers
+ private func shouldBypassFileCache(for path: [String]) -> Bool {
+ guard let folderName = path.last else {
+ return false
+ }
+
+ let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil {
+ return true
+ }
+
+ if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil {
+ return true
+ }
+
+ if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil {
+ return true
+ }
+
+ return false
+ }
+
+ private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? {
+ guard ttl > 0 else {
+ return nil
+ }
+
+ if self.shouldBypassFileCache(for: path) {
+ return nil
+ }
+
+ let key = self.searchPathKey(for: path)
+ guard let entry = self.fileListCache[key] else {
+ return nil
+ }
+
+ let age = Date().timeIntervalSince(entry.timestamp)
+ let isFresh = age <= ttl
+ if !allowStale && !isFresh {
+ return nil
+ }
+
+ return (entry.files, isFresh)
+ }
+
+ private func storeFileListInCache(_ files: [FileInfo], for path: [String]) {
+ guard self.fileSearchConfig.cacheTTL > 0 else {
+ return
+ }
+
+ if self.shouldBypassFileCache(for: path) {
+ return
+ }
+
+ let key = self.searchPathKey(for: path)
+ self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date())
+ self.pruneFileListCacheIfNeeded()
+ }
+
+ private func pruneFileListCacheIfNeeded() {
+ let limit = self.fileSearchConfig.maxCachedFolders
+ guard limit > 0, self.fileListCache.count > limit else {
+ return
+ }
+
+ let excess = self.fileListCache.count - limit
+ guard excess > 0 else { return }
+
+ let sortedKeys = self.fileListCache.sorted { lhs, rhs in
+ lhs.value.timestamp < rhs.value.timestamp
+ }
+
+ for index in 0..<excess {
+ let key = sortedKeys[index].key
+ self.fileListCache.removeValue(forKey: key)
+ }
+ }
+
+ private func invalidateFileListCache(for path: [String], includingAncestors: Bool = false) {
+ guard !self.fileListCache.isEmpty else {
+ return
+ }
+
+ var currentPath = path
+ while true {
+ let key = self.searchPathKey(for: currentPath)
+ self.fileListCache.removeValue(forKey: key)
+
+ if !includingAncestors || currentPath.isEmpty {
+ break
+ }
+
+ currentPath.removeLast()
+ if currentPath.isEmpty {
+ let rootKey = self.searchPathKey(for: currentPath)
+ self.fileListCache.removeValue(forKey: rootKey)
+ break
+ }
+ }
+ }
+
+ // MARK: - Transfer Delegates
+
+// nonisolated func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) {
+// Task { @MainActor in
+// // Update transfer info with progress
+// guard let transferIndex = self.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = self.transfers[transferIndex]
+// transfer.timeRemaining = timeRemaining
+//
+// switch status {
+// case .progress(let p):
+// transfer.progress = p
+// transfer.progressCallback?(transfer)
+// case .completed:
+// transfer.completed = true
+// transfer.progress = 1.0
+// case .failed(_):
+// transfer.failed = true
+// default:
+// break
+// }
+// }
+// }
+
+// nonisolated func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) {
+// // Info fork received (file metadata)
+// }
+
+// nonisolated func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at url: URL) {
+// Task { @MainActor in
+// // Find and remove the download client
+// if let downloadIndex = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
+// self.downloads.remove(at: downloadIndex)
+// }
+//
+// // Find and complete the transfer
+// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = AppState.shared.transfers[transferIndex]
+// transfer.fileURL = url
+// transfer.downloadCallback?(transfer)
+// AppState.shared.transfers.remove(at: transferIndex)
+// }
+// }
+
+// nonisolated func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) {
+// Task { @MainActor in
+// // Update transfer info with current file being downloaded
+// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = AppState.shared.transfers[transferIndex]
+// transfer.title = "\(fileName) (\(itemNumber)/\(totalItems))"
+// }
+// }
+
+// nonisolated func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at url: URL) {
+// Task { @MainActor in
+// // Find and remove the download client
+// if let downloadIndex = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
+// self.downloads.remove(at: downloadIndex)
+// }
+//
+// // Find and complete the transfer
+// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = AppState.shared.transfers[transferIndex]
+// transfer.fileURL = url
+// transfer.downloadCallback?(transfer)
+// AppState.shared.transfers.remove(at: transferIndex)
+// }
+// }
+}
+
+// MARK: - File Search Session
+
+@MainActor
+final class HotlineStateFileSearchSession {
+ private struct FolderTask {
+ let path: [String]
+ let depth: Int
+ let isHot: Bool
+ }
+
+ private weak var hotlineState: HotlineState?
+ private let queryTokens: [String]
+ private let config: FileSearchConfig
+
+ private var queue: [FolderTask] = []
+ private var visited: Set<String> = []
+ private var loopHistogram: [String: Int] = [:]
+
+ private var processedCount: Int = 0
+ private var currentDelay: TimeInterval
+ private var isCancelled = false
+
+ init(hotlineState: HotlineState, query: String, config: FileSearchConfig) {
+ self.hotlineState = hotlineState
+ self.queryTokens = query.lowercased().split(separator: " ").map(String.init)
+ self.config = config
+ self.currentDelay = config.initialDelay
+ }
+
+ func start() async {
+ guard let hotlineState else {
+ return
+ }
+
+ await Task.yield()
+
+ if !hotlineState.filesLoaded {
+ hotlineState.searchSession(self, didFocusOn: [])
+ let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true)
+ self.processedCount = max(self.processedCount, 1)
+ self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false)
+ } else {
+ hotlineState.searchSession(self, didFocusOn: [])
+ self.processedCount = max(self.processedCount, 1)
+ self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false)
+ }
+
+ while !self.queue.isEmpty && !self.isCancelled {
+ await Task.yield()
+
+ guard let task = self.dequeueNextTask() else {
+ continue
+ }
+
+ if self.shouldSkip(path: task.path, depth: task.depth) {
+ hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count)
+ continue
+ }
+
+ hotlineState.searchSession(self, didFocusOn: task.path)
+ self.visited.insert(self.pathKey(for: task.path))
+
+ if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) {
+ if cached.isFresh {
+ self.processedCount += 1
+ self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
+ continue
+ } else {
+ self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
+ }
+ }
+
+ let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true)
+ self.processedCount += 1
+
+ if self.isCancelled {
+ break
+ }
+
+ self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot)
+
+ await self.applyBackoff()
+ }
+
+ hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled)
+ }
+
+ func cancel() {
+ self.isCancelled = true
+ }
+
+ private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) {
+ guard let hotlineState else {
+ return
+ }
+
+ var matches: [FileInfo] = []
+ var folderEntries: [(file: FileInfo, isHot: Bool)] = []
+ var hasFileMatch = false
+
+ for file in items {
+ let matchesName = self.nameMatchesQuery(file.name)
+
+ if matchesName {
+ matches.append(file)
+ if !file.isFolder {
+ hasFileMatch = true
+ }
+ }
+
+ if file.isFolder && !file.isAppBundle {
+ folderEntries.append((file, matchesName))
+ }
+ }
+
+ var remainingBurst = 0
+ if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) {
+ remainingBurst = self.config.hotBurstLimit
+ }
+
+ if remainingBurst > 0 {
+ var candidateIndices: [Int] = []
+ for index in folderEntries.indices where !folderEntries[index].isHot {
+ candidateIndices.append(index)
+ }
+
+ if !candidateIndices.isEmpty {
+ candidateIndices.shuffle()
+ for index in candidateIndices {
+ folderEntries[index].isHot = true
+ remainingBurst -= 1
+ if remainingBurst == 0 {
+ break
+ }
+ }
+ }
+ }
+
+ for entry in folderEntries {
+ self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot)
+ }
+
+ hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count)
+ }
+
+ private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) {
+ guard !self.isCancelled else { return }
+ guard depth <= self.config.maxDepth else { return }
+
+ let path = folder.path
+ let key = self.pathKey(for: path)
+ guard !self.visited.contains(key) else { return }
+
+ if self.exceedsLoopThreshold(for: path) {
+ return
+ }
+
+ self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot))
+ }
+
+ private func dequeueNextTask() -> FolderTask? {
+ guard !self.queue.isEmpty else {
+ return nil
+ }
+
+ if self.queue.count == 1 {
+ return self.queue.removeFirst()
+ }
+
+ let currentDepth = self.queue[0].depth
+ var lastSameDepthIndex = 0
+ var hotIndices: [Int] = []
+
+ for index in 0..<self.queue.count {
+ let candidate = self.queue[index]
+ if candidate.depth == currentDepth {
+ lastSameDepthIndex = index
+ if candidate.isHot {
+ hotIndices.append(index)
+ }
+ } else {
+ break
+ }
+ }
+
+ let selectionPool: [Int]
+ if !hotIndices.isEmpty {
+ selectionPool = hotIndices
+ } else {
+ selectionPool = Array(0...lastSameDepthIndex)
+ }
+
+ let randomIndex = selectionPool.randomElement() ?? 0
+ return self.queue.remove(at: randomIndex)
+ }
+
+ private func shouldSkip(path: [String], depth: Int) -> Bool {
+ if self.isCancelled {
+ return true
+ }
+
+ if depth > self.config.maxDepth {
+ return true
+ }
+
+ let key = self.pathKey(for: path)
+ if self.visited.contains(key) {
+ return true
+ }
+
+ return false
+ }
+
+ private func nameMatchesQuery(_ name: String) -> Bool {
+ guard !self.queryTokens.isEmpty else { return false }
+ let lowercased = name.lowercased()
+ return self.queryTokens.allSatisfy { lowercased.contains($0) }
+ }
+
+ private func exceedsLoopThreshold(for path: [String]) -> Bool {
+ guard self.config.loopRepetitionLimit > 0 else { return false }
+ guard let last = path.last else { return false }
+ let parent = path.dropLast()
+
+ guard let previousIndex = parent.lastIndex(of: last) else {
+ return false
+ }
+
+ let suffix = Array(path[previousIndex...])
+ let key = suffix.joined(separator: "\u{001F}")
+ let count = (self.loopHistogram[key] ?? 0) + 1
+ self.loopHistogram[key] = count
+ return count > self.config.loopRepetitionLimit
+ }
+
+ private func pathKey(for path: [String]) -> String {
+ path.joined(separator: "\u{001F}")
+ }
+
+ private func applyBackoff() async {
+ guard !self.isCancelled else { return }
+
+ if self.processedCount > self.config.initialBurstCount {
+ self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier))
+ }
+
+ guard self.currentDelay > 0 else {
+ return
+ }
+
+ let nanoseconds = UInt64(self.currentDelay * 1_000_000_000)
+ try? await Task.sleep(nanoseconds: nanoseconds)
+ }
+}
diff --git a/Hotline/Models/Preferences.swift b/Hotline/State/Preferences.swift
index bcceec5..13469eb 100644
--- a/Hotline/Models/Preferences.swift
+++ b/Hotline/State/Preferences.swift
@@ -1,5 +1,9 @@
import SwiftUI
+extension EnvironmentValues {
+ @Entry var preferences: Prefs = Prefs.shared
+}
+
enum PrefsKeys: String {
case username = "username"
case userIconID = "user icon id"
@@ -22,8 +26,10 @@ enum PrefsKeys: String {
@Observable
class Prefs {
- init() {
- UserDefaults.standard.register(defaults: [
+ static let shared = Prefs()
+
+ private init() {
+ UserDefaults.standard.register(defaults:[
PrefsKeys.username.rawValue: "guest",
PrefsKeys.userIconID.rawValue: 191,
PrefsKeys.refusePrivateMessages.rawValue: false,
@@ -72,8 +78,6 @@ class Prefs {
forKey: PrefsKeys.showJoinLeaveMessages.rawValue)
}
- public static let shared = Prefs()
-
var username: String {
didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) }
}
diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift
new file mode 100644
index 0000000..3fe10f9
--- /dev/null
+++ b/Hotline/State/ServerState.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+
+@Observable
+class ServerState: Equatable {
+ var id: UUID = UUID()
+ var selection: ServerNavigationType
+ var serverName: String? = nil
+ var accountsShown: Bool = false
+ var broadcastShown: Bool = false
+// var serverBanner: NSImage? = nil
+// var bannerBackgroundColor: Color? = nil
+
+ init(selection: ServerNavigationType) {
+ self.selection = selection
+ }
+
+ static func == (lhs: ServerState, rhs: ServerState) -> Bool {
+ return lhs.id == rhs.id
+ }
+}
+
+enum ServerNavigationType: Identifiable, Hashable, Equatable {
+ var id: String {
+ switch self {
+ case .chat:
+ return "Chat"
+ case .news:
+ return "News"
+ case .board:
+ return "Board"
+ case .files:
+ return "Files"
+// case .accounts:
+// return "Accounts"
+ case .user(let userID):
+ return String(userID)
+ }
+ }
+
+ case chat
+ case news
+ case board
+ case files
+// case accounts
+ case user(userID: UInt16)
+}
diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift
deleted file mode 100644
index b1cd1b9..0000000
--- a/Hotline/Utility/FoundationExtensions.swift
+++ /dev/null
@@ -1,87 +0,0 @@
-import Foundation
-import SwiftUI
-
-enum Endianness {
- case big
- case little
-}
-
-extension String {
-
- func convertToAttributedStringWithLinks() -> AttributedString {
- let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self)
- let matches = self.ranges(of: RegularExpressions.relaxedLink)
- for match in matches {
- let matchString = String(self[match])
- if matchString.isEmailAddress() {
- attributedString.addAttribute(
- .link, value: "mailto:\(matchString)", range: NSRange(match, in: self))
- } else {
- attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self))
- }
- // attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self))
- }
- return AttributedString(attributedString)
- }
-
- func isEmailAddress() -> Bool {
- self.wholeMatch(of: RegularExpressions.emailAddress) != nil
- }
-
- func isWebURL() -> Bool {
- guard let url = URL(string: self) else {
- return false
- }
- switch url.scheme?.lowercased() {
- case "http", "https":
- return true
- default:
- return false
- }
- }
-
- func isImageURL() -> Bool {
- guard let url = URL(string: self) else {
- return false
- }
-
- switch url.pathExtension.lowercased() {
- case "jpg", "jpeg", "png", "gif":
- return true
- default:
- return false
- }
- }
-
- func convertingLinksToMarkdown() -> String {
- var cp = String(self)
- cp.replace(RegularExpressions.relaxedLink) { match -> String in
- let linkText = self[match.range]
- var injectedScheme = "https://"
- if (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil {
- injectedScheme = ""
- }
-
- return "[\(linkText)](\(injectedScheme)\(linkText))"
- }
- return cp
- }
-}
-
-extension Binding where Value: OptionSet, Value == Value.Element {
- func bindedValue(_ options: Value) -> Bool {
- return wrappedValue.contains(options)
- }
-
- func bind(_ options: Value) -> Binding<Bool> {
- return .init { () -> Bool in
- self.wrappedValue.contains(options)
- } set: { newValue in
- if newValue {
- self.wrappedValue.insert(options)
- } else {
- self.wrappedValue.remove(options)
- }
- }
- }
-}
diff --git a/Hotline/Utility/SoundEffects.swift b/Hotline/Utility/SoundEffects.swift
deleted file mode 100644
index 74314e4..0000000
--- a/Hotline/Utility/SoundEffects.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-import AVFAudio
-import Foundation
-
-enum SoundEffects: String {
- case loggedIn = "logged-in"
- case chatMessage = "chat-message"
- case transferComplete = "transfer-complete"
- case userLogin = "user-login"
- case userLogout = "user-logout"
- case newNews = "new-news"
- case serverMessage = "server-message"
- case error = "error"
-}
-
-@Observable
-class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate {
- static let shared = SoundEffectPlayer()
-
- private var activeSounds: [AVAudioPlayer] = []
-
- func playSoundEffect(_ name: SoundEffects) {
- // Load a local sound file
- guard
- let soundFileURL = Bundle.main.url(
- forResource: name.rawValue,
- withExtension: "aiff"
- )
- else {
- return
- }
-
- DispatchQueue.main.async { [weak self] in
- guard let self = self else {
- return
- }
-
- if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) {
- soundEffect.delegate = self
- soundEffect.volume = 0.75
- soundEffect.play()
-
- self.activeSounds.append(soundEffect)
- }
- }
- }
-
- func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
- if let i = self.activeSounds.firstIndex(of: player) {
- self.activeSounds.remove(at: i)
- }
- }
-}
diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift
deleted file mode 100644
index 27697f8..0000000
--- a/Hotline/Utility/SwiftUIExtensions.swift
+++ /dev/null
@@ -1,9 +0,0 @@
-import SwiftUI
-
-extension Color {
- init(hex: Int, opacity: Double = 1.0) {
- self.init(
- red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0,
- blue: Double(hex & 0xFF) / 255.0, opacity: opacity)
- }
-}
diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift
index e5105d0..2484310 100644
--- a/Hotline/iOS/ChatView.swift
+++ b/Hotline/iOS/ChatView.swift
@@ -28,7 +28,7 @@ struct ChatView: View {
VStack(alignment: .center) {
if let bannerImage = self.model.bannerImage {
- Image(uiImage: bannerImage)
+ bannerImage
.resizable()
.scaledToFit()
.frame(maxWidth: 468.0)
@@ -52,17 +52,24 @@ struct ChatView: View {
}
.frame(maxWidth: .infinity)
.padding()
- } else if msg.type == .status {
+ }
+ else if msg.type == .signOut {
HStack {
Spacer()
- Text(msg.text)
- .lineLimit(1)
- .truncationMode(.middle)
- .opacity(0.3)
+ Label {
+ Text(msg.text)
+ .font(.footnote)
+ } icon: {
+ Image(systemName: "arrow.up.circle.fill")
+ }
+ .labelStyle(.titleAndIcon)
+ .foregroundStyle(Color.red)
+ .opacity(0.8)
Spacer()
}
- .padding()
- } else {
+ .padding(.vertical, 6)
+ }
+ else {
HStack(alignment: .firstTextBaseline) {
if let username = msg.username {
Text("**\(username):** \(msg.text)")
diff --git a/Hotline/iOS/ServerView.swift b/Hotline/iOS/ServerView.swift
index 11f57aa..2786c27 100644
--- a/Hotline/iOS/ServerView.swift
+++ b/Hotline/iOS/ServerView.swift
@@ -1,7 +1,7 @@
import SwiftUI
struct ServerView: View {
- @Environment(Hotline.self) private var model: Hotline
+ @Environment(HotlineState.self) private var model: HotlineState
@Environment(\.colorScheme) var colorScheme
enum Tab {
diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift
index 43b1916..b2aab61 100644
--- a/Hotline/iOS/TrackerView.swift
+++ b/Hotline/iOS/TrackerView.swift
@@ -10,7 +10,7 @@ struct TrackerConnectView: View {
@State private var login = ""
@State private var password = ""
// @State private var connecting = false
-
+
func connectionStatusToProgress(status: HotlineClientStatus) -> Double {
switch status {
case .disconnected:
@@ -54,14 +54,15 @@ struct TrackerConnectView: View {
.background {
Color.black.cornerRadius(8).blendMode(.overlay)
}
- } else {
+ }
+ else {
ProgressView(value: connectionStatusToProgress(status: model.status))
.frame(minHeight: 10)
.accentColor(colorScheme == .dark ? .white : .black)
}
-
+
Spacer()
-
+
HStack {
Button {
dismiss()
@@ -75,28 +76,23 @@ struct TrackerConnectView: View {
.frame(maxWidth: .infinity)
.foregroundColor(colorScheme == .dark ? .white : .black)
.background(
- colorScheme == .dark
- ? LinearGradient(
- gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top,
- endPoint: .bottom)
- : LinearGradient(
- gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]),
- startPoint: .top, endPoint: .bottom)
+ colorScheme == .dark ?
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom)
+ :
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)
)
.overlay(
- RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(
- colorScheme == .dark ? 0.0 : 0.2)
+ RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2)
)
.cornerRadius(10.0)
Button {
- let s = Server(
- name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort,
- users: 0, login: login, password: password)
+ let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password)
server = s
self.model.login(server: s, username: "bolt", iconID: 128) { success in
if !success {
print("FAILED LOGIN??")
- } else {
+ }
+ else {
self.model.sendUserInfo(username: "bolt", iconID: 128)
self.model.getUserList()
}
@@ -110,28 +106,24 @@ struct TrackerConnectView: View {
.frame(maxWidth: .infinity)
.foregroundColor(colorScheme == .dark ? .white : .black)
.background(
- colorScheme == .dark
- ? LinearGradient(
- gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top,
- endPoint: .bottom)
- : LinearGradient(
- gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]),
- startPoint: .top, endPoint: .bottom)
+ colorScheme == .dark ?
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom)
+ :
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)
)
.overlay(
- RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(
- colorScheme == .dark ? 0.0 : 0.2)
+ RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2)
)
.cornerRadius(10.0)
}
.padding()
}
.padding()
- .onChange(of: model.status) {
+ .onChange(of: self.model.status) {
print("MODEL STATUS CHANGED")
- if model.server != nil && server != nil && model.server! == server! {
- if model.status == .loggedIn {
- dismiss()
+ if self.model.server != nil && self.server != nil && self.model.server! == server! {
+ if self.model.status == .loggedIn {
+ self.dismiss()
}
// else {
// connecting = (model.status != .disconnected)
diff --git a/Hotline/Sounds/Application-iOS.swift b/Hotline/iOSApp.swift
index 55c5aa1..55c5aa1 100644
--- a/Hotline/Sounds/Application-iOS.swift
+++ b/Hotline/iOSApp.swift
diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift
index a2771b9..80e66f5 100644
--- a/Hotline/macOS/AboutView.swift
+++ b/Hotline/macOS/AboutView.swift
@@ -1,11 +1,5 @@
import SwiftUI
-
-enum VersionCheckState {
- case needToCheck
- case checking
- case upToDate
- case updateAvailable(version: String)
-}
+import SwiftUIIntrospect
struct AboutContributor: Identifiable {
let id: UUID = UUID()
@@ -14,164 +8,66 @@ struct AboutContributor: Identifiable {
let pictureURL: URL?
}
-struct AboutView: View {
+struct AboutContributorView: View {
@Environment(\.openURL) private var openURL
-
- @State private var versionCheck: VersionCheckState = .needToCheck
- @State private var downloadURL: String = "https://github.com/mierau/hotline/releases/latest"
- @State private var contributors: [AboutContributor] = []
-
+
+ let contributor: AboutContributor
+
var body: some View {
- HStack(alignment: .center, spacing: 0) {
- VStack(alignment: .center, spacing: 0) {
- Spacer()
-
- Image("About Hotline")
- .padding(.top, 44)
-
- Text("Hotline")
- .font(.system(size: 28))
- .fontWeight(.bold)
- .padding(.top, 12)
- .kerning(-1.0)
- .foregroundColor(.white)
-
- let appDetails = getAppVersionAndBuild()
- Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)")
- .foregroundColor(.white)
- .opacity(0.4)
-
- HStack(alignment: .center) {
- switch versionCheck {
- case .needToCheck:
- Button("Check for Updates") {
- Task {
- await checkForUpdate()
- }
- }
- .controlSize(.small)
- case .checking:
- HStack(spacing: 8) {
- ProgressView()
- .controlSize(.small)
- Text("Checking for updates...")
- .fontWeight(.semibold)
- }
- .foregroundStyle(.white)
- .tint(.white)
- case .upToDate:
- Label("Hotline is up to date.", systemImage: "checkmark.circle.fill")
- .foregroundStyle(.white)
- .fontWeight(.semibold)
- .tint(.white)
- .onTapGesture {
- versionCheck = .needToCheck
- }
- case .updateAvailable(let version):
- Button("Download Latest \(version)") {
- if let url = URL(string: downloadURL) {
- openURL(url)
- }
- }
- .controlSize(.small)
+ HStack {
+ if let pictureURL = contributor.pictureURL {
+ AsyncImage(url: pictureURL) { phase in
+ if let image = phase.image {
+ image
+ .interpolation(.high)
+ .resizable()
+ .scaledToFit()
+ .background(.white)
+ .frame(width: 32, height: 32)
+ } else if phase.error != nil {
+ Color.clear
+ .frame(width: 32, height: 32)
+ } else {
+ Color.white
+ .opacity(0.2)
+ .frame(width: 32, height: 32)
}
}
- .frame(height: 40)
-
- Spacer()
+ .frame(width: 32, height: 32)
+ .clipShape(Circle())
}
- .frame(width: 250)
-
- Spacer()
-
- ScrollView(.vertical) {
- VStack(alignment: .leading, spacing: 16) {
-
- VStack(alignment: .leading, spacing: 4) {
- Link(destination: URL(string: "https://github.com/mierau/hotline")!) {
- HStack(alignment: .center, spacing: 4) {
- Text("Contributors")
- .lineLimit(1)
- .font(.system(size: 16))
- .fontWeight(.semibold)
- .foregroundStyle(.black)
- .opacity(0.75)
-
- Image(systemName: "arrow.forward.circle.fill")
- .resizable()
- .fontWeight(.bold)
- .scaledToFit()
- .frame(width: 12, height: 12)
- .foregroundStyle(.black)
- .opacity(0.75)
- }
- }
- .padding(.top, 24)
-
- Text("Hotline is an open source project made possible by its contributors.")
- .font(.system(size: 11))
- .foregroundStyle(.black)
- .blendMode(.overlay)
- .padding(.trailing, 32)
- }
- .padding(.bottom, 8)
-
- ForEach(contributors) { contributor in
- Link(destination: contributor.webURL) {
- HStack {
- if let pictureURL = contributor.pictureURL {
- AsyncImage(url: pictureURL) { phase in
- if let image = phase.image {
- image
- .interpolation(.high)
- .resizable()
- .scaledToFit()
- .background(.white)
- .frame(width: 32, height: 32)
- } else if phase.error != nil {
- Color.clear
- .frame(width: 32, height: 32)
- } else {
- Color.white
- .opacity(0.2)
- .frame(width: 32, height: 32)
- }
- }
- .frame(width: 32, height: 32)
- .clipShape(Circle())
-
- // AsyncImage(url: pictureURL) { img in
- // img
- // .interpolation(.high)
- // .resizable()
- // .scaledToFit()
- // .background(.white)
- // } placeholder: {
- // Color.white.opacity(0.2)
- // .frame(width: 32, height: 32)
- // }
- // .frame(width: 32, height: 32)
- // .clipShape(Circle())
- }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(contributor.username)
+ .fontWeight(.semibold)
+ .foregroundStyle(.white)
+ .lineLimit(1)
+
+ Text(contributor.webURL.absoluteString)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .font(.system(size: 11))
+ .foregroundStyle(.white.opacity(0.4))
+ }
+ }
+// }
+// .accessibilityAddTraits(.isLink)
+// .pointerStyle(.link)
+ }
+}
- VStack(alignment: .leading, spacing: 2) {
- Text(contributor.username)
- .fontWeight(.semibold)
- .foregroundStyle(.white)
- .lineLimit(1)
+struct AboutView: View {
+ @Environment(\.openURL) private var openURL
+
+ @State private var contributors: [AboutContributor] = []
- Text(contributor.webURL.absoluteString)
- .lineLimit(1)
- .truncationMode(.middle)
- .font(.system(size: 11))
- .foregroundStyle(.white.opacity(0.4))
- }
- }
- }
- }
+ var body: some View {
+ HStack(alignment: .center, spacing: 0) {
+ self.brandView
+ self.contributorsList
+ .background {
+ Color.black.blendMode(.softLight).opacity(0.3).ignoresSafeArea()
}
- }
- .scrollClipDisabled()
}
.frame(width: 570, height: 330)
.background(
@@ -184,12 +80,105 @@ struct AboutView: View {
.offset(x: 250)
}
)
- .background(Color.hotlineRed)
.task {
await loadContributors()
}
}
+
+ private var brandView: some View {
+ VStack(alignment: .center, spacing: 4) {
+ Spacer()
+
+ Image("About Hotline")
+
+ Text("Hotline")
+ .font(.system(size: 28))
+ .fontWeight(.bold)
+ .padding(.top, 12)
+ .kerning(-1.0)
+ .foregroundColor(.white)
+
+ let appDetails = getAppVersionAndBuild()
+ Button {
+ self.openURL(URL(string: "https://github.com/mierau/hotline/releases/tag/\(appDetails.version)beta\(appDetails.build)")!)
+ } label: {
+
+ Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)")
+ .foregroundColor(.white.opacity(0.756))
+ .padding(.vertical, 4)
+ .padding(.horizontal, 12)
+ .background {
+ Capsule()
+ .fill(.white.opacity(0.5))
+ .blendMode(.softLight)
+ }
+ }
+ .buttonStyle(.plain)
+ .buttonBorderShape(.capsule)
+ .padding(.bottom, 16)
+
+ Spacer()
+ }
+ .frame(width: 250)
+ }
+
+ private var contributorsList: some View {
+ ScrollView(.vertical) {
+ VStack(alignment: .leading, spacing: 16) {
+ self.contributorHeaderView
+ ForEach(self.contributors) { contributor in
+ Button {
+ self.openURL(contributor.webURL)
+ } label: {
+ AboutContributorView(contributor: contributor)
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(.isLink)
+ .pointerStyle(.link)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ .scrollClipDisabled()
+ .scrollContentBackground(.hidden)
+// .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26)) { v in
+// v.automaticallyAdjustsContentInsets = false
+// }
+ }
+
+ private var contributorHeaderView: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Link(destination: URL(string: "https://github.com/mierau/hotline")!) {
+ HStack(alignment: .center, spacing: 4) {
+ Text("Contributors")
+ .lineLimit(1)
+ .font(.system(size: 16))
+ .fontWeight(.semibold)
+ .foregroundStyle(.black)
+ .opacity(0.8)
+ Image(systemName: "arrow.forward.circle.fill")
+ .resizable()
+ .fontWeight(.bold)
+ .scaledToFit()
+ .frame(width: 12, height: 12)
+ .foregroundStyle(.black)
+ .opacity(0.4)
+ }
+ }
+ .accessibilityAddTraits(.isLink)
+ .pointerStyle(.link)
+
+ Text("Hotline is an open source project made possible by its contributors.")
+ .font(.system(size: 11))
+ .foregroundStyle(.black)
+ .opacity(0.5)
+ .padding(.trailing, 32)
+ }
+ .padding(.bottom, 8)
+ }
+
func loadContributors() async {
var newContributors: [AboutContributor] = []
@@ -220,41 +209,6 @@ struct AboutView: View {
}
}
- func checkForUpdate() async {
- let appDetails = getAppVersionAndBuild()
-
- self.versionCheck = .checking
-
- do {
- let url = URL(string: "https://api.github.com/repos/mierau/hotline/releases/latest")!
- let (data, _) = try await URLSession.shared.data(from: url)
- let versionExpression = /^([0-9\.]+)beta([0-9]+)/
-
- if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let tagName = json["tag_name"] as? String,
- let assets = json["assets"] as? [[String: Any]],
- let firstAsset = assets.first,
- let assetDownloadURL = firstAsset["browser_download_url"] as? String,
- let versionMatches = try? versionExpression.wholeMatch(in: tagName)
- {
- if let versionNumber = Double(versionMatches.1),
- let buildNumber = Int(versionMatches.2),
- versionNumber > appDetails.version || buildNumber > appDetails.build
- {
- let versionString = "\(versionMatches.1)b\(versionMatches.2)"
- self.versionCheck = .updateAvailable(version: versionString)
- downloadURL = assetDownloadURL
- } else {
- self.versionCheck = .upToDate
- }
- } else {
- self.versionCheck = .needToCheck
- }
- } catch {
- self.versionCheck = .needToCheck
- }
- }
-
func getAppVersionAndBuild() -> (version: Double, build: Int) {
let infoDictionary = Bundle.main.infoDictionary!
let version = Double(infoDictionary["CFBundleShortVersionString"]! as! String)!
diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift
deleted file mode 100644
index 84ae6dc..0000000
--- a/Hotline/macOS/AccountManagerView.swift
+++ /dev/null
@@ -1,405 +0,0 @@
-import SwiftUI
-
-struct AccountManagerView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- @State private var accounts: [HotlineAccount] = []
- @State private var selection: HotlineAccount?
- @State private var loading: Bool = true
-
- @State private var pendingName: String = ""
- @State private var pendingLogin: String = ""
- @State private var pendingPassword: String = ""
- @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess
-
- @State private var toDelete: HotlineAccount?
-
- let placeholderPassword = "xxxxxxxxxxxxxxxxxx"
-
- var body: some View {
- HStack(spacing: 0) {
- ZStack {
- accountList
- if loading {
- ProgressView()
- }
- }
- if selection != nil {
- accountDetails
- } else {
- ZStack(alignment: .center) {
- Text("No Account Selected")
- .font(.title)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .padding()
- }
- .frame(maxWidth: .infinity)
- }
- }
- .environment(\.defaultMinListRowHeight, 34)
- .listStyle(.inset)
- .alternatingRowBackgrounds(.enabled)
- .task {
- if loading {
- accounts = await model.getAccounts()
- loading = false
- }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- let newAccount = HotlineAccount(
- "unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess)
-
- pendingPassword = HotlineAccount.randomPassword()
- accounts.append(newAccount)
- selection = newAccount
- } label: {
- Label("New Account", systemImage: "plus")
- }
- .help("Create a new account")
- .disabled(model.access?.contains(.canCreateUsers) != true)
- }
-
- ToolbarItem(placement: .destructiveAction) {
- Button {
- toDelete = selection
- } label: {
- Label("Delete Account", systemImage: "trash")
- }
- .help("Delete account")
- .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true)
- }
- }
- }
-
- var accountDetails: some View {
- VStack(alignment: .center, spacing: 0) {
- ScrollView(.vertical) {
- Form {
- Section {
- TextField(text: $pendingName) {
- Text("Name")
- }
- TextField("Login", text: $pendingLogin)
- .disabled(selection?.persisted == true)
- if selection?.persisted == true {
- SecureField("Password", text: $pendingPassword)
- } else {
- TextField("Password", text: $pendingPassword)
- }
- }
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
-
- Section("File System Maintenance") {
- Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles))
- .disabled(model.access?.contains(.canDownloadFiles) == false)
- Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders))
- .disabled(model.access?.contains(.canDownloadFolders) == false)
- Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles))
- .disabled(model.access?.contains(.canUploadFiles) == false)
- Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders))
- .disabled(model.access?.contains(.canUploadFolders) == false)
- Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere))
- .disabled(model.access?.contains(.canUploadAnywhere) == false)
- Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles))
- .disabled(model.access?.contains(.canDeleteFiles) == false)
- Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles))
- .disabled(model.access?.contains(.canRenameFiles) == false)
- Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles))
- .disabled(model.access?.contains(.canMoveFiles) == false)
- Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment))
- .disabled(model.access?.contains(.canSetFileComment) == false)
- Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders))
- .disabled(model.access?.contains(.canCreateFolders) == false)
- Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders))
- .disabled(model.access?.contains(.canDeleteFolders) == false)
- Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders))
- .disabled(model.access?.contains(.canRenameFolders) == false)
- Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders))
- .disabled(model.access?.contains(.canMoveFolders) == false)
- Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment))
- .disabled(model.access?.contains(.canSetFolderComment) == false)
- Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes))
- .disabled(model.access?.contains(.canViewDropBoxes) == false)
- Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases))
- .disabled(model.access?.contains(.canMakeAliases) == false)
- }
-
- Section("User Maintenance") {
- Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers))
- .disabled(model.access?.contains(.canCreateUsers) == false)
- Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers))
- .disabled(model.access?.contains(.canDeleteUsers) == false)
- Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers))
- .disabled(model.access?.contains(.canOpenUsers) == false)
- Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers))
- .disabled(model.access?.contains(.canModifyUsers) == false)
- Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo))
- .disabled(model.access?.contains(.canGetClientInfo) == false)
-
- Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers))
- .disabled(model.access?.contains(.canDisconnectUsers) == false)
- Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected))
- .disabled(model.access?.contains(.cantBeDisconnected) == false)
- }
-
- Section("Messaging") {
- Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages))
- .disabled(model.access?.contains(.canSendMessages) == false)
- Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast))
- .disabled(model.access?.contains(.canBroadcast) == false)
- }
-
- Section("News") {
- Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard))
- .disabled(model.access?.contains(.canReadMessageBoard) == false)
- Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard))
- .disabled(model.access?.contains(.canPostMessageBoard) == false)
- Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles))
- .disabled(model.access?.contains(.canDeleteNewsArticles) == false)
- Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories))
- .disabled(model.access?.contains(.canCreateNewsCategories) == false)
- Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories))
- .disabled(model.access?.contains(.canDeleteNewsCategories) == false)
- Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders))
- .disabled(model.access?.contains(.canCreateNewsFolders) == false)
- Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders))
- .disabled(model.access?.contains(.canDeleteNewsFolders) == false)
- }
-
- Section("Chat") {
- Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat))
- .disabled(model.access?.contains(.canCreateChat) == false)
- Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat))
- .disabled(model.access?.contains(.canReadChat) == false)
- Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat))
- .disabled(model.access?.contains(.canSendChat) == false)
- }
-
- Section("Miscellaneous") {
- Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName))
- .disabled(model.access?.contains(.canUseAnyName) == false)
- Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement))
- .disabled(model.access?.contains(.canSkipAgreement) == false)
- }
- }
- .disabled(model.access?.contains(.canModifyUsers) == false)
- .formStyle(.grouped)
- .onChange(of: selection) {
- if let selection {
- pendingName = selection.name
- pendingLogin = selection.login
- pendingAccess = selection.access
-
- if selection.persisted {
- if selection.password == nil {
- pendingPassword = ""
- } else {
- pendingPassword = placeholderPassword
- }
- }
- }
- }
- .onAppear {
- if let selection {
- pendingName = selection.name
- pendingLogin = selection.login
- pendingAccess = selection.access
-
- if selection.persisted {
- if selection.password == nil {
- pendingPassword = ""
- } else {
- pendingPassword = placeholderPassword
- }
- } else {
- pendingPassword = HotlineAccount.randomPassword()
- }
- }
- }
- }
- .frame(maxWidth: .infinity)
-
- Divider()
-
- HStack {
- Button("Revert") {
- if let selection {
- pendingAccess = selection.access
- pendingName = selection.name
- pendingLogin = selection.login
-
- if selection.password != nil {
- pendingPassword = selection.password!
- }
- }
- }
- .controlSize(.large)
- .frame(minWidth: 75)
- .disabled(!self.isSaveable())
- // .padding()
-
- Spacer()
-
- Button("Save") {
- guard let selection else {
- return
- }
-
- // Update existing account
- if selection.persisted == true {
-
- if pendingPassword == placeholderPassword {
- Task { @MainActor in
- model.client.sendSetUser(
- name: pendingName, login: pendingLogin, newLogin: nil, password: nil,
- access: pendingAccess.rawValue)
- }
- } else {
- Task { @MainActor in
- model.client.sendSetUser(
- name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword,
- access: pendingAccess.rawValue)
- }
- }
-
- } else {
- // Create new existing account
- Task { @MainActor in
- model.client.sendCreateUser(
- name: pendingName, login: pendingLogin, password: pendingPassword,
- access: pendingAccess.rawValue)
- }
- self.selection?.password = pendingPassword
- pendingPassword = placeholderPassword
- }
-
- var account = HotlineAccount(pendingName, pendingLogin, pendingAccess)
- account.persisted = true
- account.password = placeholderPassword
-
- accounts = accounts.filter { $0.persisted == true && $0.login != selection.login }
-
- // Add new account to list
- accounts.append(account)
-
- // Re-sort accounts
- accounts.sort { $0.login < $1.login }
- self.selection = account
- }
- .controlSize(.large)
- .frame(minWidth: 75)
- .keyboardShortcut(.defaultAction)
- .disabled(!self.isSaveable())
- }
- .padding()
- }
-
- }
-
- var accountList: some View {
- List(accounts, id: \.self, selection: $selection) { account in
- HStack(spacing: 5) {
- if account.access.contains(.canDisconnectUsers) {
- Image("User Admin")
- .frame(width: 16, height: 16)
- .opacity(account.persisted ? 1.0 : 0.25)
- // .padding(.leading, 4)
- Text(account.login)
- .foregroundStyle(Color.hotlineRed)
- } else if account.access.rawValue == 0 {
- Image("User")
- .frame(width: 16, height: 16)
- // .padding(.leading, 4)
- Text(account.login)
- .foregroundStyle(.secondary)
- }
- // else if account.persisted == false {
- // HStack {
- // Image("User")
- // .frame(width: 16, height: 16)
- //// .padding(.leading, 4)
- // Text(account.login)
- // .italic()
- // }
- // }
- else {
- Image("User")
- .frame(width: 16, height: 16)
- .opacity(account.persisted ? 1.0 : 0.5)
- // .padding(.leading, 4)
- Text(account.login)
- }
- }
- }
- .frame(width: 250)
- .sheet(item: $toDelete) { item in
- Form {
- HStack {
- Image(systemName: "exclamationmark.triangle")
- .font(.system(size: 30))
- Text("Delete account \"\(item.name)\" and all associated files?")
- .lineSpacing(4)
- }
- }
- .frame(
- minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100,
- maxHeight: .infinity
- )
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- toDelete = nil
- }
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button("Delete") {
- guard let userToDelete = toDelete else {
- return
- }
-
- self.toDelete = nil
- self.selection = nil
-
- if userToDelete.persisted {
- Task { @MainActor in
- model.client.sendDeleteUser(login: userToDelete.login)
- }
- }
-
- accounts = accounts.filter { $0.login != userToDelete.login }
-
- }
- }
- }
- }
- }
-
- private func isSaveable() -> Bool {
- guard let selection else {
- return false
- }
-
- // Disable save if login field is cleared
- if pendingLogin == "" {
- return false
- }
-
- // If the account initial has a password and it was updated
- if selection.password != nil && pendingPassword != placeholderPassword {
- return true
- }
-
- // If the account initial has no password, but was updated to have one
- if selection.password == nil && pendingPassword != "" {
- return true
- }
-
- // If the access bits or user name have been changed
- return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName
- }
-}
diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift
new file mode 100644
index 0000000..8514873
--- /dev/null
+++ b/Hotline/macOS/Accounts/AccountDetailsView.swift
@@ -0,0 +1,275 @@
+import SwiftUI
+
+fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx"
+
+enum AccountDetailsError: Error {
+ case noLogin
+ case failedToSave
+
+ var alertTitle: String {
+ switch self {
+ case .noLogin: "A login is required"
+ case .failedToSave: "Failed to save account"
+ }
+ }
+
+ var alertMessage: String {
+ switch self {
+ case .noLogin: "Users with accounts are required to have a login. Please add one and try again."
+ case .failedToSave: "An error occurred while saving this account. Please try again."
+ }
+ }
+}
+
+struct AccountDetailsView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State var account: HotlineAccount = HotlineAccount(DEFAULT_ACCOUNT_NAME, "", HotlineUserAccessOptions.defaultAccess)
+
+ let saved: ((HotlineAccount) -> Void)?
+
+ @State private var password: String = ""
+ @State private var saving: Bool = false
+ @State private var alertTitle: String = ""
+ @State private var alertMessage: String = ""
+ @State private var alertShown: Bool = false
+
+ var body: some View {
+ self.detailsView
+ .alert(self.alertTitle, isPresented: self.$alertShown, actions: {
+ if #available(macOS 26.0, *) {
+ Button("OK", role: .confirm) {
+ self.alertShown = false
+ }
+ }
+ else {
+ Button("OK") {
+ self.alertShown = false
+ }
+ }
+
+ }, message: {
+ Text(self.alertMessage)
+ })
+ .onAppear {
+ // Display a placeholder for accounts that have been saved to the server
+ // because we don't have the account password on hand to display.
+ if self.account.persisted {
+ self.password = PASSWORD_PLACEHOLDER
+ }
+ }
+ .toolbar {
+ if self.saving {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ self.dismiss()
+ } label: {
+ Text("Cancel")
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ do {
+ try await self.save()
+ }
+ catch let error as AccountDetailsError {
+ self.alertTitle = error.alertTitle
+ self.alertMessage = error.alertMessage
+ self.alertShown = true
+ }
+ }
+ } label: {
+ Text(self.account.persisted ? "Save" : "Create")
+ }
+ .disabled(self.saving)
+ }
+ }
+ }
+
+ private func save() async throws {
+ guard !self.account.login.isBlank else {
+ throw AccountDetailsError.noLogin
+ }
+
+ self.account.name = self.account.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.account.login = self.account.login.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ self.saving = true
+ defer { self.saving = false }
+
+ // We create a name var here so we don't see the default account name
+ // flash in the UI while saving.
+ var accountName: String = self.account.name
+ if accountName.isBlank {
+ accountName = DEFAULT_ACCOUNT_NAME
+ }
+
+ do {
+ // Update existing account
+ if self.account.persisted {
+ if self.password == PASSWORD_PLACEHOLDER {
+ try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue)
+ } else {
+ try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue)
+ }
+
+ } else {
+ // Create new existing account
+ try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue)
+ }
+ }
+ catch {
+ throw AccountDetailsError.failedToSave
+ }
+
+ self.account.persisted = true
+ self.account.name = accountName
+ self.saved?(self.account)
+ }
+
+ private var isEditable: Bool {
+ self.model.access?.contains(.canModifyUsers) == true
+ }
+
+ private var detailsView: some View {
+ Form {
+ Section {
+ TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) {
+ Text("Account")
+ }
+ }
+ .disabled(!self.isEditable)
+
+ Section {
+ TextField("Login", text: self.$account.login, prompt: Text("Required"))
+ .disabled(self.account.persisted)
+
+ if self.account.persisted {
+ SecureField("Password", text: self.$password, prompt: Text("Optional"))
+ } else {
+ TextField("Password", text: self.$password, prompt: Text("Optional"))
+ }
+ }
+ .sectionActions {
+ HStack {
+ Spacer()
+ Text("The following permissions define what users of this account can do on this server. Accounts that can disconnect other users are shown in red.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Spacer()
+ }
+ }
+ .disabled(!self.isEditable)
+
+ Section("Files") {
+ Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles))
+// .disabled(self.model.access?.contains(.canDownloadFiles) == false)
+ Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders))
+// .disabled(model.access?.contains(.canDownloadFolders) == false)
+ Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles))
+// .disabled(model.access?.contains(.canUploadFiles) == false)
+ Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders))
+// .disabled(model.access?.contains(.canUploadFolders) == false)
+ Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere))
+// .disabled(model.access?.contains(.canUploadAnywhere) == false)
+ Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles))
+// .disabled(model.access?.contains(.canDeleteFiles) == false)
+ Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles))
+// .disabled(model.access?.contains(.canRenameFiles) == false)
+ Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles))
+// .disabled(model.access?.contains(.canMoveFiles) == false)
+ Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment))
+// .disabled(model.access?.contains(.canSetFileComment) == false)
+ Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders))
+// .disabled(model.access?.contains(.canCreateFolders) == false)
+ Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders))
+// .disabled(model.access?.contains(.canDeleteFolders) == false)
+ Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders))
+// .disabled(model.access?.contains(.canRenameFolders) == false)
+ Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders))
+// .disabled(model.access?.contains(.canMoveFolders) == false)
+ Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment))
+// .disabled(model.access?.contains(.canSetFolderComment) == false)
+ Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes))
+// .disabled(model.access?.contains(.canViewDropBoxes) == false)
+ Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases))
+// .disabled(model.access?.contains(.canMakeAliases) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("User Maintenance") {
+ Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers))
+// .disabled(model.access?.contains(.canCreateUsers) == false)
+ Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers))
+// .disabled(model.access?.contains(.canDeleteUsers) == false)
+ Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers))
+// .disabled(model.access?.contains(.canOpenUsers) == false)
+ Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers))
+// .disabled(model.access?.contains(.canModifyUsers) == false)
+ Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo))
+// .disabled(model.access?.contains(.canGetClientInfo) == false)
+
+ Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers))
+// .disabled(model.access?.contains(.canDisconnectUsers) == false)
+ Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected))
+// .disabled(model.access?.contains(.cantBeDisconnected) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Messaging") {
+ Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages))
+// .disabled(model.access?.contains(.canSendMessages) == false)
+ Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast))
+// .disabled(model.access?.contains(.canBroadcast) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("News") {
+ Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard))
+// .disabled(model.access?.contains(.canReadMessageBoard) == false)
+ Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard))
+// .disabled(model.access?.contains(.canPostMessageBoard) == false)
+ Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles))
+// .disabled(model.access?.contains(.canDeleteNewsArticles) == false)
+ Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories))
+// .disabled(model.access?.contains(.canCreateNewsCategories) == false)
+ Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories))
+// .disabled(model.access?.contains(.canDeleteNewsCategories) == false)
+ Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders))
+// .disabled(model.access?.contains(.canCreateNewsFolders) == false)
+ Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders))
+// .disabled(model.access?.contains(.canDeleteNewsFolders) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Chat") {
+ Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat))
+// .disabled(model.access?.contains(.canCreateChat) == false)
+ Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat))
+// .disabled(model.access?.contains(.canReadChat) == false)
+ Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat))
+// .disabled(model.access?.contains(.canSendChat) == false)
+ }
+ .disabled(!self.isEditable)
+
+ Section("Miscellaneous") {
+ Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName))
+// .disabled(model.access?.contains(.canUseAnyName) == false)
+ Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement))
+// .disabled(model.access?.contains(.canSkipAgreement) == false)
+ }
+ .disabled(!self.isEditable)
+ }
+ .formStyle(.grouped)
+ }
+}
diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift
new file mode 100644
index 0000000..d288196
--- /dev/null
+++ b/Hotline/macOS/Accounts/AccountManagerView.swift
@@ -0,0 +1,217 @@
+import SwiftUI
+
+let DEFAULT_ACCOUNT_NAME = "Untitled Account"
+
+struct AccountManagerView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var accounts: [HotlineAccount] = []
+ @State private var selection: HotlineAccount?
+ @State private var loading: Bool = true
+
+ @State private var creatorShown: Bool = false
+ @State private var deleteConfirm: Bool = false
+ @State private var accountToEdit: HotlineAccount? = nil
+ @State private var accountToDelete: HotlineAccount? = nil
+
+ private func newAccount() {
+ self.creatorShown = true
+ }
+
+ private func editAccount(_ account: HotlineAccount) {
+ // Always get the latest version from the array to avoid stale data
+ if let currentAccount = self.accounts.first(where: { $0.id == account.id }) {
+ self.accountToEdit = currentAccount
+ }
+ }
+
+ private func deleteAccount(_ account: HotlineAccount) {
+ self.accountToDelete = account
+ self.deleteConfirm = true
+ }
+
+ var body: some View {
+ VStack(spacing: 8) {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Accounts")
+ .font(.headline)
+
+ Spacer()
+
+ HStack {
+ Button {
+ self.newAccount()
+ } label: {
+ Image(systemName: "plus")
+ .padding(4)
+ }
+ .buttonBorderShape(.circle)
+ .help("New Account")
+
+ Button {
+ if let account = self.selection {
+ self.editAccount(account)
+ }
+ } label: {
+ Image(systemName: "pencil")
+ .padding(4)
+ }
+ .buttonBorderShape(.circle)
+ .disabled(self.selection == nil)
+ .help("Edit Account")
+
+ Button {
+ if let account = self.selection {
+ self.deleteAccount(account)
+ }
+ } label: {
+ Image(systemName: "trash")
+ .padding(4)
+ }
+ .tint(.hotlineRed)
+ .buttonBorderShape(.circle)
+ .disabled(self.selection == nil)
+ .help("Delete Account")
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 24)
+
+ self.accountList
+// .overlay {
+// if self.loading {
+// ProgressView()
+// .progressViewStyle(.linear)
+// .controlSize(.extraLarge)
+// .frame(width: 100)
+// }
+// }
+ }
+ .environment(\.defaultMinListRowHeight, 34)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .task {
+ if self.loading {
+ do {
+ self.accounts = try await self.model.getAccounts()
+ }
+ catch {
+ self.dismiss()
+ }
+
+ self.loading = false
+ }
+ }
+ .toolbar {
+ if self.loading {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ self.dismiss()
+ } label: {
+ Text("OK")
+ }
+ }
+ }
+ }
+
+ private var accountList: some View {
+ List(self.accounts, id: \.self, selection: self.$selection) { account in
+ HStack(spacing: 5) {
+ Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User")
+ .frame(width: 16, height: 16)
+ .opacity((account.access.rawValue == 0) ? 0.5 : 1.0)
+ Text(account.name)
+ .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary))
+
+ Spacer()
+
+ Text(account.login)
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .contextMenu(forSelectionType: HotlineAccount.self) { items in
+ Button {
+ if let item = items.first {
+ self.editAccount(item)
+ }
+ } label: {
+ Label("Edit Account...", systemImage: "pencil")
+ }
+ .disabled(items.isEmpty)
+
+ Divider()
+
+ Button(role: .destructive) {
+ if let item = items.first {
+ self.deleteAccount(item)
+ }
+ } label: {
+ Label("Delete Account...", systemImage: "trash")
+ }
+ .disabled(items.isEmpty)
+ } primaryAction: { items in
+ if let account = items.first {
+ self.editAccount(account)
+ }
+ }
+ .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: {
+ Button("Delete", role: .destructive) {
+ guard let account = self.accountToDelete else {
+ return
+ }
+
+ self.accountToDelete = nil
+
+ Task {
+ self.selection = nil
+
+ if account.persisted {
+ try await self.model.deleteUser(login: account.login)
+ }
+
+ self.accounts = self.accounts.filter { $0.id != account.id }
+ self.deleteConfirm = false
+ }
+ }
+ }, message: {
+ Text("You cannot undo this action.")
+ })
+ .sheet(item: self.$accountToEdit) { account in
+ AccountDetailsView(account: account) { editedAccount in
+ if let i = self.accounts.firstIndex(of: editedAccount) {
+ self.accounts.remove(at: i)
+ self.accounts.insert(editedAccount, at: i)
+ }
+ self.accounts.sort { $0.name < $1.name }
+ self.selection = editedAccount
+ self.accountToEdit = nil
+ }
+ .id(account.id)
+ .environment(self.model)
+ .frame(width: 480)
+ .frame(minHeight: 300, idealHeight: 400)
+ .presentationSizing(.fitted)
+ }
+ .sheet(isPresented: self.$creatorShown) {
+ AccountDetailsView { newAccount in
+ self.accounts.append(newAccount)
+ self.accounts.sort { $0.name < $1.name }
+ self.selection = newAccount
+ }
+ .environment(self.model)
+ .frame(width: 480)
+ .frame(minHeight: 300, idealHeight: 400)
+ .presentationSizing(.fitted)
+ }
+ }
+}
+
+
diff --git a/Hotline/macOS/AppUpdateView.swift b/Hotline/macOS/AppUpdateView.swift
new file mode 100644
index 0000000..89312fc
--- /dev/null
+++ b/Hotline/macOS/AppUpdateView.swift
@@ -0,0 +1,187 @@
+import SwiftUI
+import MarkdownUI
+import AppKit
+import Observation
+
+struct AppUpdateView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Bindable private var update = AppUpdate.shared
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ if let message = update.message {
+ messageOnlyView(message)
+ }
+ else if update.release != nil {
+ headerSection
+ releaseNotesSection
+ actionRow
+ }
+ else {
+ defaultPlaceholder
+ }
+ }
+ .padding(.horizontal, 24)
+ .padding(.bottom, 24)
+ .padding(.top, 8)
+ .frame(width: update.message != nil ? 380 : 520)
+ .frame(idealHeight: 360)
+ .onChange(of: update.showWindow) { _, show in
+ if !show {
+ dismiss()
+ }
+ }
+ .onDisappear {
+ update.handleWindowDismissed()
+ }
+ }
+
+ private var headerSection: some View {
+ HStack(alignment: .center, spacing: 8) {
+ Image(nsImage: NSApplication.shared.applicationIconImage)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 56, height: 56)
+ .shadow(color: Color.black.mix(with: .red, by: 0.4).opacity(0.15), radius: 3, y: 1.5)
+
+ VStack(alignment: .leading, spacing: 2) {
+ if let release = update.release {
+ Text("Hotline \(release.displayVersion)")
+ .font(.title2)
+ .fontWeight(.semibold)
+ }
+ Text("A new version of Hotline is available. 🎉")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var releaseNotesSection: some View {
+ ScrollView(.vertical) {
+ Markdown(releaseNotesMarkdown())
+ .textSelection(.enabled)
+ .markdownTheme(.gitHub.text(text: {
+ FontSize(.em(0.85))
+ }))
+ .font(.system(size: 14))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 12)
+ }
+ .frame(minHeight: 220, maxHeight: 260)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+
+ private var actionRow: some View {
+ HStack {
+ Button("Not Now") {
+ update.remindLater()
+ }
+ .buttonBorderShape(.capsule)
+ .keyboardShortcut(.escape, modifiers: [])
+ .controlSize(.large)
+ .disabled(update.isDownloading)
+
+ Spacer()
+
+ if update.isDownloading {
+ ProgressView()
+ .controlSize(.small)
+ .padding(.trailing, 12)
+ }
+
+ Button("Download") {
+ update.startDownload()
+ }
+ .buttonBorderShape(.capsule)
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.large)
+ .disabled(update.isDownloading)
+ }
+ }
+
+ @ViewBuilder
+ private func messageOnlyView(_ message: AppUpdateMessage) -> some View {
+ let iconName = {
+ switch message.kind {
+ case .info:
+ return "info.circle"
+ case .success:
+ return "checkmark.circle.fill"
+ case .error:
+ return "exclamationmark.triangle.fill"
+ }
+ }()
+
+ HStack(alignment: .center, spacing: 12) {
+
+ if message.kind == .success {
+ Text("👍")
+ .font(.system(size: 42))
+ .shadow(color: .yellow.mix(with: .black, by: 0.3).opacity(0.2), radius: 4, y: 1.5)
+ }
+ else {
+ Image(systemName: iconName)
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.multicolor)
+ .frame(width: 48, height: 48)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(message.title)
+ .font(.title2)
+ .fontWeight(.semibold)
+ Text(message.detail)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+ }
+
+ private var defaultPlaceholder: some View {
+ VStack(alignment: .center, spacing: 12) {
+ Text("No update information available.")
+ .font(.headline)
+ Button("Close") {
+ update.acknowledgeMessage()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func releaseNotesMarkdown() -> String {
+ if let combined = update.releaseNotesCombined?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ combined.isEmpty == false {
+ return combined
+ }
+
+ let fallback = update.release?.notes.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return fallback.isEmpty ? "_No release notes provided._" : fallback
+ }
+}
+
+#Preview {
+ AppUpdate.shared.release = UpdateReleaseInfo(
+ tagName: "1.0beta1",
+ displayVersion: "1.0b1",
+ versionNumber: 1.0,
+ buildNumber: 1,
+ notes: """
+ - Added support for release notes in Markdown.
+ - Improved the update workflow for macOS users.
+ """,
+ downloadURL: URL(string: "https://example.com")!,
+ assetName: "Hotline.zip"
+ )
+ AppUpdate.shared.releases = [AppUpdate.shared.release!]
+ AppUpdate.shared.releaseNotesCombined = nil
+ AppUpdate.shared.showWindow = true
+ return AppUpdateView()
+}
diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift
index 9026c6f..34a4bbb 100644
--- a/Hotline/macOS/MessageBoardEditorView.swift
+++ b/Hotline/macOS/Board/MessageBoardEditorView.swift
@@ -8,8 +8,8 @@ struct MessageBoardEditorView: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
- @Environment(Hotline.self) private var model: Hotline
-
+ @Environment(HotlineState.self) private var model: HotlineState
+
@State private var text: String = ""
@State private var sending: Bool = false
@@ -20,14 +20,14 @@ struct MessageBoardEditorView: View {
let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
- model.postToMessageBoard(text: cleanedText)
- let _ = await model.getMessageBoard()
-
- // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
- // if success {
- // await model.getNewsList(at: path)
- // }
-
+ try? await model.postToMessageBoard(text: cleanedText)
+ let _ = try? await model.getMessageBoard()
+
+// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
+// if success {
+// await model.getNewsList(at: path)
+// }
+
sending = false
}
@@ -68,9 +68,9 @@ struct MessageBoardEditorView: View {
} else {
Button {
sending = true
- model.postToMessageBoard(text: text)
Task {
- let _ = await model.getMessageBoard()
+ try? await model.postToMessageBoard(text: text)
+ let _ = try? await model.getMessageBoard()
Task { @MainActor in
sending = false
dismiss()
diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift
new file mode 100644
index 0000000..be039d8
--- /dev/null
+++ b/Hotline/macOS/Board/MessageBoardView.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+struct MessageBoardView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+
+ @State private var composerDisplayed: Bool = false
+ @State private var composerText: String = ""
+
+ var body: some View {
+ NavigationStack {
+ if self.model.access?.contains(.canReadMessageBoard) != true {
+ self.disabledBoardView
+ }
+ else if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty {
+ self.emptyBoardView
+ }
+ else {
+ self.messageBoardView
+ }
+ }
+ .sheet(isPresented: $composerDisplayed) {
+ MessageBoardEditorView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .frame(idealWidth: 450, idealHeight: 350)
+ }
+ .toolbar {
+ ToolbarItem(placement:.primaryAction) {
+ Button {
+ self.composerDisplayed.toggle()
+ } label: {
+ Image(systemName: "square.and.pencil")
+ }
+ .disabled((self.model.access?.contains(.canPostMessageBoard) != true) || (self.model.access?.contains(.canReadMessageBoard) != true))
+ .help("Post to Message Board")
+ }
+ }
+ .task {
+ if !self.model.messageBoardLoaded {
+ let _ = try? await self.model.getMessageBoard()
+ }
+ }
+ }
+
+ private var disabledBoardView: some View {
+ ContentUnavailableView {
+ Label("No Message Board", systemImage: "quote.bubble")
+ } description: {
+ Text("This server has turned off their message board")
+ }
+ }
+
+ private var emptyBoardView: some View {
+ ContentUnavailableView {
+ Label("No Posts", systemImage: "quote.bubble")
+ } description: {
+ Text("Message board posts will appear here")
+ }
+ }
+
+ private var messageBoardView: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading) {
+ ForEach(self.model.messageBoard, id: \.self) { msg in
+ Text(LocalizedStringKey(msg))
+ .tint(Color("Link Color"))
+ .lineLimit(100)
+ .lineSpacing(4)
+ .padding()
+ .textSelection(.enabled)
+ Divider()
+ }
+ }
+ Spacer()
+ }
+ .overlay {
+ if !self.model.messageBoardLoaded {
+ VStack {
+ ProgressView()
+ .controlSize(.large)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ }
+}
+
+#Preview {
+ MessageBoardView()
+ .environment(HotlineState())
+}
diff --git a/Hotline/macOS/BroadcastMessageSheet.swift b/Hotline/macOS/BroadcastMessageSheet.swift
new file mode 100644
index 0000000..b3bd7ea
--- /dev/null
+++ b/Hotline/macOS/BroadcastMessageSheet.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+
+fileprivate let CHARACTER_LIMIT: Int = 255
+
+struct BroadcastMessageSheet: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var sending: Bool = false
+
+ private var message: String {
+ self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var body: some View {
+ @Bindable var model = self.model
+
+ VStack {
+ TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical)
+ .textFieldStyle(.plain)
+ .lineLimit(5, reservesSpace: true)
+ }
+ .padding(.leading, 32)
+ .padding(.top, 2)
+ .overlay(alignment: .topLeading) {
+ Image("Server Message")
+ }
+ .padding(16)
+ .frame(width: 400)
+ .toolbar {
+ if self.sending {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Broadcast") {
+ let message = self.message
+ model.broadcastMessage = ""
+
+ guard !message.isBlank else {
+ return
+ }
+
+ Task {
+ self.sending = true
+ defer { self.sending = false }
+
+ try await model.sendBroadcast(message)
+
+ self.dismiss()
+ }
+ }
+ .disabled(self.message.isEmpty)
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift
new file mode 100644
index 0000000..e1ec73a
--- /dev/null
+++ b/Hotline/macOS/Chat/ChatView.swift
@@ -0,0 +1,345 @@
+import SwiftUI
+
+enum FocusedField: Int, Hashable {
+ case chatInput
+}
+
+struct ChatJoinedMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ Image(systemName: "arrow.right")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .foregroundStyle(.primary)
+ .frame(width: 12, height: 12)
+
+ Text(message.text)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .fontWeight(.semibold)
+ .textSelection(.disabled)
+
+ Spacer()
+ }
+ .opacity(0.3)
+ }
+}
+
+struct ChatLeftMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 4) {
+ Image(systemName: "arrow.left")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .foregroundStyle(.primary)
+ .frame(width: 12, height: 12)
+
+ Text(message.text)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .fontWeight(.semibold)
+ .textSelection(.disabled)
+
+ Spacer()
+ }
+ .opacity(0.3)
+ }
+}
+
+
+struct ChatDisconnectedMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack {
+ Spacer()
+ }
+ .frame(height: 5)
+ .background(Color.primary)
+ .clipShape(.capsule)
+ .opacity(0.1)
+ }
+}
+
+struct ChatMessageView: View {
+ let message: ChatMessage
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ if let username = message.username {
+ Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString())
+ }
+ else {
+ Text(message.text)
+ }
+ Spacer()
+ }
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(Color("Link Color"))
+ }
+}
+
+struct ChatView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.colorScheme) var colorScheme
+ @Environment(\.dismiss) var dismiss
+
+ @State private var scrollPos: Int?
+ @State private var contentHeight: CGFloat = 0
+
+ @State private var searchQuery: String = ""
+ @State private var searchResults: [ChatMessage] = []
+ @State private var isSearching: Bool = false
+
+ @State private var stableBannerImage: Image?
+
+ @FocusState private var focusedField: FocusedField?
+
+ @Namespace var bottomID
+
+ private var bindableModel: Bindable<HotlineState> {
+ Bindable(model)
+ }
+
+// @State private var showingExporter: Bool = false
+//
+// @State private var chatDocument: TextFile = TextFile()
+
+ var displayedMessages: [ChatMessage] {
+ searchQuery.isEmpty ? model.chat : searchResults
+ }
+
+// private var blurredBannerImage: some View {
+// self.stableBannerImage?
+// .resizable()
+// .scaledToFit()
+// .frame(maxWidth: 468.0)
+// .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+// .offset(y: 1.5)
+// .blur(radius: 4)
+// .opacity(0.2)
+// }
+
+ private var bannerView: some View {
+ ZStack {
+ self.stableBannerImage?
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 468.0)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .offset(y: 1.5)
+ .blur(radius: 4)
+ .opacity(0.2)
+ self.stableBannerImage?
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 468.0)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+ }
+
+ var body: some View {
+ @Bindable var bindModel = model
+
+ NavigationStack {
+ ScrollViewReader { reader in
+ VStack(alignment: .leading, spacing: 0) {
+
+ // MARK: Scroll View
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading, spacing: 8) {
+
+ ForEach(displayedMessages) { msg in
+ if msg.type == .agreement {
+ VStack(alignment: .center, spacing: 16) {
+ HStack(spacing: 0) {
+ Spacer(minLength: 0)
+ self.bannerView
+ Spacer(minLength: 0)
+ }
+
+ ServerAgreementView(text: msg.text)
+ }
+ .padding(.vertical, 24)
+ }
+ else if msg.type == .server {
+ ServerMessageView(message: msg.text)
+ }
+ else if msg.type == .joined {
+ ChatJoinedMessageView(message: msg)
+ }
+ else if msg.type == .left {
+ ChatLeftMessageView(message: msg)
+ }
+ else if msg.type == .signOut {
+ ChatDisconnectedMessageView(message: msg)
+ .padding(.vertical, 24)
+ }
+ else {
+ ChatMessageView(message: msg)
+ }
+ }
+ }
+ .padding()
+
+ VStack(spacing: 0) {}.id(bottomID)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+// .defaultScrollAnchor(.bottom, for: .initialOffset)
+ .defaultScrollAnchor(.bottom, for: .alignment)
+ .defaultScrollAnchor(.bottom, for: .sizeChanges)
+ .onChange(of: model.chat.count) {
+ // Re-run search when new messages arrive to keep filter active
+ if !searchQuery.isEmpty {
+ performSearch()
+ }
+ reader.scrollTo(bottomID, anchor: .bottom)
+ model.markPublicChatAsRead()
+ }
+ .onAppear {
+ self.focusedField = .chatInput
+ model.markPublicChatAsRead()
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .onChange(of: self.model.bannerImage) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .onChange(of: searchQuery) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+ .onChange(of: isSearching) {
+ reader.scrollTo(bottomID, anchor: .bottom)
+ }
+
+ // MARK: Input Divider
+ Divider()
+
+ // MARK: Input Bar
+ HStack(alignment: .lastTextBaseline, spacing: 0) {
+ TextField("", text: $bindModel.chatInput, axis: .vertical)
+ .focused($focusedField, equals: .chatInput)
+ .textFieldStyle(.plain)
+ .lineLimit(1...5)
+ .multilineTextAlignment(.leading)
+ .onSubmit {
+ if !model.chatInput.isEmpty {
+ let message = model.chatInput
+ let announce = NSEvent.modifierFlags.contains(.shift)
+ Task {
+ try? await model.sendChat(message, announce: announce)
+ }
+ }
+ model.chatInput = ""
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ .frame(maxWidth: .infinity, minHeight: 28)
+ .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
+ .overlay(alignment: .leadingLastTextBaseline) {
+ Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16)
+ }
+ .onContinuousHover { phase in
+ switch phase {
+ case .active(_):
+ NSCursor.iBeam.set()
+ case .ended:
+ NSCursor.arrow.set()
+ break
+ }
+ }
+ .onTapGesture(count: 1) {
+ focusedField = .chatInput
+ }
+ }
+ }
+ .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+// .navigationTitle(model.serverTitle)
+ .onChange(of: searchQuery) {
+ performSearch()
+ }
+ .onChange(of: model.bannerImage) { oldValue, newValue in
+ stableBannerImage = newValue
+ }
+ .onAppear {
+ stableBannerImage = model.bannerImage
+ }
+// .toolbar {
+// ToolbarItem(placement: .primaryAction) {
+// Button {
+// showingExporter = true
+// } label: {
+// Image(systemName: "square.and.arrow.up")
+// }.help("Save Chat...")
+// }
+// }
+// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in
+// switch result {
+// case .success(let url):
+// print("Saved to \(url)")
+//
+// case .failure(let error):
+// print(error.localizedDescription)
+// }
+// self.chatDocument.text = ""
+// }
+ }
+
+ private func performSearch() {
+ guard !searchQuery.isEmpty else {
+ searchResults = []
+ return
+ }
+
+ searchResults = model.searchChat(query: searchQuery)
+ }
+
+// private func prepareChatDocument() -> Bool {
+// var text: String = String()
+//
+// self.chatDocument.text = ""
+// for msg in model.chat {
+// if msg.type == .agreement {
+// text.append(msg.text)
+// text.append("\n\n")
+// }
+// else if msg.type == .message {
+// if let username = msg.username {
+// text.append("\(username): \(msg.text)")
+// }
+// else {
+// text.append(msg.text)
+// }
+// text.append("\n")
+// }
+// else if msg.type == .status {
+// text.append(msg.text)
+// text.append("\n")
+// }
+// }
+//
+// if text.isEmpty {
+// return false
+// }
+//
+// self.chatDocument.text = text
+//
+// return true
+// }
+}
+
+#Preview {
+ ChatView()
+ .environment(HotlineState())
+}
diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/Chat/ServerAgreementView.swift
index 77e9e15..c65a1cb 100644
--- a/Hotline/macOS/ServerAgreementView.swift
+++ b/Hotline/macOS/Chat/ServerAgreementView.swift
@@ -1,6 +1,6 @@
import SwiftUI
-private let MAX_AGREEMENT_HEIGHT: CGFloat = 280
+fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340
struct ServerAgreementView: View {
let text: String
@@ -36,12 +36,12 @@ struct ServerAgreementView: View {
.scrollIndicators(.never)
.frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT)
.scrollBounceBehavior(.basedOnSize)
- #if os(iOS)
- .background(Color("Agreement Background"))
- #elseif os(macOS)
- .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
- #endif
- .overlay(
+#if os(iOS)
+ .background(Color("Agreement Background"))
+#elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+#endif
+ .overlay(alignment: .bottomTrailing) {
ZStack(alignment: .bottomTrailing) {
Group {
if !expandable || expanded {
@@ -65,14 +65,23 @@ struct ServerAgreementView: View {
.frame(width: 12, height: 12)
.foregroundColor(.primary.opacity(0.8)), alignment: .center)
}
- )
+ }, label: {
+ Image(systemName: "arrow.up.and.down.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .frame(width: 16, height: 16)
+ .foregroundColor(.primary.opacity(0.5))
+ })
.buttonStyle(.plain)
+ .buttonBorderShape(.circle)
.help("Expand Server Agreement")
+ .padding([.trailing, .bottom], 16)
}
}
- }, alignment: .bottomTrailing
- )
- .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/Chat/ServerMessageView.swift
index 98f4043..6e8f5db 100644
--- a/Hotline/macOS/ServerMessageView.swift
+++ b/Hotline/macOS/Chat/ServerMessageView.swift
@@ -19,12 +19,12 @@ struct ServerMessageView: View {
}
.padding()
.frame(maxWidth: .infinity)
- #if os(iOS)
- .background(Color("Agreement Background"))
- #elseif os(macOS)
- .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
- #endif
- .clipShape(RoundedRectangle(cornerRadius: 8))
+#if os(iOS)
+ .background(Color("Agreement Background"))
+#elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+#endif
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift
deleted file mode 100644
index 33a43b1..0000000
--- a/Hotline/macOS/ChatView.swift
+++ /dev/null
@@ -1,242 +0,0 @@
-import SwiftUI
-
-enum FocusedField: Int, Hashable {
- case chatInput
-}
-
-struct ChatView: View {
- @Environment(Hotline.self) private var model: Hotline
- @Environment(\.colorScheme) var colorScheme
- @Environment(\.dismiss) var dismiss
-
- @State var input: String = ""
- @State private var scrollPos: Int?
- @State private var contentHeight: CGFloat = 0
-
- @FocusState private var focusedField: FocusedField?
-
- @Namespace var bottomID
-
- @State private var showingExporter: Bool = false
-
- @State private var chatDocument: TextFile = TextFile()
-
- var body: some View {
- NavigationStack {
- ScrollViewReader { reader in
- VStack(alignment: .leading, spacing: 0) {
-
- // MARK: Scroll View
- GeometryReader { gm in
- ScrollView(.vertical) {
- LazyVStack(alignment: .leading) {
-
- ForEach(model.chat) { msg in
-
- // MARK: Agreement
- if msg.type == .agreement {
- #if os(iOS)
- if let bannerImage = self.model.bannerImage {
- Image(uiImage: bannerImage)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: 468.0)
- .clipShape(RoundedRectangle(cornerRadius: 3))
- }
- #endif
- ServerAgreementView(text: msg.text)
- .padding(.bottom, 16)
- }
- // MARK: Server Message
- else if msg.type == .server {
- ServerMessageView(message: msg.text)
- .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
- }
- // MARK: Status
- else if msg.type == .status {
- HStack {
- Spacer()
- Text(msg.text)
- .lineLimit(1)
- .truncationMode(.middle)
- .textSelection(.disabled)
- .opacity(0.3)
- Spacer()
- }
- .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
- } else {
- HStack(alignment: .firstTextBaseline) {
- if let username = msg.username {
- // if msg.text.isImageURL() {
- // HStack(alignment: .bottom) {
- // Text("**\(username):** ")
- //
- // let imageURL = URL(string: msg.text)!
- // AsyncImage(url: imageURL) { phase in
- // switch phase {
- // case .failure:
- // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown()))
- // .lineSpacing(4)
- // .multilineTextAlignment(.leading)
- // .textSelection(.enabled)
- // .tint(Color("Link Color"))
- // case .success(let img):
- // Link(destination: imageURL) {
- // img
- // .resizable()
- // .scaledToFit()
- // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading)
- // .onAppear {
- // reader.scrollTo(bottomID, anchor: .bottom)
- // }
- // }
- // default:
- // ProgressView().controlSize(.small)
- // }
- // }
- //
- // Spacer()
- // }
- // }
- // else {
- Text(
- LocalizedStringKey(
- "**\(username):** \(msg.text)".convertingLinksToMarkdown())
- )
- .lineSpacing(4)
- .multilineTextAlignment(.leading)
- .textSelection(.enabled)
- .tint(Color("Link Color"))
- // }
- } else {
- Text(msg.text)
- .lineSpacing(4)
- .multilineTextAlignment(.leading)
- .textSelection(.enabled)
- .tint(Color("Link Color"))
- }
- Spacer()
- }
- .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
- }
- }
- }
- .padding()
-
- VStack(spacing: 0) {}.id(bottomID)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .defaultScrollAnchor(.bottom)
- .onChange(of: model.chat.count) {
- reader.scrollTo(bottomID, anchor: .bottom)
- model.markPublicChatAsRead()
- }
- .onAppear {
- reader.scrollTo(bottomID, anchor: .bottom)
- }
- .onChange(of: gm.size) {
- reader.scrollTo(bottomID, anchor: .bottom)
- }
- }
-
- // MARK: Input Divider
- Divider()
-
- // MARK: Input Bar
- HStack(alignment: .lastTextBaseline, spacing: 0) {
- TextField("", text: $input, axis: .vertical)
- .focused($focusedField, equals: .chatInput)
- .textFieldStyle(.plain)
- .lineLimit(1...5)
- .multilineTextAlignment(.leading)
- .onSubmit {
- if !self.input.isEmpty {
- model.sendChat(self.input, announce: NSEvent.modifierFlags.contains(.shift))
- }
- self.input = ""
- }
- .frame(maxWidth: .infinity)
- .padding()
- }
- .frame(maxWidth: .infinity, minHeight: 28)
- .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
- .overlay(alignment: .leadingLastTextBaseline) {
- Image(systemName: "chevron.right").opacity(0.4).offset(x: 16)
- }
- .onContinuousHover { phase in
- switch phase {
- case .active(_):
- NSCursor.iBeam.set()
- case .ended:
- NSCursor.arrow.set()
- break
- }
- }
- .onTapGesture {
- focusedField = .chatInput
- }
- }
- }
- }
- .background(Color(nsColor: .textBackgroundColor))
- // .toolbar {
- // ToolbarItem(placement: .primaryAction) {
- // Button {
- // if prepareChatDocument() {
- // showingExporter = true
- // }
- // } label: {
- // Image(systemName: "square.and.arrow.up")
- // }.help("Save Chat...")
- // }
- // }
- .fileExporter(
- isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText,
- defaultFilename: "\(self.model.serverTitle) Chat.txt"
- ) { result in
- switch result {
- case .success(let url):
- print("Saved to \(url)")
-
- case .failure(let error):
- print(error.localizedDescription)
- }
- self.chatDocument.text = ""
- }
- }
-
- private func prepareChatDocument() -> Bool {
- var text: String = String()
-
- self.chatDocument.text = ""
- for msg in model.chat {
- if msg.type == .agreement {
- text.append(msg.text)
- text.append("\n\n")
- } else if msg.type == .message {
- if let username = msg.username {
- text.append("\(username): \(msg.text)")
- } else {
- text.append(msg.text)
- }
- text.append("\n")
- } else if msg.type == .status {
- text.append(msg.text)
- text.append("\n")
- }
- }
-
- if text.isEmpty {
- return false
- }
-
- self.chatDocument.text = text
-
- return true
- }
-}
-
-#Preview {
- ChatView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
-}
diff --git a/Hotline/macOS/ConnectView.swift b/Hotline/macOS/ConnectView.swift
new file mode 100644
index 0000000..8419522
--- /dev/null
+++ b/Hotline/macOS/ConnectView.swift
@@ -0,0 +1,147 @@
+import SwiftUI
+
+struct ConnectView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @Binding var address: String
+ @Binding var login: String
+ @Binding var password: String
+
+ var action: (() -> Void)? = nil
+
+ @State private var bookmarkSheetPresented: Bool = false
+ @State private var bookmarkName: String = ""
+
+ private enum FocusFields {
+ case address
+ case login
+ case password
+ }
+
+ @FocusState private var focusedField: FocusFields?
+
+ var body: some View {
+ VStack(alignment: .center, spacing: 0) {
+ Form {
+ HStack(alignment: .top, spacing: 10) {
+ Image("Server Large")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading) {
+ Text("Connect to Server")
+ Text("Enter the address of a Hotline server to connect to.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ }
+
+ TextField(text: self.$address) {
+ Text("Address")
+ }
+ .focused($focusedField, equals: .address)
+
+ TextField(text: self.$login, prompt: Text("Optional")) {
+ Text("Login")
+ }
+ .focused($focusedField, equals: .login)
+
+ SecureField(text: self.$password, prompt: Text("Optional")) {
+ Text("Password")
+ }
+ .focused($focusedField, equals: .password)
+ }
+ .formStyle(.grouped)
+ .fixedSize(horizontal: false, vertical: true)
+
+ HStack {
+ Button {
+ if !self.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ self.bookmarkSheetPresented = true
+ }
+ } label: {
+ Image(systemName: "bookmark.fill")
+ }
+ .disabled(self.address.isEmpty)
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .help("Bookmark server")
+
+ Spacer()
+
+ Button("Cancel") {
+ self.dismiss()
+ }
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .keyboardShortcut(.cancelAction)
+
+ Button("Connect") {
+ self.action?()
+// self.connectToServer()
+ }
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(.horizontal, 20)
+ }
+// .onChange(of: self.address) {
+// let (a, p) = Server.parseServerAddressAndPort(connectAddress)
+// server.address = a
+// server.port = p
+// }
+// .onChange(of: connectLogin) {
+// server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+// }
+// .onChange(of: connectPassword) {
+// server.password = connectPassword
+// }
+ .frame(maxWidth: 380)
+ .padding()
+ .onAppear {
+ self.focusedField = .address
+ }
+ .sheet(isPresented: self.$bookmarkSheetPresented) {
+ VStack(alignment: .leading) {
+ Text("Save Bookmark")
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 4)
+ TextField("Bookmark Name", text: self.$bookmarkName)
+ .textFieldStyle(.roundedBorder)
+ .controlSize(.large)
+ }
+ .frame(width: 250)
+ .padding()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.bookmarkSheetPresented = false
+ self.bookmarkName = ""
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ let name = String(self.bookmarkName.trimmingCharacters(in: .whitespacesAndNewlines))
+ if !name.isEmpty {
+ self.bookmarkSheetPresented = false
+ self.bookmarkName = ""
+
+ let (host, port) = Server.parseServerAddressAndPort(self.address)
+ let login: String? = self.login.isBlank ? nil : self.login
+ let password: String? = self.password.isBlank ? nil : self.password
+
+ if !host.isEmpty {
+ let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password)
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/FileDetailsView.swift b/Hotline/macOS/FileDetailsView.swift
deleted file mode 100644
index 16b7a60..0000000
--- a/Hotline/macOS/FileDetailsView.swift
+++ /dev/null
@@ -1,149 +0,0 @@
-import Foundation
-import SwiftUI
-
-struct FileDetailsView: View {
- @Environment(Hotline.self) private var model: Hotline
- @Environment(\.presentationMode) var presentationMode
-
- var fd: FileDetails
-
- @State private var comment: String = ""
- @State private var filename: String = ""
-
- var body: some View {
- VStack(alignment: .leading) {
- Form {
- HStack(alignment: .center) {
- FileIconView(filename: fd.name, fileType: nil)
- .frame(width: 32, height: 32)
- TextField("", text: $filename)
- .disabled(!self.canRename())
- }
- HStack(alignment: .center) {
- Text("Type:").bold().padding(.leading, 43)
- Text(fd.type)
- }
- HStack(alignment: .center) {
- Text("Creator:").bold().padding(.leading, 26)
- Text(fd.creator)
- }
- HStack(alignment: .center) {
- Text("Size:").bold().bold().padding(.leading, 48)
- Text(self.formattedSize(byteCount: fd.size))
- }
- HStack(alignment: .center) {
- Text("Created:").bold().padding(.leading, 24)
- Text("\(FileDetailsView.dateFormatter.string(from: fd.created))")
- }
- HStack(alignment: .center) {
- Text("Modified:").bold().padding(.leading, 19)
- Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))")
- }
- HStack(alignment: .center) {
- Text("Comments:").bold().padding(.top, 8)
- .padding(.leading, 5)
- }
-
- VStack(alignment: .trailing) {
- TextEditor(text: $comment)
- .padding(.leading, 2)
- .padding(.top, 1)
- .font(.system(size: 13))
- .background(Color(nsColor: .textBackgroundColor))
- .border(Color.secondary, width: 1)
- .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
- .disabled(!self.canSetComment())
- }
- }
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- presentationMode.wrappedValue.dismiss()
- }
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- var editedFilename: String?
- if filename != fd.name {
- editedFilename = filename
- }
-
- var editedComment: String?
- if comment != fd.comment {
- editedComment = comment
- }
-
- model.client.sendSetFileInfo(
- fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment)
- presentationMode.wrappedValue.dismiss()
-
- // TODO: Update the file list if the filename was changed
- } label: {
- Text("Save")
- }.disabled(!isEdited())
- }
- }
-
- .onAppear {
- self.filename = fd.name
- self.comment = fd.comment
- }
- .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
- }
- .frame(minWidth: 400, minHeight: 400)
- }
-
- static var dateFormatter: DateFormatter = {
- var dateFormatter = DateFormatter()
- dateFormatter.dateStyle = .long
- dateFormatter.timeStyle = .short
-
- // Original format: Fri, Aug 20, 2021, 5:14:07 PM
- return dateFormatter
- }()
-
- static var byteCountSizeFormatter: NumberFormatter = {
- let numberFormatter = NumberFormatter()
- numberFormatter.numberStyle = .decimal
- return numberFormatter
- }()
-
- static let byteFormatter = ByteCountFormatter()
-
- private func formattedFileSize(_ fileSize: UInt) -> String {
- FileView.byteFormatter.allowedUnits = [.useAll]
- FileView.byteFormatter.countStyle = .file
- return FileView.byteFormatter.string(fromByteCount: Int64(fileSize))
- }
-
- // Format byte count Int into string like: 23.4M (24,601,664 bytes)
- private func formattedSize(byteCount: Int) -> String {
- let formattedByteCount =
- FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value: byteCount)) ?? "0"
- return
- "\(FileView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)"
- }
-
- private func isEdited() -> Bool {
- return self.filename != fd.name || self.comment != fd.comment
- }
-
- private func canRename() -> Bool {
- if self.fd.type == "fldr" {
- return model.access?.contains(.canRenameFolders) == true
- }
- return model.access?.contains(.canRenameFiles) == true
- }
-
- private func canSetComment() -> Bool {
- if self.fd.type == "fldr" {
- return model.access?.contains(.canSetFolderComment) == true
- }
- return model.access?.contains(.canSetFileComment) == true
- }
-}
-
-//#Preview {
-// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now ))
-//}
diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift
new file mode 100644
index 0000000..1bfacb2
--- /dev/null
+++ b/Hotline/macOS/Files/FileDetailsSheet.swift
@@ -0,0 +1,172 @@
+import Foundation
+import SwiftUI
+
+struct FileDetailsSheet: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.dismiss) private var dismiss
+
+ var details: FileDetails
+
+ @State private var saving: Bool = false
+ @State private var comment: String = ""
+ @State private var filename: String = ""
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(alignment: .center, spacing: 16){
+ if self.isFolder {
+ FolderIconView()
+ .frame(width: 32, height: 32)
+ }
+ else {
+ FileIconView(filename: self.details.name, fileType: nil)
+ .frame(width: 32, height: 32)
+ }
+
+ TextField("File Name", text: $filename)
+ .disabled(!self.canRename)
+ }
+
+ let rows: [(String, String)] = [
+ ("Type", self.details.type),
+ ("Creator", self.details.creator),
+ ("Size", self.formattedSize(byteCount: self.details.size)),
+ ("Created", Self.dateFormatter.string(from: self.details.created)),
+ ("Modified", Self.dateFormatter.string(from: self.details.modified))
+ ]
+
+ Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
+ ForEach(rows, id: \.0) { label, value in
+ GridRow {
+ Text(label)
+ .font(.body.bold())
+ .gridColumnAlignment(.trailing) // right-align label column
+ Text(value)
+ .font(.body)
+ .gridColumnAlignment(.leading) // left-align value column
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 32 + 16)
+
+ TextField(text: self.$comment, prompt: Text("Comments"), axis: .vertical) {
+ EmptyView()
+ }
+ .font(.body)
+ .lineLimit(10, reservesSpace: true)
+ .padding(.leading, 32 + 16)
+ .disabled(!self.canSetComment)
+ }
+ .padding(.vertical, 24)
+ .padding(.horizontal, 24)
+ .frame(width: 400)
+ .toolbar {
+ if self.saving {
+ ToolbarItem {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button{
+ var editedFilename: String?
+ if self.filename != self.details.name {
+ editedFilename = self.filename
+ }
+
+ var editedComment: String?
+ if self.comment != self.details.comment {
+ editedComment = self.comment
+ }
+
+ Task {
+ self.saving = true
+ defer { self.saving = false }
+
+ if editedComment != nil || editedFilename != nil {
+ if try await self.model.setFileInfo(fileName: self.details.name, path: self.details.path, fileNewName: editedFilename, comment: editedComment) {
+ try await self.model.getFileList(path: self.details.path)
+ }
+ }
+
+ // We dismiss even if there is an error for now
+ // This is not ideal as we may lose a user's written comment
+ // or new file name, but SwiftUI doesn't show the current
+ // alert above this sheet so we'll need a different way of
+ // handling errors to make this work. Until then...
+ self.dismiss()
+ }
+ } label: {
+ Text("Save")
+ }
+ }
+ }
+ .onAppear {
+ self.filename = self.details.name
+ self.comment = self.details.comment
+ }
+ }
+
+ private var isFolder: Bool {
+ self.details.type == "Folder" || self.details.type == "fldr"
+ }
+
+ private func isEdited() -> Bool {
+ return self.filename != self.details.name || self.comment != self.details.comment
+ }
+
+ private var canRename: Bool {
+ if self.isFolder {
+ return self.model.access?.contains(.canRenameFolders) == true
+ }
+ return self.model.access?.contains(.canRenameFiles) == true
+ }
+
+ private var canSetComment: Bool {
+ if self.isFolder {
+ return self.model.access?.contains(.canSetFolderComment) == true
+ }
+ return self.model.access?.contains(.canSetFileComment) == true
+ }
+
+ static var dateFormatter: DateFormatter = {
+ var dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .long
+ dateFormatter.timeStyle = .short
+
+ // Original format: Fri, Aug 20, 2021, 5:14:07 PM
+ return dateFormatter
+ }()
+
+ static var byteCountSizeFormatter: NumberFormatter = {
+ let numberFormatter = NumberFormatter()
+ numberFormatter.numberStyle = .decimal
+ return numberFormatter
+ }()
+
+ static let byteFormatter = ByteCountFormatter()
+
+ private func formattedFileSize(_ fileSize: UInt) -> String {
+ FileItemView.byteFormatter.allowedUnits = [.useAll]
+ FileItemView.byteFormatter.countStyle = .file
+ return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize))
+ }
+
+ // Format byte count Int into string like: 23.4M (24,601,664 bytes)
+ private func formattedSize(byteCount: Int) -> String {
+ let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0"
+ return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)"
+ }
+}
+
+//#Preview {
+// FileDetailsView(details: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now ))
+//}
diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift
new file mode 100644
index 0000000..31a8af7
--- /dev/null
+++ b/Hotline/macOS/Files/FileItemView.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+
+struct FileItemView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+
+ var file: FileInfo
+ let depth: Int
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 0) {
+ Spacer()
+ .frame(width: CGFloat(depth * (12 + 2)))
+
+ Spacer()
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 8)
+
+ HStack(alignment: .center) {
+ if file.isUnavailable {
+ Image(systemName: "questionmark.app.fill")
+ .frame(width: 16, height: 16)
+ .opacity(0.5)
+ }
+ else {
+ FileIconView(filename: file.name, fileType: file.type)
+ .frame(width: 16, height: 16)
+ }
+ }
+ .frame(width: 16)
+ .padding(.trailing, 6)
+
+ Text(file.name)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .opacity(file.isUnavailable ? 0.5 : 1.0)
+
+ Spacer()
+ if !file.isUnavailable {
+ Text(formattedFileSize(file.fileSize))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .padding(.trailing, 6)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ if file.expanded {
+ ForEach(file.children!, id: \.self) { childFile in
+ if childFile.isFolder {
+ FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ else {
+ FileItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ }
+ }
+ }
+
+ static let byteFormatter = ByteCountFormatter()
+
+ private func formattedFileSize(_ fileSize: UInt) -> String {
+ FileItemView.byteFormatter.allowedUnits = [.useAll]
+ FileItemView.byteFormatter.countStyle = .file
+ return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize))
+ }
+}
diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift
index 3aa37d3..5469e23 100644
--- a/Hotline/macOS/FilePreviewImageView.swift
+++ b/Hotline/macOS/Files/FilePreviewImageView.swift
@@ -11,8 +11,8 @@ struct FilePreviewImageView: View {
@Environment(\.dismiss) var dismiss
@Binding var info: PreviewFileInfo?
-
- @State var preview: FilePreview? = nil
+
+ @State var preview: FilePreviewState? = nil
@FocusState private var focusField: FilePreviewFocus?
var body: some View {
@@ -76,7 +76,10 @@ struct FilePreviewImageView: View {
if let info = info {
ToolbarItem(placement: .primaryAction) {
Button {
- let _ = preview?.data?.saveAsFileToDownloads(filename: info.name)
+ if let fileURL = preview?.fileURL,
+ let data = try? Data(contentsOf: fileURL) {
+ let _ = data.saveAsFileToDownloads(filename: info.name)
+ }
} label: {
Label("Download Image...", systemImage: "arrow.down")
}
@@ -94,7 +97,7 @@ struct FilePreviewImageView: View {
}
.task {
if let info = info {
- preview = FilePreview(info: info)
+ preview = FilePreviewState(info: info)
preview?.download()
}
}
diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift
new file mode 100644
index 0000000..a504a7a
--- /dev/null
+++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct FilePreviewQuickLookView: View {
+ enum FilePreviewFocus: Hashable {
+ case window
+ }
+
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.dismiss) private var dismiss
+
+ @Binding var info: PreviewFileInfo?
+ @State private var preview: FilePreviewState? = nil
+
+ @FocusState private var focusField: FilePreviewFocus?
+
+ var body: some View {
+ Group {
+ if self.preview?.state != .loaded {
+ VStack(alignment: .center, spacing: 0) {
+ Spacer()
+ ProgressView(value: max(0.0, min(1.0, self.preview?.progress ?? 0.0)))
+ .focusable(false)
+ .progressViewStyle(.circular)
+ .controlSize(.extraLarge)
+ .frame(maxWidth: 300, alignment: .center)
+ .padding(.bottom, 48)
+ Spacer()
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity)
+ .padding()
+ }
+ else {
+ if let fileURL = self.preview?.fileURL {
+ QuickLookPreviewView(fileURL: fileURL)
+ .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity)
+ }
+ else {
+ VStack(alignment: .center, spacing: 0) {
+ Spacer()
+
+ Image(systemName: "eye.trianglebadge.exclamationmark")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ .frame(height: 48)
+ .padding(.bottom)
+ Group {
+ Text("This file type is not previewable")
+ .bold()
+ Text("Try downloading and opening this file in another application.")
+ .foregroundStyle(Color.secondary)
+ }
+ .font(.system(size: 14.0))
+ .frame(maxWidth: 300)
+ .multilineTextAlignment(.center)
+
+ Spacer()
+ Spacer()
+ }
+ .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity)
+ .padding()
+ }
+ }
+ }
+ .focusable()
+ .focusEffectDisabled()
+ .background(Color(nsColor: .textBackgroundColor))
+ .focused(self.$focusField, equals: .window)
+ .navigationTitle(self.info?.name ?? "File Preview")
+ .applyNavigationDocumentIfPresent(self.preview?.fileURL)
+ .toolbar {
+ if let fileURL = self.preview?.fileURL {
+ if let info = info {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ FileManager.default.copyToDownloads(from: fileURL, using: info.name, bounceDock: true)
+ } label: {
+ Label("Download File...", systemImage: "arrow.down")
+ }
+ .help("Download File")
+ }
+ }
+ }
+ }
+ .task {
+ if let info = self.info {
+ self.preview = FilePreviewState(info: info)
+ self.preview?.download()
+ }
+ }
+ .onAppear {
+ guard self.info != nil else {
+ self.dismiss()
+ return
+ }
+
+ self.focusField = .window
+ }
+ .onDisappear {
+ self.preview?.cancel()
+ self.preview?.cleanup()
+ self.dismiss()
+ }
+ .onChange(of: self.preview?.state) {
+ if self.preview?.state == .failed {
+ self.dismiss()
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+}
diff --git a/Hotline/macOS/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift
index 6dc1529..2f9a85c 100644
--- a/Hotline/macOS/FilePreviewTextView.swift
+++ b/Hotline/macOS/Files/FilePreviewTextView.swift
@@ -11,7 +11,7 @@ struct FilePreviewTextView: View {
@Environment(\.dismiss) var dismiss
@Binding var info: PreviewFileInfo?
- @State var preview: FilePreview? = nil
+ @State var preview: FilePreviewState? = nil
@FocusState private var focusField: FilePreviewFocus?
var body: some View {
@@ -87,7 +87,10 @@ struct FilePreviewTextView: View {
if let info = info {
ToolbarItem(placement: .primaryAction) {
Button {
- let _ = preview?.data?.saveAsFileToDownloads(filename: info.name)
+ if let fileURL = preview?.fileURL,
+ let data = try? Data(contentsOf: fileURL) {
+ let _ = data.saveAsFileToDownloads(filename: info.name)
+ }
} label: {
Label("Save Text File...", systemImage: "square.and.arrow.down")
}
@@ -98,7 +101,7 @@ struct FilePreviewTextView: View {
}
.task {
if let info = info {
- preview = FilePreview(info: info)
+ preview = FilePreviewState(info: info)
preview?.download()
}
}
diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift
new file mode 100644
index 0000000..c9c4c24
--- /dev/null
+++ b/Hotline/macOS/Files/FilesView.swift
@@ -0,0 +1,529 @@
+import SwiftUI
+import UniformTypeIdentifiers
+import AppKit
+
+struct FilesView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+ @Environment(\.openWindow) private var openWindow
+
+ @State private var selection: FileInfo?
+ @State private var fileDetails: FileDetails?
+ @State private var uploadFileSelectorDisplayed: Bool = false
+ @State private var searchText: String = ""
+ @State private var isSearching: Bool = false
+ @State private var dragOver: Bool = false
+ @State private var confirmDeleteShown: Bool = false
+ @State private var newFolderShown: Bool = false
+
+ var body: some View {
+ NavigationStack {
+ List(self.displayedFiles, id: \.self, selection: self.$selection) { file in
+ if file.isFolder {
+ FolderItemView(file: file, depth: 0).tag(file.id)
+ }
+ else {
+ FileItemView(file: file, depth: 0).tag(file.id)
+ }
+ }
+ .environment(\.defaultMinListRowHeight, 28)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .onDrop(of: [.fileURL], isTargeted: self.$dragOver) { items in
+ guard self.model.access?.contains(.canUploadFiles) == true,
+ let item = items.first,
+ let identifier = item.registeredTypeIdentifiers.first else {
+ return false
+ }
+
+ item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
+ DispatchQueue.main.async {
+ if let urlData = urlData as? Data,
+ let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) {
+
+ // Access security-scoped resource for drag-and-drop
+ let didStartAccessing = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccessing {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ self.upload(file: fileURL, to: [])
+ }
+ }
+ }
+
+ return true
+ }
+ .task {
+ if !self.model.filesLoaded {
+ let _ = try? await self.model.getFileList()
+ }
+ }
+ .contextMenu(forSelectionType: FileInfo.self) { items in
+ let selectedFile = items.first
+
+ Button {
+ if let s = selectedFile {
+ downloadFile(s)
+ }
+ } label: {
+ Label("Download", systemImage: "arrow.down")
+ }
+ .disabled(selectedFile == nil)
+
+ Divider()
+
+ Button {
+ if let s = selectedFile {
+ getFileInfo(s)
+ }
+ } label: {
+ Label("Get Info", systemImage: "info.circle")
+ }
+ .disabled(selectedFile == nil)
+
+ Button {
+ if let s = selectedFile {
+ previewFile(s)
+ }
+ } label: {
+ Label("Preview", systemImage: "eye")
+ }
+ .disabled(selectedFile == nil || (selectedFile != nil && !selectedFile!.isPreviewable))
+
+ if model.access?.contains(.canDeleteFiles) == true {
+ Divider()
+
+ Button {
+ self.confirmDeleteShown = true
+ } label: {
+ Label("Delete...", systemImage: "trash")
+ }
+ .disabled(selectedFile == nil)
+ }
+ } primaryAction: { items in
+ guard let clickedFile = items.first else {
+ return
+ }
+
+ self.selection = clickedFile
+ if clickedFile.isFolder {
+ clickedFile.expanded.toggle()
+ }
+ else {
+ downloadFile(clickedFile)
+ }
+ }
+ .onKeyPress(.rightArrow) {
+ if let s = selection, s.isFolder {
+ s.expanded = true
+ return .handled
+ }
+ return .ignored
+ }
+ .onKeyPress(.leftArrow) {
+ if let s = selection, s.isFolder {
+ s.expanded = false
+ return .handled
+ }
+ return .ignored
+ }
+ .onKeyPress(.space) {
+ if let s = selection, s.isPreviewable {
+ previewFile(s)
+ return .handled
+ }
+ return .ignored
+ }
+ .overlay {
+ if !model.filesLoaded {
+ VStack {
+ ProgressView()
+ .controlSize(.large)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
+ .toolbar {
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection, selectedFile.isPreviewable {
+ self.previewFile(selectedFile)
+ }
+ } label: {
+ Label("Preview", systemImage: "eye")
+ }
+ .help("Preview")
+ .disabled(selection == nil || selection?.isPreviewable != true)
+ }
+
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection {
+ self.getFileInfo(selectedFile)
+ }
+ } label: {
+ Label("Get Info", systemImage: "info.circle")
+ }
+ .help("Get Info")
+ .disabled(selection == nil)
+ }
+
+ ToolbarItem {
+ Button {
+ self.uploadFileSelectorDisplayed = true
+ } label: {
+ Label("Upload", systemImage: "arrow.up")
+ }
+ .help("Upload")
+ .disabled(model.access?.contains(.canUploadFiles) != true)
+ }
+
+ ToolbarItem {
+ Button {
+ if let selectedFile = selection {
+ self.downloadFile(selectedFile)
+ }
+ } label: {
+ Label("Download", systemImage: "arrow.down")
+ }
+ .help("Download")
+ .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true)
+ }
+
+ if #available(macOS 26.0, *) {
+ ToolbarSpacer()
+ }
+
+ if self.model.access?.contains(.canCreateFolders) == true {
+ ToolbarItem {
+ Button {
+ self.newFolderShown = true
+ } label: {
+ Label("New Folder", systemImage: "folder.badge.plus")
+ }
+ .help("New Folder")
+ .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) {
+ NewFolderPopover { folderName in
+ self.newFolder(name: folderName, parent: self.selection)
+ }
+ }
+ }
+ }
+
+ if self.model.access?.contains(.canDeleteFiles) == true || self.model.access?.contains(.canDeleteFolders) == true {
+ ToolbarItem {
+ Button {
+ self.confirmDeleteShown = true
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ .disabled(self.selection == nil)
+ .help("Delete")
+ }
+ }
+ }
+ }
+ .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$confirmDeleteShown, actions: {
+ Button("Delete", role: .destructive) {
+ if let s = self.selection {
+ Task {
+ await self.deleteFile(s)
+ }
+ }
+ }
+ }, message: {
+ Text("You cannot undo this action.")
+ })
+ .sheet(item: self.$fileDetails) { item in
+ FileDetailsSheet(details: item)
+ }
+ .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in
+ switch results {
+ case .success(let fileURLS):
+ guard fileURLS.count > 0,
+ let fileURL = fileURLS.first
+ else {
+ return
+ }
+
+ var uploadPath: [String] = []
+
+ if let selection = selection {
+ if selection.isFolder {
+ uploadPath = selection.path
+ }
+ else {
+ uploadPath = Array<String>(selection.path)
+ uploadPath.removeLast()
+ }
+ }
+
+ print("UPLOAD PATH: \(uploadPath)")
+ self.upload(file: fileURL, to: uploadPath)
+// uploadFile(file: fileURL, to: uploadPath)
+
+ case .failure(let error):
+ print(error)
+ }
+ })
+ .onSubmit(of: .search) {
+ #if os(macOS)
+ let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
+ if shiftPressed {
+ model.clearFileListCache()
+ }
+ #endif
+
+ let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ model.cancelFileSearch()
+ return
+ }
+ searchText = trimmed
+ model.startFileSearch(query: trimmed)
+ }
+ .onChange(of: searchText) { _, newValue in
+ if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ if isShowingSearchResults {
+ model.cancelFileSearch()
+ }
+ }
+ }
+ .onChange(of: model.fileSearchQuery) { _, newValue in
+ if newValue != searchText {
+ searchText = newValue
+ }
+ }
+ .onAppear {
+ if searchText != model.fileSearchQuery {
+ searchText = model.fileSearchQuery
+ }
+ }
+ .safeAreaInset(edge: .top) {
+ if isShowingSearchResults, let message = searchStatusMessage {
+ HStack(alignment: .center, spacing: 6) {
+ if case .searching(_, _) = model.fileSearchStatus {
+ ProgressView()
+ .controlSize(.small)
+ .accentColor(.white)
+ .tint(.white)
+ }
+ else if case .completed = model.fileSearchStatus {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .symbolRenderingMode(.monochrome)
+ .foregroundStyle(.white)
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ }
+ else if case .failed = model.fileSearchStatus {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .symbolRenderingMode(.monochrome)
+ .foregroundStyle(.white)
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ }
+
+ Text(message)
+ .lineLimit(1)
+ .font(.body)
+ .foregroundStyle(.white)
+
+ Spacer()
+
+ if let pathMessage = searchStatusPath {
+ Text(pathMessage)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .font(.footnote)
+// .fontWeight(.semibold)
+ .foregroundStyle(.white)
+ .opacity(0.5)
+ .padding(.top, 2)
+ }
+ }
+ .padding(.trailing, 14)
+ .padding(.leading, 8)
+ .padding(.vertical, 8)
+ .background {
+ Group {
+ if case .completed = model.fileSearchStatus {
+ Color.fileComplete
+ }
+ else {
+ Color(nsColor: .controlAccentColor)
+ }
+ }
+ .clipShape(.capsule(style: .continuous))
+ }
+ .padding(.horizontal, 8)
+ .padding(.top, 8)
+ }
+ }
+ }
+
+ private var isShowingSearchResults: Bool {
+ switch model.fileSearchStatus {
+ case .idle:
+ return !model.fileSearchResults.isEmpty
+ case .cancelled(_):
+ return !model.fileSearchResults.isEmpty
+ default:
+ return true
+ }
+ }
+
+ private var displayedFiles: [FileInfo] {
+ isShowingSearchResults ? model.fileSearchResults : model.files
+ }
+
+ private var searchStatusMessage: String? {
+ switch model.fileSearchStatus {
+ case .searching(let processed, _):
+ let scanned = processed == 1 ? "folder" : "folders"
+ return "Searched \(processed) \(scanned)..."
+ case .completed(let processed):
+ let count = model.fileSearchResults.count
+ let folderWord = processed == 1 ? "folder" : "folders"
+ if count == 0 {
+ return "No files found in \(processed) \(folderWord)"
+ }
+ return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)"
+ case .cancelled(_):
+ if model.fileSearchResults.isEmpty {
+ return nil
+ }
+ return "Search cancelled"
+ case .failed(let message):
+ return "Search failed: \(message)"
+ case .idle:
+ return nil
+ }
+ }
+
+ private var searchStatusPath: String? {
+ guard let path = model.fileSearchCurrentPath else {
+ return nil
+ }
+ if path.isEmpty {
+ return "/"
+ }
+ return path.joined(separator: "/")
+ }
+
+ private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
+ switch previewInfo.previewType {
+ case .image:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ case .text:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ case .unknown:
+ self.openWindow(id: "preview-quicklook", value: previewInfo)
+ return
+ }
+ }
+
+ @MainActor private func newFolder(name: String, parent: FileInfo?) {
+ Task {
+ var parentFolder: FileInfo? = nil
+ if parent?.isFolder == true {
+ parentFolder = parent
+ }
+
+ let path: [String] = parentFolder?.path ?? []
+ if try await self.model.newFolder(name: name, parentPath: path) {
+ try await self.model.getFileList(path: path)
+ }
+ }
+ }
+
+ @MainActor private func getFileInfo(_ file: FileInfo) {
+ Task {
+ if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) {
+ self.fileDetails = fileInfo
+ }
+ }
+ }
+
+ @MainActor private func downloadFile(_ file: FileInfo) {
+ if file.isFolder {
+ self.model.downloadFolder(file.name, path: file.path)
+ }
+ else {
+ self.model.downloadFile(file.name, path: file.path)
+ }
+ }
+
+ @MainActor private func uploadFile(file fileURL: URL, to path: [String]) throws {
+ self.model.uploadFile(url: fileURL, path: path) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await self.model.getFileList(path: path)
+ }
+ }
+ }
+
+ @MainActor private func upload(file fileURL: URL, to path: [String]) {
+ var fileIsDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else {
+ return
+ }
+
+ if fileIsDirectory.boolValue {
+ self.model.uploadFolder(url: fileURL, path: path, complete: { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: path)
+ }
+ })
+ }
+ else {
+ self.model.uploadFile(url: fileURL, path: path) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: path)
+ }
+ }
+ }
+ }
+
+ @MainActor private func previewFile(_ file: FileInfo) {
+ guard file.isPreviewable else {
+ return
+ }
+
+ self.model.previewFile(file.name, path: file.path) { info in
+ if let info = info {
+ var extendedInfo = info
+ extendedInfo.creator = file.creator
+ extendedInfo.type = file.type
+ self.openPreviewWindow(extendedInfo)
+ }
+ }
+ }
+
+ private func deleteFile(_ file: FileInfo) async {
+ var parentPath: [String] = []
+ if file.path.count > 1 {
+ parentPath = Array(file.path[0..<file.path.count-1])
+ }
+
+ do {
+ try await self.model.deleteFile(file.name, path: file.path)
+ try await self.model.getFileList(path: parentPath)
+ }
+ catch {
+ print("Error deleting file: \(error)")
+ }
+ }
+}
+
+#Preview {
+ FilesView()
+ .environment(HotlineState())
+}
diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift
new file mode 100644
index 0000000..f82bb11
--- /dev/null
+++ b/Hotline/macOS/Files/FolderItemView.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+struct FolderItemView: View {
+ @Environment(HotlineState.self) private var model: HotlineState
+
+ @State var loading = false
+ @State var dragOver = false
+
+ var file: FileInfo
+ let depth: Int
+
+ @MainActor private func uploadFile(file fileURL: URL) {
+ var filePath: [String] = [String](self.file.path)
+ if !self.file.isFolder {
+ filePath.removeLast()
+ }
+
+ print("UPLOADING TO PATH: ", filePath)
+
+ self.model.uploadFile(url: fileURL, path: filePath) { info in
+ Task {
+ // Refresh file listing to display newly uploaded file.
+ try? await model.getFileList(path: filePath)
+ }
+ }
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 0) {
+ Spacer()
+ .frame(width: CGFloat(depth * (12 + 2)))
+
+ Button {
+ if file.isFolder {
+ file.expanded.toggle()
+ }
+ } label: {
+ Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right"))
+ .bold()
+ .font(.system(size: 10))
+ .foregroundStyle(dragOver ? Color.white : Color.primary)
+ .opacity(0.5)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 8)
+
+ HStack(alignment: .center) {
+ if file.isUnavailable {
+ Image(systemName: "questionmark.app.fill")
+ .frame(width: 16, height: 16)
+ .opacity(0.5)
+ }
+ else if file.isAdminDropboxFolder {
+ Image("Admin Drop Box")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else if file.isDropboxFolder {
+ Image("Drop Box")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else {
+ Image("Folder")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+ .frame(width: 16)
+ .padding(.trailing, 6)
+
+ Text(file.name)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .foregroundStyle(dragOver ? Color.white : Color.primary)
+ .opacity(file.isUnavailable ? 0.5 : 1.0)
+
+ if loading {
+ ProgressView().controlSize(.mini).padding([.leading, .trailing], 5)
+ }
+ Spacer()
+ if !file.isUnavailable {
+ Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)")
+ .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary)
+ .lineLimit(1)
+ .padding(.trailing, 6)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(
+ RoundedRectangle(cornerRadius: 4.0)
+ .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear)
+ .padding(.horizontal, -6)
+ .padding(.vertical, -4)
+ )
+ .onChange(of: file.expanded) {
+ loading = false
+ if file.expanded && file.fileSize > 0 {
+ Task {
+ loading = true
+ let _ = try? await model.getFileList(path: file.path)
+ loading = false
+ }
+ }
+ }
+ .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in
+ guard let item = items.first,
+ let identifier = item.registeredTypeIdentifiers.first else {
+ return false
+ }
+
+ item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
+ DispatchQueue.main.async {
+ if let urlData = urlData as? Data,
+ let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) {
+ self.uploadFile(file: fileURL)
+ }
+ }
+ }
+
+ return true
+ }
+
+ if file.expanded {
+ ForEach(file.children!, id: \.self) { childFile in
+ if childFile.isFolder {
+ FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ else {
+ FileItemView(file: childFile, depth: self.depth + 1).tag(file.id)
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Files/NewFolderPopover.swift b/Hotline/macOS/Files/NewFolderPopover.swift
new file mode 100644
index 0000000..4e282f5
--- /dev/null
+++ b/Hotline/macOS/Files/NewFolderPopover.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+
+struct NewFolderPopover: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let action: ((String) -> Void)?
+
+ @State private var folderName: String = "Untitled Folder"
+
+ var body: some View {
+ VStack(spacing: 16) {
+ TextField("Folder Name", text: self.$folderName)
+ .onSubmit(of: .text) {
+ self.createFolder()
+ }
+
+ HStack(spacing: 8) {
+ Spacer()
+
+ Button("Cancel", role: .cancel) {
+ self.dismiss()
+ }
+ .buttonStyle(.bordered)
+ .buttonBorderShape(.capsule)
+
+ if #available(macOS 26.0, *) {
+ Button("New Folder", role: .confirm) {
+ self.createFolder()
+ }
+ .buttonStyle(.borderedProminent)
+ .buttonBorderShape(.capsule)
+ }
+ else {
+ Button("OK") {
+ self.dismiss()
+ self.action?(self.folderName)
+ }
+ .buttonStyle(.borderedProminent)
+ .buttonBorderShape(.capsule)
+ }
+ }
+ }
+ .frame(width: 250)
+ .padding()
+ }
+
+ private func createFolder() {
+ self.dismiss()
+ self.action?(self.folderName)
+ }
+}
diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift
deleted file mode 100644
index fd5826f..0000000
--- a/Hotline/macOS/FilesView.swift
+++ /dev/null
@@ -1,481 +0,0 @@
-import SwiftUI
-import UniformTypeIdentifiers
-
-struct FolderView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- @State var loading = false
- @State var dragOver = false
-
- var file: FileInfo
- let depth: Int
-
- @MainActor private func uploadFile(file fileURL: URL) {
- var filePath: [String] = [String](self.file.path)
- if !self.file.isFolder {
- filePath.removeLast()
- }
-
- print("UPLOADING TO PATH: ", filePath)
-
- model.uploadFile(url: fileURL, path: filePath) { info in
- Task {
- // Refresh file listing to display newly uploaded file.
- let _ = await model.getFileList(path: filePath)
- }
- }
- }
-
- var body: some View {
- HStack(alignment: .center, spacing: 0) {
- Spacer()
- .frame(width: CGFloat(depth * (12 + 2)))
-
- Button {
- if file.isFolder {
- file.expanded.toggle()
- }
- } label: {
- Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right"))
- .bold()
- .font(.system(size: 10))
- .foregroundStyle(dragOver ? Color.white : Color.primary)
- .opacity(0.5)
- }
- .buttonStyle(.plain)
- .frame(width: 10)
- .padding(.leading, 4)
- .padding(.trailing, 8)
-
- HStack(alignment: .center) {
- if file.isUnavailable {
- Image(systemName: "questionmark.app.fill")
- .frame(width: 16, height: 16)
- .opacity(0.5)
- } else if file.isAdminDropboxFolder {
- Image("Admin Drop Box")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- } else if file.isDropboxFolder {
- Image("Drop Box")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- } else {
- Image("Folder")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- }
- }
- .frame(width: 16)
- .padding(.trailing, 6)
-
- Text(file.name)
- .lineLimit(1)
- .truncationMode(.tail)
- .foregroundStyle(dragOver ? Color.white : Color.primary)
- .opacity(file.isUnavailable ? 0.5 : 1.0)
-
- if loading {
- ProgressView().controlSize(.small).padding([.leading, .trailing], 5)
- }
- Spacer()
- if !file.isUnavailable {
- Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)")
- .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary)
- .lineLimit(1)
- .padding(.trailing, 6)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 4.0)
- .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear)
- .padding(.horizontal, -6)
- .padding(.vertical, -4)
- )
- .onChange(of: file.expanded) {
- loading = false
- if file.expanded && file.fileSize > 0 {
- Task {
- loading = true
- let _ = await model.getFileList(path: file.path)
- loading = false
- }
- }
- }
- .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in
- guard let item = items.first,
- let identifier = item.registeredTypeIdentifiers.first
- else {
- return false
- }
-
- item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
- DispatchQueue.main.async {
- if let urlData = urlData as? Data,
- let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true)
- {
- uploadFile(file: fileURL)
- }
- }
- }
-
- return true
- }
-
- if file.expanded {
- ForEach(file.children!, id: \.self) { childFile in
- if childFile.isFolder {
- FolderView(file: childFile, depth: self.depth + 1).tag(file.id)
- } else {
- FileView(file: childFile, depth: self.depth + 1).tag(file.id)
- }
- }
- }
- }
-}
-
-struct FileView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- var file: FileInfo
- let depth: Int
-
- var body: some View {
- HStack(alignment: .center, spacing: 0) {
- Spacer()
- .frame(width: CGFloat(depth * (12 + 2)))
-
- Spacer()
- .frame(width: 10)
- .padding(.leading, 4)
- .padding(.trailing, 8)
-
- HStack(alignment: .center) {
- if file.isUnavailable {
- Image(systemName: "questionmark.app.fill")
- .frame(width: 16, height: 16)
- .opacity(0.5)
- } else {
- FileIconView(filename: file.name, fileType: file.type)
- .frame(width: 16, height: 16)
- }
- }
- .frame(width: 16)
- .padding(.trailing, 6)
-
- Text(file.name)
- .lineLimit(1)
- .truncationMode(.tail)
- .opacity(file.isUnavailable ? 0.5 : 1.0)
-
- Spacer()
- if !file.isUnavailable {
- Text(formattedFileSize(file.fileSize))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .padding(.trailing, 6)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-
- if file.expanded {
- ForEach(file.children!, id: \.self) { childFile in
- if childFile.isFolder {
- FolderView(file: childFile, depth: self.depth + 1).tag(file.id)
- } else {
- FileView(file: childFile, depth: self.depth + 1).tag(file.id)
- }
- }
- }
- }
-
- static let byteFormatter = ByteCountFormatter()
-
- private func formattedFileSize(_ fileSize: UInt) -> String {
- FileView.byteFormatter.allowedUnits = [.useAll]
- FileView.byteFormatter.countStyle = .file
- return FileView.byteFormatter.string(fromByteCount: Int64(fileSize))
- }
-}
-
-struct FilesView: View {
- @Environment(Hotline.self) private var model: Hotline
- @Environment(\.openWindow) private var openWindow
-
- @State private var selection: FileInfo?
- @State private var fileDetails: FileDetails?
- @State private var uploadFileSelectorDisplayed: Bool = false
-
- private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
- switch previewInfo.previewType {
- case .image:
- openWindow(id: "preview-image", value: previewInfo)
- case .text:
- openWindow(id: "preview-text", value: previewInfo)
- default:
- return
- }
- }
-
- @MainActor private func getFileInfo(_ file: FileInfo) {
- Task {
- if let fileInfo = await model.getFileDetails(file.name, path: file.path) {
- Task { @MainActor in
- self.fileDetails = fileInfo
- }
- }
- }
- }
-
- @MainActor private func downloadFile(_ file: FileInfo) {
- guard !file.isFolder else {
- return
- }
-
- model.downloadFile(file.name, path: file.path)
- }
-
- @MainActor private func uploadFile(file fileURL: URL, to path: [String]) {
- model.uploadFile(url: fileURL, path: path) { info in
- Task {
- // Refresh file listing to display newly uploaded file.
- let _ = await model.getFileList(path: path)
- }
- }
- }
-
- @MainActor private func previewFile(_ file: FileInfo) {
- guard file.isPreviewable else {
- return
- }
-
- model.previewFile(file.name, path: file.path) { info in
- if let info = info {
- openPreviewWindow(info)
- }
- }
- }
-
- private func deleteFile(_ file: FileInfo) async {
- var parentPath: [String] = []
- if file.path.count > 1 {
- parentPath = Array(file.path[0..<file.path.count - 1])
- }
-
- if await model.deleteFile(file.name, path: file.path) {
- let _ = await model.getFileList(path: parentPath)
- }
- }
-
- // Friendship Quest Remix
- private func isInHomeFolder(_ file: FileInfo?) -> Bool {
- if let file {
- return file.path.first == "~" && file.path.count > 1
- }
- return false
- }
-
- var body: some View {
- NavigationStack {
- List(model.files, id: \.self, selection: $selection) { file in
- if file.isFolder {
- FolderView(file: file, depth: 0).tag(file.id)
- } else {
- FileView(file: file, depth: 0).tag(file.id)
- }
- }
- .environment(\.defaultMinListRowHeight, 28)
- .listStyle(.inset)
- .alternatingRowBackgrounds(.enabled)
- .task {
- if !model.filesLoaded {
- let _ = await model.getFileList()
- }
- }
- .contextMenu(forSelectionType: FileInfo.self) { items in
- let selectedFile = items.first
-
- Button {
- if let s = selectedFile, !s.isFolder {
- downloadFile(s)
- }
- } label: {
- Label("Download", systemImage: "arrow.down")
- }
- .disabled(selectedFile == nil || (selectedFile != nil && selectedFile!.isFolder))
-
- Divider()
-
- Button {
- if let s = selectedFile {
- getFileInfo(s)
- }
- } label: {
- Label("Get Info", systemImage: "info.circle")
- }
- .disabled(selectedFile == nil)
-
- Button {
- if let s = selectedFile {
- previewFile(s)
- }
- } label: {
- Label("Preview", systemImage: "eye")
- }
- .disabled(selectedFile == nil || (selectedFile != nil && !selectedFile!.isPreviewable))
-
- if model.access?.contains(.canDeleteFiles) == true
- // Friendship Quest Remix
- || isInHomeFolder(selectedFile)
- {
- Divider()
-
- Button {
- if let s = selectedFile {
- Task {
- await deleteFile(s)
- }
- }
- } label: {
- Label("Delete", systemImage: "trash")
- }
- .disabled(selectedFile == nil)
- }
- } primaryAction: { items in
- guard let clickedFile = items.first else {
- return
- }
-
- self.selection = clickedFile
- if clickedFile.isFolder {
- clickedFile.expanded.toggle()
- } else {
- downloadFile(clickedFile)
- }
- }
- .onKeyPress(.rightArrow) {
- if let s = selection, s.isFolder {
- s.expanded = true
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.leftArrow) {
- if let s = selection, s.isFolder {
- s.expanded = false
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.space) {
- if let s = selection, s.isPreviewable {
- previewFile(s)
- return .handled
- }
- return .ignored
- }
- .overlay {
- if !model.filesLoaded {
- VStack {
- ProgressView()
- .controlSize(.large)
- }
- .frame(maxWidth: .infinity)
- }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- if let selectedFile = selection, selectedFile.isPreviewable {
- previewFile(selectedFile)
- }
- } label: {
- Label("Preview", systemImage: "eye")
- }
- .help("Preview")
- .disabled(selection == nil || selection?.isPreviewable == false)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- if let selectedFile = selection {
- getFileInfo(selectedFile)
- }
- } label: {
- Label("Get Info", systemImage: "info.circle")
- }
- .help("Get Info")
- .disabled(selection == nil)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- uploadFileSelectorDisplayed = true
- } label: {
- Label("Upload", systemImage: "arrow.up")
- }
- .help("Upload")
- .disabled(model.access?.contains(.canUploadFiles) != true)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- if let selectedFile = selection, !selectedFile.isFolder {
- downloadFile(selectedFile)
- }
- } label: {
- Label("Download", systemImage: "arrow.down")
- }
- .help("Download")
- .disabled(
- selection == nil || selection?.isFolder == true
- || model.access?.contains(.canDownloadFiles) != true)
- }
- }
- }
- .sheet(item: $fileDetails) { item in
- FileDetailsView(fd: item)
- }
- .fileImporter(
- isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data],
- allowsMultipleSelection: false,
- onCompletion: { results in
- switch results {
- case .success(let fileURLS):
- guard fileURLS.count > 0 else {
- return
- }
-
- let fileURL = fileURLS.first!
-
- print(fileURL)
-
- var uploadPath: [String] = []
-
- if let selection = selection {
- if selection.isFolder {
- uploadPath = selection.path
- } else {
- uploadPath = [String](selection.path)
- uploadPath.removeLast()
- }
- }
-
- print("UPLOAD PATH: \(uploadPath)")
- uploadFile(file: fileURL, to: uploadPath)
-
- case .failure(let error):
- print(error)
- }
- })
- }
-}
-
-#Preview {
- FilesView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
-}
diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift
index 2617bc8..aec00df 100644
--- a/Hotline/macOS/HotlinePanelView.swift
+++ b/Hotline/macOS/HotlinePanelView.swift
@@ -1,23 +1,44 @@
import SwiftUI
+import Kingfisher
struct HotlinePanelView: View {
@Environment(\.openWindow) var openWindow
@Environment(\.colorScheme) var colorScheme
+ @Environment(\.appState) private var appState
+
+ private var activeServerState: ServerState? {
+ self.appState.activeServerState
+ }
+
+ private var activeHotline: HotlineState? {
+ self.appState.activeHotline
+ }
+
+ private var bannerImage: Image {
+ self.activeHotline?.bannerImage ?? Image("Default Banner")
+ }
+
+ private var backgroundColor: Color {
+ Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor)
+ }
+
+ private var bannerFileURL: URL? {
+ self.activeHotline?.bannerFileURL
+ }
+
+ private var bannerIsAnimated: Bool {
+ self.activeHotline?.bannerImageFormat == .gif
+ }
var body: some View {
VStack(spacing: 0) {
- Image(
- nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!
- )
- .interpolation(.high)
- .resizable()
- .scaledToFill()
- .frame(width: 468, height: 60)
- .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
- .clipped()
- .background(.black)
- // .clipShape(RoundedRectangle(cornerRadius: 6.0))
- // .padding([.top, .leading, .trailing], 4)
+
+ self.bannerView
+ .id("banner image view")
+ .animation(.default, value: self.bannerFileURL)
+ .background {
+ Color.black
+ }
HStack(spacing: 12) {
Button {
@@ -36,71 +57,86 @@ struct HotlinePanelView: View {
.help("Hotline Servers")
Button {
- ApplicationState.shared.activeServerState?.selection = .chat
- } label: {
+ self.activeServerState?.selection = .chat
+ }
+ label: {
Image("Section Chat")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
+ .disabled(self.activeServerState == nil)
.help("Public Chat")
Button {
- ApplicationState.shared.activeServerState?.selection = .board
- } label: {
+ self.activeServerState?.selection = .board
+ }
+ label: {
Image("Section Board")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
+ .disabled(self.activeServerState == nil)
.help("Message Board")
Button {
- ApplicationState.shared.activeServerState?.selection = .news
- } label: {
+ self.activeServerState?.selection = .news
+ }
+ label: {
Image("Section News")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(
- ApplicationState.shared.activeServerState == nil
- || (ApplicationState.shared.activeHotline?.serverVersion ?? 0) < 151
- )
+ .disabled(self.activeServerState == nil || (self.activeHotline?.serverVersion ?? 0) < 151)
.help("News")
Button {
- ApplicationState.shared.activeServerState?.selection = .files
- } label: {
+ self.activeServerState?.selection = .files
+ }
+ label: {
Image("Section Files")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
+ .disabled(self.activeServerState == nil)
.help("Files")
Spacer()
- if ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == true {
+ if self.activeHotline?.access?.contains(.canOpenUsers) == true {
Button {
- ApplicationState.shared.activeServerState?.selection = .accounts
- } label: {
+// self.activeServerState?.selection = .accounts
+ self.activeServerState?.accountsShown = true
+ }
+ label: {
Image("Section Users")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil)
- .help("Accounts")
+ .disabled(self.activeServerState == nil)
+ .help("Manage Accounts")
+ }
+
+ Button {
+ self.openWindow(id: "transfers")
}
+ label: {
+ Image("Section Transfers")
+ .resizable()
+ .scaledToFit()
+ }
+ .buttonStyle(.plain)
+ .frame(width: 20, height: 20)
+ .help("File Transfers")
SettingsLink(label: {
Image("Section Settings")
@@ -114,22 +150,24 @@ struct HotlinePanelView: View {
.padding(.top, 12)
.padding(.bottom, 12)
.padding([.leading, .trailing], 12)
- // .background(Color.red.opacity(0.5).blendMode(.multiply))
-
- // GroupBox {
- // HStack(spacing: 0) {
- // Text("Not Connected")
- // .font(.system(size: 10.0))
- // .lineLimit(1)
- // .truncationMode(.tail)
- // .opacity(0.5)
- // .padding(.vertical, 0.0)
- // .padding(.horizontal, 4.0)
- //
- // Spacer()
- // }
- // }
- // .padding([.leading, .bottom, .trailing], 4.0)
+ .background(self.backgroundColor)
+ .foregroundStyle(.primary)
+ .animation(.default, value: self.backgroundColor)
+
+// GroupBox {
+// HStack(spacing: 0) {
+// Text("Not Connected")
+// .font(.system(size: 10.0))
+// .lineLimit(1)
+// .truncationMode(.tail)
+// .opacity(0.5)
+// .padding(.vertical, 0.0)
+// .padding(.horizontal, 4.0)
+//
+// Spacer()
+// }
+// }
+// .padding([.leading, .bottom, .trailing], 4.0)
}
// .frame(width: 468)
// .background(colorScheme == .dark ? .black : .white)
@@ -138,9 +176,48 @@ struct HotlinePanelView: View {
// .cornerRadius(10.0)
// )
}
+
+ private var bannerView: some View {
+ ZStack {
+ if self.bannerIsAnimated {
+ KFAnimatedImage
+ .url(self.bannerFileURL)
+ .placeholder {
+ Image("Default Banner")
+ }
+ .cacheMemoryOnly()
+ .cacheOriginalImage()
+ .scaledToFill()
+ .frame(width: 468, height: 60)
+ .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
+ .clipped()
+ .transition(.opacity)
+ .id("animated banner \(self.bannerFileURL?.absoluteString ?? "")")
+ }
+ else {
+ KFImage
+ .url(self.bannerFileURL)
+ .resizable()
+ .interpolation(.high)
+ .placeholder {
+ Image("Default Banner")
+ }
+ .cacheMemoryOnly()
+ .cacheOriginalImage()
+ .scaledToFill()
+ .frame(width: 468, height: 60)
+ .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
+ .clipped()
+ .transition(.opacity)
+ .id("static banner \(self.bannerFileURL?.absoluteString ?? "")")
+ }
+ }
+ .animation(.default, value: self.bannerIsAnimated)
+ .animation(.default, value: self.bannerFileURL)
+ }
}
#Preview {
HotlinePanelView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift
deleted file mode 100644
index 115e4e3..0000000
--- a/Hotline/macOS/MessageBoardView.swift
+++ /dev/null
@@ -1,101 +0,0 @@
-import SwiftUI
-
-struct MessageBoardView: View {
- @Environment(Hotline.self) private var model: Hotline
-
- @State private var composerDisplayed: Bool = false
- @State private var composerText: String = ""
-
- var body: some View {
- NavigationStack {
- if model.access?.contains(.canReadMessageBoard) != false {
- ScrollView {
- LazyVStack(alignment: .leading) {
- ForEach(model.messageBoard, id: \.self) { msg in
- Text(LocalizedStringKey(msg))
- .tint(Color("Link Color"))
- .lineLimit(100)
- .lineSpacing(4)
- .padding()
- .textSelection(.enabled)
- Divider()
- }
- }
- Spacer()
- }
- .task {
- if !model.messageBoardLoaded {
- let _ = await model.getMessageBoard()
- }
- }
- .overlay {
- if !model.messageBoardLoaded {
- VStack {
- ProgressView()
- .controlSize(.large)
- }
- .frame(maxWidth: .infinity)
- }
- }
- .background(Color(nsColor: .textBackgroundColor))
- } else {
- ZStack(alignment: .center) {
- Text("No Message Board")
- .font(.title)
- .multilineTextAlignment(.center)
- .foregroundStyle(.secondary)
- .padding()
- }
- .frame(maxWidth: .infinity)
- }
- }
- .sheet(isPresented: $composerDisplayed) {
- MessageBoardEditorView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .frame(idealWidth: 450, idealHeight: 350)
- // RichTextEditor(text: $composerText)
- // .richEditorFont(NSFont.systemFont(ofSize: 16.0))
- // .richEditorAutomaticDashSubstitution(false)
- // .richEditorAutomaticQuoteSubstitution(false)
- // .richEditorAutomaticSpellingCorrection(false)
- // .background(Color(nsColor: .textBackgroundColor))
- // .frame(maxWidth: .infinity, maxHeight: .infinity)
- // .frame(idealWidth: 450, idealHeight: 350)
- // .toolbar {
- // ToolbarItem(placement: .cancellationAction) {
- // Button("Cancel") {
- // composerDisplayed.toggle()
- // }
- // }
- //
- // ToolbarItem(placement: .primaryAction) {
- // Button("Post") {
- // composerDisplayed.toggle()
- // let text = composerText
- // composerText = ""
- // model.postToMessageBoard(text: text)
- // Task {
- // await model.getMessageBoard()
- // }
- // }
- // }
- // }
- }
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button {
- composerDisplayed.toggle()
- } label: {
- Image(systemName: "square.and.pencil")
- }
- .disabled(model.access?.contains(.canPostMessageBoard) == false)
- .help("Post to Message Board")
- }
- }
- }
-}
-
-#Preview {
- MessageBoardView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
-}
diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift
index f62f922..5c27a6d 100644
--- a/Hotline/macOS/MessageView.swift
+++ b/Hotline/macOS/MessageView.swift
@@ -1,121 +1,183 @@
import SwiftUI
struct MessageView: View {
- @Environment(Hotline.self) private var model: Hotline
+ @Environment(HotlineState.self) private var model: HotlineState
@Environment(\.colorScheme) private var colorScheme
@State private var input: String = ""
@State private var scrollPos: Int?
@State private var contentHeight: CGFloat = 0
+ @State private var userInfo: HotlineUserClientInfo?
+ @State private var disconnectConfirmShown: Bool = false
+ @State private var username: String?
+
@Namespace private var bottomID
@FocusState private var focusedField: FocusedField?
var userID: UInt16
var body: some View {
- ScrollViewReader { reader in
- VStack(alignment: .leading, spacing: 0) {
-
- // MARK: Scroll View
- GeometryReader { gm in
- ScrollView(.vertical) {
- LazyVStack(alignment: .leading) {
- ForEach(model.instantMessages[userID] ?? [InstantMessage]()) { msg in
- HStack(alignment: .firstTextBaseline) {
- if msg.direction == .outgoing {
- Spacer()
- }
-
- Text(LocalizedStringKey(msg.text))
- .lineSpacing(4)
- .multilineTextAlignment(.leading)
- .textSelection(.enabled)
- .tint(
- msg.direction == .outgoing
- ? Color("Outgoing Message Link") : Color("Link Color")
- )
- .foregroundStyle(
- msg.direction == .outgoing
- ? Color("Outgoing Message Text") : Color("Incoming Message Text")
- )
- .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14))
- .background(
- msg.direction == .outgoing
- ? Color("Outgoing Message Background")
- : Color("Incoming Message Background")
- )
- .clipShape(RoundedRectangle(cornerRadius: 16))
-
- if msg.direction == .incoming {
- Spacer()
- }
- }
- .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
- }
- }
- .padding()
-
- VStack(spacing: 0) {}.id(bottomID)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .defaultScrollAnchor(.bottom)
- .onChange(of: model.instantMessages[userID]?.count) {
- reader.scrollTo(bottomID, anchor: .bottom)
- model.markInstantMessagesAsRead(userID: userID)
+ VStack(alignment: .leading, spacing: 0) {
+
+ // MARK: Scroll View
+ self.messageList
+
+ // MARK: Input Divider
+ Divider()
+
+ // MARK: Input Bar
+ self.inputBar
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .onAppear {
+ self.model.markInstantMessagesAsRead(userID: self.userID)
+
+ let user = self.model.users.first(where: { $0.id == self.userID })
+ self.username = user?.name
+ }
+
+ .onAppear {
+ self.model.markInstantMessagesAsRead(userID: userID)
+ }
+ .toolbar {
+ if self.model.access?.contains(.canGetClientInfo) == true {
+ ToolbarItem {
+ Button {
+ self.getUserInfo()
+ } label: {
+ Image(systemName: "info.circle")
}
- .onAppear {
- reader.scrollTo(bottomID, anchor: .bottom)
- model.markInstantMessagesAsRead(userID: userID)
+ .help("View \(self.username ?? "user")'s information")
+ }
+ }
+
+ if self.model.access?.contains(.canDisconnectUsers) == true {
+ ToolbarItem {
+ Button {
+ self.disconnectConfirmShown = true
+ } label: {
+ Image(systemName: "nosign")
}
- .onChange(of: gm.size) {
- reader.scrollTo(bottomID, anchor: .bottom)
+ .help("Disconnect \(self.username ?? "this user")")
+ }
+ }
+ }
+ .sheet(item: self.$userInfo) { info in
+ UserClientInfoSheet(info: info)
+ }
+ .alert("Are you sure you want to disconnect \(self.username ?? "this user")?", isPresented: self.$disconnectConfirmShown) {
+ Button("Disconnect", role: .destructive) {
+ self.disconnectUser()
+ }
+ } message: {
+ Text("They will be disconnected from the server, but may reconnect.")
+ }
+ }
+
+ private func getUserInfo() {
+ Task {
+ if let info = try await self.model.getClientInfoText(id: self.userID) {
+ self.userInfo = info
+ }
+ }
+ }
+
+ private func disconnectUser() {
+ Task {
+ try await self.model.disconnectUser(id: self.userID, options: nil)
+ }
+ }
+
+ private var inputBar: some View {
+ HStack(alignment: .lastTextBaseline, spacing: 0) {
+ TextField("Message \(self.username ?? "")", text: $input, axis: .vertical)
+ .focused($focusedField, equals: .chatInput)
+ .textFieldStyle(.plain)
+ .lineLimit(1...5)
+ .multilineTextAlignment(.leading)
+ .onSubmit {
+ if !self.input.isEmpty {
+ let message = self.input
+ let uid = self.userID
+ Task {
+ try? await self.model.sendInstantMessage(message, userID: uid)
+ }
}
+ self.input = ""
}
-
- // MARK: Input Divider
- Divider()
-
- // MARK: Input Bar
- HStack(alignment: .lastTextBaseline, spacing: 0) {
- let user = model.users.first(where: { $0.id == userID })
- TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical)
- .focused($focusedField, equals: .chatInput)
- .textFieldStyle(.plain)
- .lineLimit(1...5)
- .multilineTextAlignment(.leading)
- .onSubmit {
- if !self.input.isEmpty {
- model.sendInstantMessage(self.input, userID: self.userID)
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+ .frame(maxWidth: .infinity, minHeight: 28)
+ .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
+ .overlay(alignment: .leadingFirstTextBaseline) {
+ Image(systemName: "chevron.right").opacity(0.4).offset(x: 16)
+ }
+ .onContinuousHover { phase in
+ switch phase {
+ case .active(_):
+ NSCursor.iBeam.set()
+ case .ended:
+ NSCursor.arrow.set()
+ break
+ }
+ }
+ .onTapGesture {
+ focusedField = .chatInput
+ }
+ }
+
+ private var messageList: some View {
+ ScrollViewReader { reader in
+ GeometryReader { gm in
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading) {
+ ForEach(self.model.instantMessages[self.userID] ?? [InstantMessage]()) { msg in
+ HStack(alignment: .firstTextBaseline) {
+ if msg.direction == .outgoing {
+ Spacer()
+ }
+
+ Text(LocalizedStringKey(msg.text))
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(msg.direction == .outgoing ? Color("Outgoing Message Link") : Color("Link Color"))
+ .foregroundStyle(msg.direction == .outgoing ? Color("Outgoing Message Text") : Color("Incoming Message Text"))
+ .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14))
+ .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background"))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+
+ if msg.direction == .incoming {
+ Spacer()
+ }
}
- self.input = ""
+ .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
}
- .frame(maxWidth: .infinity)
- .padding()
+ }
+ .padding()
+
+ VStack(spacing: 0) {}.id(bottomID)
}
- .frame(maxWidth: .infinity, minHeight: 28)
- .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
- .overlay(alignment: .leadingFirstTextBaseline) {
- Image(systemName: "chevron.right").opacity(0.4).offset(x: 16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .defaultScrollAnchor(.bottom)
+ .onChange(of: self.model.instantMessages[self.userID]?.count) {
+ reader.scrollTo(self.bottomID, anchor: .bottom)
+ self.model.markInstantMessagesAsRead(userID: self.userID)
}
- .onContinuousHover { phase in
- switch phase {
- case .active(_):
- NSCursor.iBeam.set()
- case .ended:
- NSCursor.arrow.set()
- break
- }
+ .onAppear {
+ reader.scrollTo(self.bottomID, anchor: .bottom)
+ self.model.markInstantMessagesAsRead(userID: self.userID)
}
- .onTapGesture {
- focusedField = .chatInput
+ .onChange(of: gm.size) {
+ reader.scrollTo(self.bottomID, anchor: .bottom)
}
}
- .background(Color(nsColor: .textBackgroundColor))
}
}
}
#Preview {
ChatView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift
index 9a7242e..62902e1 100644
--- a/Hotline/macOS/NewsEditorView.swift
+++ b/Hotline/macOS/News/NewsEditorView.swift
@@ -9,8 +9,8 @@ struct NewsEditorView: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
- @Environment(Hotline.self) private var model: Hotline
-
+ @Environment(HotlineState.self) private var model: HotlineState
+
let editorTitle: String
let isReply: Bool
let path: [String]
@@ -25,15 +25,15 @@ struct NewsEditorView: View {
func sendArticle() async -> Bool {
sending = true
- let success = await model.postNewsArticle(
- title: title, body: text, at: path, parentID: parentID)
- if success {
- await model.getNewsList(at: path)
+ do {
+ try await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
+ try? await model.getNewsList(at: path)
+ sending = false
+ return true
+ } catch {
+ sending = false
+ return false
}
-
- sending = false
-
- return success
}
var body: some View {
diff --git a/Hotline/macOS/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift
index ff7495b..d477348 100644
--- a/Hotline/macOS/NewsItemView.swift
+++ b/Hotline/macOS/News/NewsItemView.swift
@@ -1,8 +1,8 @@
import SwiftUI
struct NewsItemView: View {
- @Environment(Hotline.self) private var model: Hotline
-
+ @Environment(HotlineState.self) private var model: HotlineState
+
var news: NewsInfo
let depth: Int
@@ -134,7 +134,7 @@ struct NewsItemView: View {
}
Task {
- await model.getNewsList(at: news.path)
+ try? await model.getNewsList(at: news.path)
}
}
@@ -147,11 +147,6 @@ struct NewsItemView: View {
}
#Preview {
- NewsItemView(
- news: NewsInfo(
- hotlineNewsArticle: HotlineNewsArticle(
- id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now,
- flavors: [("", 1)], path: ["Guest"])), depth: 0
- )
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0)
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/News/NewsView.swift
index d710c62..00b6555 100644
--- a/Hotline/macOS/NewsView.swift
+++ b/Hotline/macOS/News/NewsView.swift
@@ -3,15 +3,14 @@ import SplitView
import SwiftUI
struct NewsView: View {
- @Environment(Hotline.self) private var model: Hotline
+ @Environment(HotlineState.self) private var model: HotlineState
@Environment(\.openWindow) private var openWindow
@Environment(\.colorScheme) private var colorScheme
@State private var selection: NewsInfo?
@State private var articleText: String?
- @State private var splitHidden = SideHolder(.bottom)
- @State private var splitFraction = FractionHolder.usingUserDefaults(
- 0.25, key: "News Split Fraction")
+ @State private var splitHidden: SideHolder = SideHolder(.bottom)
+ @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction")
@State private var editorOpen: Bool = false
@State private var replyOpen: Bool = false
@State private var loading: Bool = false
@@ -19,34 +18,19 @@ struct NewsView: View {
var body: some View {
Group {
if model.serverVersion < 151 {
- VStack {
- Text("No News")
- .bold()
- .foregroundStyle(.secondary)
- .font(.title3)
- Text("This server has news turned off.")
- .foregroundStyle(.tertiary)
- .font(.system(size: 13))
- }
- .padding()
- } else {
+ disabledNewsView
+ }
+ else if !model.newsLoaded {
+ loadingIndicator
+ }
+ else if model.news.isEmpty {
+ emptyNewsView
+ }
+ else {
NavigationStack {
VSplit(
top: {
- if !model.newsLoaded {
- loadingIndicator
- } else if model.news.isEmpty {
- ZStack(alignment: .center) {
- Text("No News")
- .font(.title)
- .multilineTextAlignment(.center)
- .foregroundStyle(.secondary)
- .padding()
- }
- .frame(maxWidth: .infinity)
- } else {
- newsBrowser
- }
+ newsBrowser
},
bottom: {
articleViewer
@@ -62,13 +46,13 @@ struct NewsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .textBackgroundColor))
}
- .task {
- if !model.newsLoaded {
- loading = true
- await model.getNewsList()
- loading = false
- }
- }
+ }
+ }
+ .task {
+ if !model.newsLoaded {
+ loading = true
+ try? await model.getNewsList()
+ loading = false
}
}
.sheet(isPresented: $editorOpen) {
@@ -98,7 +82,7 @@ struct NewsView: View {
}
}
.toolbar {
- ToolbarItem(placement: .primaryAction) {
+ ToolbarItem {
Button {
if selection?.type == .category || selection?.type == .article {
editorOpen = true
@@ -106,11 +90,11 @@ struct NewsView: View {
} label: {
Image(systemName: "square.and.pencil")
}
- .help("New Post")
+ .help("New post under current topic")
.disabled(selection?.type != .category && selection?.type != .article)
}
-
- ToolbarItem(placement: .primaryAction) {
+
+ ToolbarItem {
Button {
if selection?.type == .article {
replyOpen = true
@@ -118,34 +102,76 @@ struct NewsView: View {
} label: {
Image(systemName: "arrowshape.turn.up.left")
}
- .help("Reply to Post")
+ .help("Reply to selected post")
.disabled(selection?.type != .article)
}
-
- ToolbarItem(placement: .primaryAction) {
+
+ if #available(macOS 26.0, *) {
+ ToolbarSpacer(.fixed)
+ }
+
+ ToolbarItem {
+ Button {
+// if selection?.type == .category || selection?.type == .article {
+// editorOpen = true
+// }
+ } label: {
+ Image(systemName: "newspaper")
+ }
+ .help("Create a new topic")
+ }
+
+ ToolbarItem {
+ Button {
+// if selection?.type == .category || selection?.type == .article {
+// editorOpen = true
+// }
+ } label: {
+ Image(systemName: "tray")
+ }
+ .help("Create a new category")
+ }
+
+ ToolbarItem {
Button {
loading = true
if let selectionPath = selection?.path {
Task {
- await model.getNewsList(at: selectionPath)
+ try? await model.getNewsList(at: selectionPath)
loading = false
}
} else {
Task {
- await model.getNewsList()
+ try? await model.getNewsList()
loading = false
}
}
} label: {
Image(systemName: "arrow.clockwise")
}
- .help("Reload News")
+ .help("Reload selected topic")
.disabled(loading)
}
}
}
-
- var newsBrowser: some View {
+
+ private var disabledNewsView: some View {
+ ContentUnavailableView {
+ Label("No News", systemImage: "newspaper")
+ } description: {
+ Text("This server has turned off newsgroups")
+ }
+ }
+
+ private var emptyNewsView: some View {
+ ContentUnavailableView {
+ Label("No News", systemImage: "newspaper")
+ } description: {
+ Text("This server has no newsgroups")
+ }
+ }
+
+ private var newsBrowser: some View {
List(model.news, id: \.self, selection: $selection) { newsItem in
NewsItemView(news: newsItem, depth: 0).tag(newsItem.id)
}
@@ -187,9 +213,7 @@ struct NewsView: View {
let articleID = article.articleID
{
Task {
- if let articleText = await self.model.getNewsArticle(
- id: articleID, at: article.path, flavor: articleFlavor)
- {
+ if let articleText = try? await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) {
self.articleText = articleText
}
}
@@ -223,8 +247,8 @@ struct NewsView: View {
return .ignored
}
}
-
- var loadingIndicator: some View {
+
+ private var loadingIndicator: some View {
VStack {
HStack {
ProgressView {
@@ -235,8 +259,8 @@ struct NewsView: View {
}
.frame(maxWidth: .infinity)
}
-
- var articleViewer: some View {
+
+ private var articleViewer: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if let selection = selection, selection.type == .article {
@@ -272,23 +296,20 @@ struct NewsView: View {
.lineSpacing(6)
.padding(.top, 16)
}
- } else {
- HStack(alignment: .center) {
- Spacer()
- HStack(alignment: .center, spacing: 8) {
- // Image(systemName: "doc.append")
- // .resizable()
- // .scaledToFit()
- // .foregroundStyle(.tertiary)
- // .frame(width: 16, height: 16)
- Text("Select a news post to read")
- .foregroundStyle(.tertiary)
- .font(.system(size: 13))
- }
- Spacer()
- }
- .padding()
- .padding(.top, 48)
+ }
+ else {
+ EmptyView()
+// HStack(alignment: .center) {
+// Spacer()
+// HStack(alignment: .center, spacing: 8) {
+// Text("Select a news article to read")
+// .foregroundStyle(.tertiary)
+// .font(.subheadline)
+// }
+// Spacer()
+// }
+// .padding()
+// .padding(.top, 48)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
@@ -301,5 +322,5 @@ struct NewsView: View {
#Preview {
NewsView()
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ .environment(HotlineState())
}
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index 3a25925..4026e92 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -1,28 +1,6 @@
import SwiftUI
import UniformTypeIdentifiers
-
-@Observable
-class ServerState: Equatable {
- var id: UUID = UUID()
- var selection: ServerNavigationType
-
- init(selection: ServerNavigationType) {
- self.selection = selection
- }
-
- static func == (lhs: ServerState, rhs: ServerState) -> Bool {
- return lhs.id == rhs.id
- }
-}
-
-enum MenuItemType {
- case chat
- case news
- case messageBoard
- case files
- case tasks
- case user
-}
+import AppKit
struct ServerMenuItem: Identifiable, Hashable {
let id: UUID
@@ -69,10 +47,8 @@ struct ListItemView: View {
if let i = icon {
Image(i)
.resizable()
- // .renderingMode(.template)
.scaledToFit()
.frame(width: 20, height: 20)
- // .padding(.leading, 2)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
@@ -90,80 +66,39 @@ struct ListItemView: View {
}
}
-struct ActiveHotlineModelFocusedValueKey: FocusedValueKey {
- typealias Value = Hotline
-}
-
-struct ActiveServerStateFocusedValueKey: FocusedValueKey {
- typealias Value = ServerState
-}
-
extension FocusedValues {
- var activeHotlineModel: Hotline? {
- get { self[ActiveHotlineModelFocusedValueKey.self] }
- set { self[ActiveHotlineModelFocusedValueKey.self] = newValue }
- }
-
- var activeServerState: ServerState? {
- get { self[ActiveServerStateFocusedValueKey.self] }
- set { self[ActiveServerStateFocusedValueKey.self] = newValue }
- }
-}
-
-enum ServerNavigationType: Identifiable, Hashable, Equatable {
- var id: String {
- switch self {
- case .chat:
- return "Chat"
- case .news:
- return "News"
- case .board:
- return "Board"
- case .files:
- return "Files"
- case .accounts:
- return "Accounts"
- case .user(let userID):
- return String(userID)
- }
- }
-
- case chat
- case news
- case board
- case files
- case accounts
- case user(userID: UInt16)
+ @Entry var activeHotlineModel: HotlineState?
+ @Entry var activeServerState: ServerState?
}
struct ServerView: View {
- @Environment(\.dismiss) var dismiss
+ @Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.scenePhase) private var scenePhase
@Environment(\.modelContext) private var modelContext
+
+ @Binding var server: Server
+
+ @State private var model: HotlineState = HotlineState()
@State private var reconnectTimer: Task<Void, Never>?
@State private var shouldReconnect: Bool = false
- @State private var model: Hotline = Hotline(
- trackerClient: HotlineTrackerClient(), client: HotlineClient())
@State private var state: ServerState = ServerState(selection: .chat)
@State private var agreementShown: Bool = false
@State private var connectAddress: String = ""
@State private var connectLogin: String = ""
@State private var connectPassword: String = ""
- @State private var connectNameSheetPresented: Bool = false
- @State private var connectName: String = ""
- @State private var autoconnect: Bool = false
-
- @Binding var server: Server
+ @State private var connectionDisplayed: Bool = false
+// @State private var accountsShown: Bool = false
+ @State private var autoconnect: Bool = false
static var menuItems: [ServerMenuItem] = [
ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"),
ServerMenuItem(type: .board, name: "Board", image: "Section Board"),
ServerMenuItem(type: .news, name: "News", image: "Section News"),
ServerMenuItem(type: .files, name: "Files", image: "Section Files"),
- ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"),
+// ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"),
]
static var classicMenuItems: [ServerMenuItem] = [
@@ -171,21 +106,19 @@ struct ServerView: View {
ServerMenuItem(type: .board, name: "Board", image: "Section Board"),
ServerMenuItem(type: .files, name: "Files", image: "Section Files"),
]
-
- enum FocusFields {
- case address
- case login
- case password
- }
-
- @FocusState private var focusedField: FocusFields?
-
+
var body: some View {
Group {
- if model.status == .disconnected {
- connectForm
- .navigationTitle("Connect to Server")
- } else if model.status != .loggedIn {
+ if self.model.status == .disconnected {
+ VStack(alignment: .center) {
+ Spacer()
+ self.connectForm
+ Spacer()
+ }
+ .presentedWindowToolbarStyle(.unified(showsTitle: false))
+ .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle)
+ }
+ else if self.model.status.isLoggingIn {
HStack {
Image("Hotline")
.resizable()
@@ -193,41 +126,80 @@ struct ServerView: View {
.scaledToFit()
.foregroundColor(Color(hex: 0xE10000))
.frame(width: 18)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ .opacity(self.controlActiveState == .inactive ? 0.5 : 1.0)
.padding(.trailing, 4)
- ProgressView(value: connectionStatusToProgress(status: model.status)) {
- Text(connectionStatusToLabel(status: model.status))
+ ProgressView(value: connectionStatusToProgress(status: self.model.status)) {
+ Text(connectionStatusToLabel(status: self.model.status))
}
- .accentColor(colorScheme == .dark ? .white : .black)
+ .accentColor(self.colorScheme == .dark ? .white : .black)
}
.frame(maxWidth: 300)
.padding()
- .navigationTitle("Connecting to Server")
- } else {
- serverView
- .environment(model)
- .onChange(of: Prefs.shared.userIconID) { sendPreferences() }
- .onChange(of: Prefs.shared.username) { sendPreferences() }
- .onChange(of: Prefs.shared.refusePrivateMessages) { sendPreferences() }
- .onChange(of: Prefs.shared.refusePrivateChat) { sendPreferences() }
- .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() }
- .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() }
+ .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle)
+ }
+ else if self.model.status == .loggedIn {
+ self.serverView
+ .environment(self.model)
+ .onChange(of: Prefs.shared.userIconID) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.username) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.refusePrivateMessages) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.refusePrivateChat) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.enableAutomaticMessage) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .onChange(of: Prefs.shared.automaticMessage) {
+ Task { try? await self.model.sendUserPreferences() }
+ }
+ .sheet(isPresented: self.$state.broadcastShown) {
+ BroadcastMessageSheet()
+ .environment(self.model)
+ .presentationSizing(.fitted)
+ }
+ .sheet(isPresented: self.$state.accountsShown) {
+ AccountManagerView()
+ .environment(self.model)
+ .frame(width: 400, height: 450)
+ .presentationSizing(.fitted)
+ }
.toolbar {
- ToolbarItem(placement: .navigation) {
- Image("Server Large")
- // .renderingMode(.template)
-
- .resizable()
- .scaledToFit()
- .frame(width: 28)
- .opacity(controlActiveState == .inactive ? 0.4 : 1.0)
+ if #available(macOS 26.0, *) {
+ ToolbarItem(placement: .navigation) {
+ Image("Server Large")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28)
+ .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0)
+ }
+ .sharedBackgroundVisibility(.hidden)
+ }
+ else {
+ ToolbarItem(placement: .navigation) {
+ Image("Server Large")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28)
+ .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0)
+ }
}
}
}
}
.onDisappear {
- model.disconnect()
+ Task {
+ await self.model.disconnect()
+ }
+ }
+ .onChange(of: self.model.serverTitle) {
+ self.state.serverName = self.model.serverTitle
}
.onChange(of: model.status) {
if model.status == .loggedIn {
@@ -239,181 +211,84 @@ struct ServerView: View {
startReconnectTimer()
}
}
- .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) {
- Button("OK") {}
- }
- .task {
- var address = server.address
- if server.port != HotlinePorts.DefaultServerPort {
- address += ":\(server.port)"
+ .onAppear {
+ var address = self.server.address
+ if self.server.port != HotlinePorts.DefaultServerPort {
+ address += ":\(self.server.port)"
}
- connectAddress = server.address
- connectLogin = server.login
- connectPassword = server.password
-
+ self.connectAddress = self.server.address
+ self.connectLogin = self.server.login
+ self.connectPassword = self.server.password
+
// Connect to server automatically unless the option key is held down.
if !NSEvent.modifierFlags.contains(.option) {
- connectToServer()
+ self.connectToServer()
+ }
+
+ self.connectionDisplayed = true
+ }
+ .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) {
+ Button("OK") {}
+ } message: {
+ if let message = self.model.errorMessage,
+ !message.isBlank {
+ Text(message)
}
}
.focusedSceneValue(\.activeHotlineModel, model)
.focusedSceneValue(\.activeServerState, state)
}
-
- private func startReconnectTimer() {
- reconnectTimer = Task {
- while !Task.isCancelled {
- connectToServer()
- try? await Task.sleep(for: .seconds(5))
- }
+
+ private var connectForm: some View {
+ ConnectView(address: self.$connectAddress, login: self.$connectLogin, password: self.$connectPassword) {
+ self.connectToServer()
}
- }
-
- var connectForm: some View {
- VStack(alignment: .center) {
- GroupBox {
- Form {
- Group {
- TextField(text: $connectAddress) {
- Text("Address:")
- }
- .focused($focusedField, equals: .address)
-
- Text(
- "Type the address of the Hotline server you would like to connect to. If you have an account on that server, type your login and password too."
- )
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.bottom, 4)
-
- TextField(text: $connectLogin, prompt: Text("Optional")) {
- Text("Login:")
- }
- .focused($focusedField, equals: .login)
- SecureField(text: $connectPassword, prompt: Text("Optional")) {
- Text("Password:")
- }
- .focused($focusedField, equals: .password)
- }
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
-
- HStack {
- Button("Save...") {
- if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- connectNameSheetPresented = true
- }
- }
- .disabled(connectAddress.isEmpty)
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .help("Bookmark server")
-
- Spacer()
-
- Button("Cancel") {
- dismiss()
- }
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .keyboardShortcut(.cancelAction)
-
- Button("Connect") {
- Task {
- connectToServer()
- }
- }
-
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .keyboardShortcut(.defaultAction)
- }
- .padding(.top, 8)
-
- }
- .padding()
- .onChange(of: connectAddress) {
- let (a, p) = Server.parseServerAddressAndPort(connectAddress)
- server.address = a
- server.port = p
- }
- .onChange(of: connectLogin) {
- server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
- }
- .onChange(of: connectPassword) {
- server.password = connectPassword
- }
- }
- .onAppear {
- focusedField = .address
- }
+ .focusSection()
+ .onChange(of: self.connectAddress) {
+ let (a, p) = Server.parseServerAddressAndPort(self.connectAddress)
+ self.server.address = a
+ self.server.port = p
}
- .frame(maxWidth: 380)
- .padding()
- .sheet(isPresented: $connectNameSheetPresented) {
- VStack(alignment: .leading) {
- Text("Name this server bookmark:")
- .foregroundStyle(.secondary)
- .padding(.bottom, 4)
- TextField("Bookmark Name", text: $connectName)
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
- Toggle("Connect on Startup", isOn: $autoconnect)
- }
- .frame(width: 250)
- .padding()
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- connectNameSheetPresented = false
- connectName = ""
- }
- }
-
- ToolbarItem(placement: .confirmationAction) {
- Button("Save") {
- let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines))
- if !name.isEmpty {
- connectNameSheetPresented = false
- connectName = ""
- Task.detached {
- let (host, port) = await Server.parseServerAddressAndPort(connectAddress)
- let login: String? = await connectLogin.isEmpty ? nil : connectLogin
- let password: String? = await connectPassword.isEmpty ? nil : connectPassword
-
- if !host.isEmpty {
- let newBookmark = await Bookmark(
- type: .server, name: name, address: host, port: port, login: login,
- password: password, autoconnect: autoconnect)
- await MainActor.run {
- Bookmark.add(newBookmark, context: modelContext)
- }
- }
- }
- }
- }
- }
- }
- .onAppear {
- autoconnect = false
- }
+ .onChange(of: self.connectLogin) {
+ self.server.login = self.connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ .onChange(of: self.connectPassword) {
+ self.server.password = self.connectPassword
+ }
+ .onAppear {
+ autoconnect = false
}
}
- var navigationList: some View {
+ private var navigationList: some View {
List(selection: $state.selection) {
// Don't show news on older servers.
ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) {
menuItem in
if menuItem.type == .chat {
- ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat)
- .tag(menuItem.type)
- } else if menuItem.type == .accounts {
- if model.access?.contains(.canOpenUsers) == true {
- ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(
- menuItem.type)
- }
- } else {
+ ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type)
+ }
+// else if menuItem.type == .board {
+// if self.model.access?.contains(.canReadMessageBoard) == true {
+// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+// }
+// }
+// else if menuItem.type == .accounts {
+// if model.access?.contains(.canOpenUsers) == true {
+// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+// }
+// }
+ else if menuItem.type == .files {
+ ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+ .overlay(alignment: .trailing) {
+ if case .searching(_, _) = model.fileSearchStatus {
+ ProgressView()
+ .controlSize(.mini)
+ .padding(.trailing, 4)
+ }
+ }
+ }
+ else {
ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
}
}
@@ -443,34 +318,32 @@ struct ServerView: View {
}
var transfersSection: some View {
- // Section("Transfers") {
ForEach(model.transfers) { transfer in
- TransferItemView(transfer: transfer)
+ ServerTransferRow(transfer: transfer)
}
- // }
}
var usersSection: some View {
- // Section("\(model.users.count) Online") {
ForEach(model.users) { user in
HStack(spacing: 5) {
- if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) {
+ if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) {
Image(nsImage: iconImage)
.frame(width: 16, height: 16)
.padding(.leading, 2)
.padding(.trailing, 2)
- } else {
+ }
+ else {
Image("User")
.frame(width: 16, height: 16)
.padding(.leading, 2)
.padding(.trailing, 2)
}
-
+
Text(user.name)
.foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary)
-
+
Spacer()
-
+
if model.hasUnreadInstantMessages(userID: user.id) {
Circle()
.frame(width: 6, height: 6)
@@ -482,91 +355,82 @@ struct ServerView: View {
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
.tag(ServerNavigationType.user(userID: user.id))
}
- // }
}
var serverView: some View {
NavigationSplitView {
self.navigationList
- .frame(maxWidth: .infinity)
- .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500)
+ .navigationSplitViewColumnWidth(200)
+// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 400)
+ .toolbar(removing: .sidebarToggle)
+// .toolbar {
+// if self.model.access?.contains(.canOpenUsers) == true {
+// ToolbarItem(placement: .primaryAction) {
+// Button {
+// self.state.accountsShown = true
+// } label: {
+// Label("Manage Accounts", systemImage: "gear")
+// }
+// .help("Manage Accounts")
+// }
+// }
+// }
} detail: {
- switch state.selection {
- case .chat:
- ChatView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Public Chat")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .news:
- NewsView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Newsgroups")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .board:
- MessageBoardView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Message Board")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .files:
- FilesView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Shared Files")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .accounts:
- AccountManagerView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Accounts")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .user(let userID):
- let user = model.users.first(where: { $0.id == userID })
- MessageView(userID: userID)
- .navigationTitle(model.serverTitle)
- .navigationSubtitle(user?.name ?? "Private Message")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- .onAppear {
- model.markInstantMessagesAsRead(userID: userID)
- }
- }
+ switch state.selection {
+ case .chat:
+ ChatView()
+ case .news:
+ NewsView()
+ case .board:
+ MessageBoardView()
+ case .files:
+ FilesView()
+ case .user(let userID):
+ MessageView(userID: userID)
+ }
}
- .toolbar(removing: .sidebarToggle)
-
+ .navigationTitle(self.model.serverTitle)
}
-
+
// MARK: -
@MainActor func connectToServer() {
- guard !server.address.isEmpty else {
+ guard !self.server.address.isEmpty else {
return
}
- model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) {
- success in
- if !success {
- print("FAILED LOGIN??")
- model.disconnect()
- } else {
- sendPreferences()
- model.getUserList()
- model.downloadBanner()
+
+ // Set status here so it's immediate (not waiting to enter task).
+ self.model.status = .connecting
+
+ Task { @MainActor in
+ do {
+ try await self.model.login(
+ server: server,
+ username: Prefs.shared.username,
+ iconID: Prefs.shared.userIconID
+ )
+ } catch {
+ print("ServerView: Login failed: \(error)")
}
}
}
-
- private func connectionStatusToProgress(status: HotlineClientStatus) -> Double {
+
+ private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double {
switch status {
case .disconnected:
return 0.0
case .connecting:
return 0.4
case .connected:
- return 0.75
- case .loggingIn:
return 0.9
case .loggedIn:
return 1.0
+ case .failed:
+ return 0.0
}
}
- private func connectionStatusToLabel(status: HotlineClientStatus) -> String {
+ private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String {
let n = server.name ?? server.address
switch status {
case .disconnected:
@@ -574,88 +438,69 @@ struct ServerView: View {
case .connecting:
return "Connecting to \(n)..."
case .connected:
- return "Connected to \(n)"
- case .loggingIn:
return "Logging in to \(n)..."
case .loggedIn:
return "Logged in to \(n)"
+ case .failed(let error):
+ return "Failed: \(error)"
}
}
-
- @MainActor func sendPreferences() {
- if self.model.status == .loggedIn {
- var options: HotlineUserOptions = HotlineUserOptions()
-
- if Prefs.shared.refusePrivateMessages {
- options.update(with: .refusePrivateMessages)
- }
-
- if Prefs.shared.refusePrivateChat {
- options.update(with: .refusePrivateChat)
- }
-
- if Prefs.shared.enableAutomaticMessage {
- options.update(with: .automaticResponse)
- }
-
- print("Updating preferences with server")
-
- self.model.sendUserInfo(
- username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options,
- autoresponse: Prefs.shared.automaticMessage)
- }
- }
+
}
-//#Preview {
-// ServerView(server: Server(name: "", description: "", address: "", port: 0))
-//}
-
-struct TransferItemView: View {
+struct ServerTransferRow: View {
let transfer: TransferInfo
@Environment(\.controlActiveState) private var controlActiveState
- @Environment(Hotline.self) private var model: Hotline
+ @Environment(HotlineState.self) private var model: HotlineState
@State private var hovered: Bool = false
@State private var buttonHovered: Bool = false
-
- private func formattedProgressHelp() -> String {
- if self.transfer.completed {
- return "File transfer complete"
- } else if self.transfer.failed {
- return "File transfer failed"
- } else if self.transfer.progress > 0.0 {
- if self.transfer.timeRemaining > 0.0 {
- return
- "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left"
- } else {
- return "\(round(self.transfer.progress * 100.0))% complete"
- }
- }
- return ""
- }
-
+ @State private var detailsShown: Bool = false
+
var body: some View {
HStack(alignment: .center, spacing: 5) {
HStack(spacing: 0) {
Spacer()
- FileIconView(filename: transfer.title, fileType: nil)
- .frame(width: 16, height: 16)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- // .padding(.leading, 2)
+ if self.transfer.isFolder {
+ Image("Folder")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
+ else {
+ FileIconView(filename: transfer.title, fileType: nil)
+ .frame(width: 16, height: 16)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
Spacer()
}
.frame(width: 20)
-
- Text(transfer.title)
+
+ Text(self.transfer.folderName ?? self.transfer.title)
.lineLimit(1)
.truncationMode(.middle)
-
- Spacer()
-
+
+ Spacer(minLength: 0)
+
+ if !self.transfer.done {
+ if self.transfer.progress == 0.0 {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .controlSize(.extraLarge)
+ .frame(maxWidth: 40)
+ }
+ else {
+ ProgressView(value: self.transfer.progress, total: 1.0)
+ .progressViewStyle(.linear)
+ .controlSize(.extraLarge)
+ .frame(maxWidth: 40)
+ }
+ }
+
if self.hovered {
Button {
- model.deleteTransfer(id: transfer.id)
+ AppState.shared.cancelTransfer(id: transfer.id)
} label: {
Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle")
.resizable()
@@ -686,21 +531,73 @@ struct TransferItemView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- } else if transfer.progress == 0.0 {
- ProgressView()
- .progressViewStyle(.circular)
- .controlSize(.small)
- } else {
- ProgressView(value: transfer.progress, total: 1.0)
- .progressViewStyle(.circular)
- .controlSize(.small)
}
}
.onHover { hovered in
- withAnimation(.easeOut(duration: 0.25)) {
+ withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) {
self.hovered = hovered
}
+// self.detailsShown = hovered
+ }
+ .onTapGesture(count: 2) {
+ guard transfer.completed, let url = transfer.fileURL else {
+ return
+ }
+
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ }
+ .popover(isPresented: .constant(self.detailsShown && !self.transfer.done), arrowEdge: .trailing) {
+ let rows: [(String, String)] = [
+ ("document", self.transfer.title),
+ ("info", self.transfer.displaySize),
+ (self.transfer.isUpload ? "arrow.up" : "arrow.down", self.transfer.displaySpeed ?? "--"),
+ ("clock", self.transfer.displayTimeRemaining ?? "--")
+ ]
+
+ Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
+ ForEach(rows, id: \.0) { imageName, label in
+ GridRow {
+ Image(systemName: imageName)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ .gridColumnAlignment(.trailing)
+ Text(label)
+ .monospacedDigit()
+ .gridColumnAlignment(.leading)
+ }
+ }
+ }
+ .frame(minWidth: 200, maxWidth: 350, alignment: .leading)
+ .padding()
+ }
+ }
+
+ private var formattedProgressHelp: String {
+ if self.transfer.completed {
+ return "File transfer complete"
+ }
+ else if self.transfer.failed {
+ return "File transfer failed"
+ }
+ else if self.transfer.cancelled {
+ return "File transfer cancelled"
+ }
+ else if self.transfer.progress > 0.0 {
+ var parts: [String] = []
+
+ if let speed = self.transfer.displaySpeed {
+ parts.append(speed)
+ }
+
+ if let timeRemaining = self.transfer.displayTimeRemaining {
+ parts.append(timeRemaining)
+ }
+
+ if parts.count > 0 {
+ return parts.joined(separator: " • ")
+ }
}
- .help(formattedProgressHelp())
+ return ""
}
}
diff --git a/Hotline/macOS/Settings/GeneralSettingsView.swift b/Hotline/macOS/Settings/GeneralSettingsView.swift
new file mode 100644
index 0000000..6be73aa
--- /dev/null
+++ b/Hotline/macOS/Settings/GeneralSettingsView.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+
+struct GeneralSettingsView: View {
+ @State private var username: String = ""
+ @State private var usernameChanged: Bool = false
+ @State private var showClearHistoryConfirmation: Bool = false
+
+ let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
+
+ var body: some View {
+ @Bindable var preferences = Prefs.shared
+
+ Form {
+ TextField("Your Name", text: $username, prompt: Text("guest"))
+ Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages)
+ Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages)
+ Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat)
+ Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage)
+ if preferences.enableAutomaticMessage {
+ TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message"))
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity)
+ .onSubmit(of: .text) {
+ preferences.username = self.username
+ }
+ }
+
+ Divider()
+
+ Button(role: .destructive) {
+ showClearHistoryConfirmation = true
+ } label: {
+ Text("Clear Chat History…")
+ }
+ }
+ .padding()
+ .frame(width: 392)
+ .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) {
+ Button("Clear Chat History", role: .destructive) {
+ Task {
+ await ChatStore.shared.clearAll()
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.")
+ }
+ .onAppear {
+ self.username = preferences.username
+ self.usernameChanged = false
+ }
+ .onDisappear {
+ preferences.username = self.username
+ self.usernameChanged = false
+ }
+ .onChange(of: username) { oldValue, newValue in
+ self.usernameChanged = true
+ }
+ .onReceive(saveTimer) { _ in
+ if self.usernameChanged {
+ self.usernameChanged = false
+ preferences.username = self.username
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift
new file mode 100644
index 0000000..2bb92c4
--- /dev/null
+++ b/Hotline/macOS/Settings/IconSettingsView.swift
@@ -0,0 +1,58 @@
+import SwiftUI
+
+struct IconSettingsView: View {
+ @State private var hoveredUserIconID: Int = -1
+
+ var body: some View {
+ @Bindable var preferences = Prefs.shared
+
+ Form {
+ ScrollViewReader { scrollProxy in
+ ScrollView {
+ LazyVGrid(columns: [
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4)),
+ GridItem(.fixed(4+32+4))
+ ], spacing: 0) {
+ ForEach(HotlineState.classicIconSet, id: \.self) { iconID in
+ HStack {
+ Image("Classic/\(iconID)")
+ .resizable()
+ .interpolation(.none)
+ .scaledToFit()
+ .frame(width: 32, height: 16)
+ .help("Icon \(String(iconID))")
+ }
+ .tag(iconID)
+ .frame(width: 32, height: 32)
+ .padding(4)
+ .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor)))
+ .clipShape(RoundedRectangle(cornerRadius: 5))
+ .onTapGesture {
+ preferences.userIconID = iconID
+ }
+ .onHover { hovered in
+ if hovered {
+ self.hoveredUserIconID = iconID
+ }
+ }
+ }
+ }
+ .padding()
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1))
+ .onAppear {
+ scrollProxy.scrollTo(preferences.userIconID, anchor: .center)
+ }
+ .frame(height: 355)
+ }
+ }
+ .padding()
+ }
+}
diff --git a/Hotline/macOS/Settings/SettingsView.swift b/Hotline/macOS/Settings/SettingsView.swift
new file mode 100644
index 0000000..b2a2948
--- /dev/null
+++ b/Hotline/macOS/Settings/SettingsView.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct SettingsView: View {
+ private enum Tabs: Hashable {
+ case general, icon
+ }
+
+ var body: some View {
+ TabView {
+ GeneralSettingsView()
+ .tabItem {
+ Label("General", systemImage: "person.text.rectangle")
+ }
+ .tag(Tabs.general)
+ IconSettingsView()
+ .tabItem {
+ Label("Icon", systemImage: "person")
+ }
+ .tag(Tabs.icon)
+ SoundSettingsView()
+ .tabItem {
+ Label("Sound", systemImage: "speaker.wave.3")
+ }
+ .tag(Tabs.icon)
+ }
+ }
+}
+
+#Preview {
+ SettingsView()
+}
diff --git a/Hotline/macOS/Settings/SoundSettingsView.swift b/Hotline/macOS/Settings/SoundSettingsView.swift
new file mode 100644
index 0000000..b99db77
--- /dev/null
+++ b/Hotline/macOS/Settings/SoundSettingsView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct SoundSettingsView: View {
+ var body: some View {
+ @Bindable var preferences = Prefs.shared
+
+ Form {
+ Toggle("Enable Sounds", isOn: $preferences.playSounds)
+ .controlSize(.large)
+
+ Section("Sounds") {
+ Toggle("Chat", isOn: $preferences.playChatSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Private Message", isOn: $preferences.playPrivateMessageSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Join", isOn: $preferences.playJoinSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Leave", isOn: $preferences.playLeaveSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Logged in", isOn: $preferences.playLoggedInSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Error", isOn: $preferences.playErrorSound)
+ .disabled(!preferences.playSounds)
+
+ Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound)
+ .disabled(!preferences.playSounds)
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 392, height: 433)
+ }
+}
diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift
deleted file mode 100644
index c67ba85..0000000
--- a/Hotline/macOS/SettingsView.swift
+++ /dev/null
@@ -1,183 +0,0 @@
-import SwiftUI
-
-struct GeneralSettingsView: View {
- @State private var username: String = ""
- @State private var usernameChanged: Bool = false
-
- let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
-
- var body: some View {
- @Bindable var preferences = Prefs.shared
-
- Form {
- TextField("Your Name", text: $username, prompt: Text("guest"))
- Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages)
- Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages)
- Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat)
- Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage)
- if preferences.enableAutomaticMessage {
- TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message"))
- .lineLimit(2)
- .multilineTextAlignment(.leading)
- .frame(maxWidth: .infinity)
- .onSubmit(of: .text) {
- preferences.username = self.username
- }
- }
- }
- .padding()
- .frame(width: 392)
- .onAppear {
- self.username = preferences.username
- self.usernameChanged = false
- }
- .onDisappear {
- preferences.username = self.username
- self.usernameChanged = false
- }
- .onChange(of: username) { oldValue, newValue in
- self.usernameChanged = true
- }
- .onReceive(saveTimer) { _ in
- if self.usernameChanged {
- self.usernameChanged = false
- preferences.username = self.username
- }
- }
- }
-}
-
-struct IconSettingsView: View {
- @State private var hoveredUserIconID: Int = -1
-
- var body: some View {
- @Bindable var preferences = Prefs.shared
-
- Form {
- ScrollViewReader { scrollProxy in
- ScrollView {
- LazyVGrid(
- columns: [
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- GridItem(.fixed(4 + 32 + 4)),
- ], spacing: 0
- ) {
- ForEach(Hotline.classicIconSet, id: \.self) { iconID in
- HStack {
- Image("Classic/\(iconID)")
- .resizable()
- .interpolation(.none)
- .scaledToFit()
- .frame(width: 32, height: 16)
- .help("Icon \(String(iconID))")
- }
- .tag(iconID)
- .frame(width: 32, height: 32)
- .padding(4)
- .background(
- iconID == preferences.userIconID
- ? Color.accentColor
- : (iconID == hoveredUserIconID
- ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))
- )
- .clipShape(RoundedRectangle(cornerRadius: 5))
- .onTapGesture {
- preferences.userIconID = iconID
- }
- .onHover { hovered in
- if hovered {
- self.hoveredUserIconID = iconID
- }
- }
- }
- }
- .padding()
- }
- .background(Color(nsColor: .textBackgroundColor))
- .clipShape(RoundedRectangle(cornerRadius: 6))
- .overlay(
- RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- )
- .onAppear {
- scrollProxy.scrollTo(preferences.userIconID, anchor: .center)
- }
- .frame(height: 355)
- }
- }
- .padding()
- }
-}
-
-struct SoundSettingsView: View {
- var body: some View {
- @Bindable var preferences = Prefs.shared
-
- Form {
- Toggle("Enable Sounds", isOn: $preferences.playSounds)
- .controlSize(.large)
-
- Section("Sounds") {
- Toggle("Chat", isOn: $preferences.playChatSound)
- .disabled(!preferences.playSounds)
-
- Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Private Message", isOn: $preferences.playPrivateMessageSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Join", isOn: $preferences.playJoinSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Leave", isOn: $preferences.playLeaveSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Logged in", isOn: $preferences.playLoggedInSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Error", isOn: $preferences.playErrorSound)
- .disabled(!preferences.playSounds)
-
- Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound)
- .disabled(!preferences.playSounds)
- }
- }
- .formStyle(.grouped)
- .frame(width: 392, height: 433)
- }
-}
-
-struct SettingsView: View {
- private enum Tabs: Hashable {
- case general, icon
- }
-
- var body: some View {
- TabView {
- GeneralSettingsView()
- .tabItem {
- Label("General", systemImage: "person.text.rectangle")
- }
- .tag(Tabs.general)
- IconSettingsView()
- .tabItem {
- Label("Icon", systemImage: "person")
- }
- .tag(Tabs.icon)
- SoundSettingsView()
- .tabItem {
- Label("Sound", systemImage: "speaker.wave.3")
- }
- .tag(Tabs.icon)
- }
- }
-}
-
-#Preview {
- SettingsView()
-}
diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift
deleted file mode 100644
index 0943d63..0000000
--- a/Hotline/macOS/TrackerView.swift
+++ /dev/null
@@ -1,498 +0,0 @@
-import Foundation
-import SwiftData
-import SwiftUI
-import UniformTypeIdentifiers
-
-struct TrackerView: View {
- @Environment(\.colorScheme) private var colorScheme
- @Environment(\.openWindow) private var openWindow
- @Environment(\.controlActiveState) private var controlActiveState
- @Environment(\.modelContext) private var modelContext
-
- @State private var refreshing = false
- @State private var trackerSheetPresented: Bool = false
- @State private var trackerSheetBookmark: Bookmark? = nil
- @State private var attemptedPrepopulate: Bool = false
- @State private var fileDropActive = false
- @State private var bookmarkExportActive = false
- @State private var bookmarkExport: BookmarkDocument? = nil
-
- @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark]
- @Binding var selection: Bookmark?
-
- var body: some View {
- List(selection: $selection) {
- ForEach(bookmarks, id: \.self) { bookmark in
- TrackerItemView(bookmark: bookmark)
- .onAppear {
- Task {
- if bookmark.autoconnect, let server = bookmark.server {
- openWindow(id: "server", value: server)
- }
- }
- }
- .tag(bookmark)
-
- if bookmark.type == .tracker && bookmark.expanded {
- ForEach(bookmark.servers, id: \.self) { trackedServer in
- TrackerItemView(bookmark: trackedServer)
- .moveDisabled(true)
- .deleteDisabled(true)
- .tag(trackedServer)
- }
- }
- }
- .onMove { movedIndexes, destinationIndex in
- Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext)
- }
- .onDelete { deletedIndexes in
- Bookmark.delete(at: deletedIndexes, context: modelContext)
- }
- }
- .onDeleteCommand {
- if let bookmark = selection,
- bookmark.type != .temporary
- {
- Bookmark.delete(bookmark, context: modelContext)
- }
- }
- .environment(\.defaultMinListRowHeight, 34)
- .listStyle(.inset)
- .alternatingRowBackgrounds(.enabled)
- .onChange(of: ApplicationState.shared.cloudKitReady) {
- if attemptedPrepopulate {
- print("Tracker: Already attempted to prepopulate bookmarks")
- return
- }
-
- print("Tracker: Prepopulating bookmarks")
-
- attemptedPrepopulate = true
-
- // Make sure default bookmarks are there when empty.
- Bookmark.populateDefaults(context: modelContext)
- }
- .onAppear {
- // Bookmark.deleteAll(context: modelContext)
- }
- .contextMenu(forSelectionType: Bookmark.self) { items in
- if let item = items.first {
- if item.type == .temporary {
- Button {
- let newBookmark = Bookmark(
- type: .server, name: item.name, address: item.address, port: item.port,
- login: item.login, password: item.password)
- Bookmark.add(newBookmark, context: modelContext)
- } label: {
- Label("Bookmark", systemImage: "bookmark")
- }
-
- Divider()
- }
-
- Button {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(item.displayAddress, forType: .string)
- } label: {
- Label("Copy Address", systemImage: "doc.on.doc")
- }
-
- if item.type == .tracker || item.type == .server {
- Divider()
-
- if item.type == .tracker {
- Button {
- trackerSheetBookmark = item
- } label: {
- Label("Edit Tracker...", systemImage: "pencil")
- }
- }
-
- if item.type == .server {
- Button {
- bookmarkExport = BookmarkDocument(bookmark: item)
- bookmarkExportActive = true
- } label: {
- Label("Export Bookmark...", systemImage: "bookmark.square")
- }
- }
-
- Divider()
-
- Button {
- Bookmark.delete(item, context: modelContext)
- } label: {
- Label(
- item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash")
- }
- }
- }
- } primaryAction: { items in
- guard let clickedItem = items.first else {
- return
- }
-
- if clickedItem.type == .tracker {
- if NSEvent.modifierFlags.contains(.option) {
- trackerSheetBookmark = clickedItem
- } else {
- clickedItem.expanded.toggle()
- }
- } else if let server = clickedItem.server {
- openWindow(id: "server", value: server)
- }
- }
- .fileExporter(
- isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data],
- defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm",
- onCompletion: { result in
- switch result {
- case .success(let fileURL):
- print("Hotline Bookmark: Successfully exported:", fileURL)
- case .failure(let err):
- print("Hotline Bookmark: Failed to export:", err)
- }
-
- bookmarkExport = nil
- bookmarkExportActive = false
- }, onCancellation: {}
- )
- .onKeyPress(.rightArrow) {
- if let bookmark = selection,
- bookmark.type == .tracker
- {
- bookmark.expanded = true
- return .handled
- }
- return .ignored
- }
- .onKeyPress(.leftArrow) {
- if let bookmark = selection,
- bookmark.type == .tracker
- {
- bookmark.expanded = false
- return .handled
- }
- return .ignored
- }
- .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in
- for provider in providers {
- let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in
- // HOTLINE CREATOR CODE: 1213484099
- // HOTLINE BOOKMARK TYPE CODE: 1213489773
-
- if let filePathData = dataRepresentation,
- let filePath = String(data: filePathData, encoding: .utf8),
- let fileURL = URL(string: filePath)
- {
-
- print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false))
-
- DispatchQueue.main.async {
- if let newBookmark = Bookmark(fileURL: fileURL) {
- print("Hotline Bookmark: Added bookmark.")
- Bookmark.add(newBookmark, context: modelContext)
- } else {
- print("Hotline Bookmark: Failed to parse.")
- }
- }
- }
- }
- }
-
- return true
- }
- .sheet(item: $trackerSheetBookmark) { item in
- TrackerBookmarkSheet(item)
- }
- .sheet(isPresented: $trackerSheetPresented) {
- TrackerBookmarkSheet()
- }
- .navigationTitle("Servers")
- .toolbar {
- ToolbarItem(placement: .navigation) {
- Image("Hotline")
- .resizable()
- .renderingMode(.template)
- .scaledToFit()
- .foregroundColor(Color(hex: 0xE10000))
- .frame(width: 9)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- refreshing = true
- refresh()
- refreshing = false
- } label: {
- Label("Refresh", systemImage: "arrow.clockwise")
- }
- .disabled(refreshing)
- .help("Refresh Trackers")
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- trackerSheetPresented = true
- } label: {
- Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted")
- }
- .help("Add Tracker")
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button {
- openWindow(id: "server")
- } label: {
- Label("Connect to Server", systemImage: "globe.americas.fill")
- }
- .help("Connect to Server")
- }
- }
- .onOpenURL(perform: { url in
- if let s = Server(url: url) {
- openWindow(id: "server", value: s)
- }
- })
- }
-
- func refresh() {
- // When a tracker is selected, refresh only that tracker.
- if let selectedBookmark = selection,
- selectedBookmark.type == .tracker
- {
- if !selectedBookmark.expanded {
- selectedBookmark.expanded = true
- } else {
- Task {
- await selectedBookmark.fetchServers()
- }
- }
- return
- }
-
- // Otherwise refresh/expand all trackers.
- for bookmark in self.bookmarks {
- if bookmark.type == .tracker {
- if !bookmark.expanded {
- bookmark.expanded = true
- } else {
- Task {
- await bookmark.fetchServers()
- }
- }
- }
- }
- }
-}
-
-struct TrackerBookmarkSheet: View {
- @Environment(\.dismiss) private var dismiss
- @Environment(\.modelContext) private var modelContext
-
- @State private var bookmark: Bookmark? = nil
- @State private var trackerAddress: String = ""
- @State private var trackerName: String = ""
-
- init() {
-
- }
-
- init(_ editingBookmark: Bookmark) {
- _bookmark = .init(initialValue: editingBookmark)
- _trackerAddress = .init(initialValue: editingBookmark.displayAddress)
- _trackerName = .init(initialValue: editingBookmark.name)
- }
-
- var body: some View {
- VStack(alignment: .leading) {
- Text("Type the address and name of a Hotline Tracker:")
- .foregroundStyle(.secondary)
- .padding(.bottom, 8)
- Form {
- Group {
- TextField(text: $trackerAddress) {
- Text("Address:")
- }
- TextField(text: $trackerName, prompt: Text("Optional")) {
- Text("Name:")
- }
- }
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
- }
- }
- .frame(width: 300)
- .fixedSize(horizontal: true, vertical: true)
- .padding()
- .toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button(self.bookmark != nil ? "Save Tracker" : "Add Tracker") {
- var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines)
- let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress)
-
- if displayName.isEmpty {
- displayName = host
- }
-
- if !displayName.isEmpty && !host.isEmpty {
- if !host.isEmpty {
- if self.bookmark != nil {
- // We're editing an existing bookmark.
- self.bookmark?.name = displayName
- self.bookmark?.address = host
- self.bookmark?.port = port
- } else {
- // We're creating a new bookmark.
- let newBookmark = Bookmark(
- type: .tracker, name: displayName, address: host, port: port)
- Bookmark.add(newBookmark, context: modelContext)
- }
-
- self.trackerName = ""
- self.trackerAddress = ""
-
- dismiss()
- }
- }
- }
- }
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- self.trackerName = ""
- self.trackerAddress = ""
-
- dismiss()
- }
- }
- }
- }
-}
-
-struct TrackerItemView: View {
- let bookmark: Bookmark
-
- @State private var onlineAnimationMaxState: Bool = true
-
- var body: some View {
- HStack(alignment: .center, spacing: 6) {
- if bookmark.type == .tracker {
- Button {
- bookmark.expanded.toggle()
- } label: {
- Text(Image(systemName: bookmark.expanded ? "chevron.down" : "chevron.right"))
- .bold()
- .font(.system(size: 10))
- .opacity(0.5)
- .frame(alignment: .center)
- }
- .buttonStyle(.plain)
- .frame(width: 10)
- .padding(.leading, 4)
- .padding(.trailing, 2)
- }
-
- switch bookmark.type {
- case .tracker:
- Image("Tracker")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16, alignment: .center)
- Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail)
- if bookmark.loading {
- ProgressView()
- .padding([.leading, .trailing], 2)
- .controlSize(.small)
- }
- Spacer(minLength: 0)
- case .server:
- Image(systemName: "bookmark.fill")
- .resizable()
- .renderingMode(.template)
- .aspectRatio(contentMode: .fit)
- .frame(width: 11, height: 11, alignment: .center)
- .opacity(0.5)
- .padding(.leading, 3)
- Image(systemName: "bolt.fill")
- .resizable()
- .renderingMode(.template)
- .aspectRatio(contentMode: .fit)
- .frame(width: 13, height: 13, alignment: .center)
- .opacity(0.5)
- .padding(.leading, 3)
- .padding(.trailing, 2)
- .foregroundStyle(bookmark.autoconnect ? Color.accentColor : Color.gray)
- .onTapGesture {
- bookmark.autoconnect.toggle()
- try? bookmark.modelContext?.save()
- }
- Image("Server")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16, alignment: .center)
- Text(bookmark.name).lineLimit(1).truncationMode(.tail)
- Spacer(minLength: 0)
- case .temporary:
- Spacer()
- .frame(width: 14 + 8 + 16)
- Image("Server")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16, alignment: .center)
- Text(bookmark.name).lineLimit(1).truncationMode(.tail)
- if let serverDescription = bookmark.serverDescription {
- Text(serverDescription)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.tail)
- }
- Spacer(minLength: 0)
- if let serverUserCount = bookmark.serverUserCount,
- serverUserCount > 0
- {
- Text(String(serverUserCount))
- .foregroundStyle(.secondary)
- .lineLimit(1)
-
- Circle()
- .fill(.fileComplete)
- .frame(width: 7, height: 7)
- .opacity(onlineAnimationMaxState ? 1.0 : 0.2)
- .onAppear {
- withAnimation(.easeInOut(duration: 1.0).repeatForever()) {
- onlineAnimationMaxState.toggle()
- }
- }
- .padding(.trailing, 6)
- }
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .onChange(of: bookmark.expanded) {
- guard bookmark.type == .tracker else {
- return
- }
-
- if bookmark.expanded {
- Task {
- await bookmark.fetchServers()
- }
- }
- }
- }
-}
-
-#if DEBUG
- private struct TrackerViewPreview: View {
- @State var selection: Bookmark? = nil
-
- var body: some View {
- TrackerView(selection: $selection)
- }
- }
-
- #Preview {
- TrackerViewPreview()
- }
-#endif
diff --git a/Hotline/macOS/Trackers/BonjourServerRow.swift b/Hotline/macOS/Trackers/BonjourServerRow.swift
new file mode 100644
index 0000000..d99b23b
--- /dev/null
+++ b/Hotline/macOS/Trackers/BonjourServerRow.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+struct BonjourServerRow: View {
+ @Environment(\.openWindow) private var openWindow
+
+ let server: BonjourState.BonjourServer
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 6) {
+ Image("Server")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(self.server.displayName).lineLimit(1).truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Hotline/macOS/Trackers/ServerBookmarkSheet.swift b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift
new file mode 100644
index 0000000..6ad1657
--- /dev/null
+++ b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+
+struct ServerBookmarkSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var bookmark: Bookmark
+ @State private var serverName: String = ""
+ @State private var serverAddress: String = ""
+ @State private var serverLogin: String = ""
+ @State private var serverPassword: String = ""
+
+ init(_ editingBookmark: Bookmark) {
+ _bookmark = .init(initialValue: editingBookmark)
+ _serverName = .init(initialValue: editingBookmark.name)
+ _serverAddress = .init(initialValue: editingBookmark.displayAddress)
+ _serverLogin = .init(initialValue: editingBookmark.login ?? "")
+ _serverPassword = .init(initialValue: editingBookmark.password ?? "")
+ }
+
+ var body: some View {
+ Form {
+ Section {
+ TextField(text: $serverName) {
+ Text("Name")
+ }
+ }
+
+ Section {
+ TextField(text: $serverAddress) {
+ Text("Address")
+ }
+ TextField(text: $serverLogin, prompt: Text("Optional")) {
+ Text("Login")
+ }
+ SecureField(text: $serverPassword, prompt: Text("Optional")) {
+ Text("Password")
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 350)
+ .fixedSize(horizontal: true, vertical: true)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let (host, port) = Server.parseServerAddressAndPort(self.serverAddress)
+ let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ let password = self.serverPassword
+
+ if !displayName.isEmpty && !host.isEmpty {
+ self.bookmark.name = displayName
+ self.bookmark.address = host
+ self.bookmark.port = port
+ self.bookmark.login = login.isEmpty ? nil : login
+ self.bookmark.password = password.isEmpty ? nil : password
+
+ self.dismiss()
+ }
+ }
+ }
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.dismiss()
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift
new file mode 100644
index 0000000..93aea15
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct TrackerBookmarkServerView: View {
+ let server: BookmarkServer
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 6) {
+ Image("Server")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail)
+ if let serverDescription = self.server.description {
+ Text(serverDescription)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ Spacer(minLength: 0)
+ if self.server.users > 0 {
+ Text(String(self.server.users))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ Circle()
+ .fill(.fileComplete)
+ .frame(width: 7, height: 7)
+ .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in
+ content.opacity(opacity)
+ } keyframes: { _ in
+ CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second
+ CubicKeyframe(0.6, duration: 0.5) // Fade out quickly
+ CubicKeyframe(1.0, duration: 0.5) // Fade in quickly
+ }
+ .padding(.trailing, 6)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift
new file mode 100644
index 0000000..d66a466
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift
@@ -0,0 +1,121 @@
+import SwiftUI
+
+struct TrackerBookmarkSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var bookmark: Bookmark? = nil
+ @State private var trackerAddress: String = ""
+ @State private var trackerName: String = ""
+
+ init() {
+
+ }
+
+ init(_ editingBookmark: Bookmark) {
+ _bookmark = .init(initialValue: editingBookmark)
+ _trackerAddress = .init(initialValue: editingBookmark.displayAddress)
+ _trackerName = .init(initialValue: editingBookmark.name)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ Form {
+ if self.bookmark == nil {
+ HStack(alignment: .top, spacing: 10) {
+ GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0)
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading) {
+ Text("Add a Hotline Tracker")
+
+ Text("Enter the address and name of a Hotline Tracker you want to add.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ }
+ }
+ else {
+ HStack(alignment: .top, spacing: 10) {
+ GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0)
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading) {
+ Text("Edit Hotline Tracker")
+
+ Text("Change the address and name of your Hotline Tracker.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ }
+ }
+
+ Group {
+ TextField(text: $trackerAddress) {
+ Text("Address")
+ }
+ TextField(text: $trackerName, prompt: Text("Optional")) {
+ Text("Name")
+ }
+ }
+// .textFieldStyle(.roundedBorder)
+ .controlSize(.large)
+ }
+ .formStyle(.grouped)
+ }
+ .frame(width: 350)
+ .fixedSize(horizontal: true, vertical: true)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ self.saveTracker()
+ } label: {
+ if self.bookmark != nil {
+ Text("Save")
+ }
+ else {
+ Text("Add")
+ }
+ }
+ }
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.trackerName = ""
+ self.trackerAddress = ""
+
+ self.dismiss()
+ }
+ }
+ }
+ }
+
+ private func saveTracker() {
+ var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress)
+
+ if displayName.isEmpty {
+ displayName = host
+ }
+
+ if !displayName.isEmpty && !host.isEmpty {
+ if !host.isEmpty {
+ if self.bookmark != nil {
+ // We're editing an existing bookmark.
+ self.bookmark?.name = displayName
+ self.bookmark?.address = host
+ self.bookmark?.port = port
+ }
+ else {
+ // We're creating a new bookmark.
+ let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port)
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+
+ self.trackerName = ""
+ self.trackerAddress = ""
+
+ self.dismiss()
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift
new file mode 100644
index 0000000..37093d2
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerItemView.swift
@@ -0,0 +1,75 @@
+import SwiftUI
+
+struct TrackerItemView: View {
+ let bookmark: Bookmark
+ let isExpanded: Bool
+ let isLoading: Bool
+ let count: Int
+ let onToggleExpanded: () -> Void
+ @Environment(\.appearsActive) private var appearsActive
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 6) {
+ if bookmark.type == .tracker {
+ Button {
+ self.onToggleExpanded()
+ } label: {
+ Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right"))
+ .bold()
+ .font(.system(size: 10))
+ .opacity(0.5)
+ .frame(alignment: .center)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 2)
+ }
+
+ switch bookmark.type {
+ case .tracker:
+ Image("Tracker")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail)
+ if isLoading {
+ ProgressView()
+ .padding([.leading, .trailing], 2)
+ .controlSize(.mini)
+ }
+ Spacer(minLength: 0)
+ if isExpanded && count > 0 {
+ HStack(spacing: 4) {
+ Text(String(count))
+
+ SpinningGlobeView()
+ .fontWeight(.semibold)
+ .frame(width: 12, height: 12)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .foregroundStyle(.secondary)
+// .background(.quinary)
+ .clipShape(.capsule)
+ }
+ case .server:
+ Image(systemName: "bookmark.fill")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(Color.secondary)
+ .frame(width: 11, height: 11, alignment: .center)
+ .opacity(0.75)
+ .padding(.leading, 3)
+ .padding(.trailing, 2)
+ Image("Server")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16, alignment: .center)
+ Text(bookmark.name).lineLimit(1).truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift
new file mode 100644
index 0000000..e0ca87d
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerView.swift
@@ -0,0 +1,623 @@
+import SwiftUI
+import SwiftData
+import Foundation
+import UniformTypeIdentifiers
+
+enum TrackerSelection: Hashable {
+ case bookmark(Bookmark)
+ case bookmarkServer(BookmarkServer)
+ case bonjourGroup
+ case bonjourServer(BonjourState.BonjourServer)
+
+ var server: Server? {
+ switch self {
+ case .bookmark(let b): b.server
+ case .bookmarkServer(let t): t.server
+ case .bonjourGroup: nil
+ case .bonjourServer(let b): b.server
+ }
+ }
+}
+
+struct TrackerView: View {
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.openWindow) private var openWindow
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var refreshing = false
+ @State private var trackerSheetPresented: Bool = false
+ @State private var trackerSheetBookmark: Bookmark? = nil
+ @State private var serverSheetBookmark: Bookmark? = nil
+ @State private var attemptedPrepopulate: Bool = false
+ @State private var fileDropActive = false
+ @State private var bookmarkExportActive = false
+ @State private var bookmarkExport: BookmarkDocument? = nil
+ @State private var expandedTrackers: Set<Bookmark> = []
+ @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:]
+ @State private var loadingTrackers: Set<Bookmark> = []
+ @State private var fetchTasks: [Bookmark: Task<Void, Never>] = [:]
+ @State private var searchText: String = ""
+ @State private var isSearching = false
+
+ @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark]
+ @Binding var selection: TrackerSelection?
+
+ private var filteredBookmarks: [Bookmark] {
+ guard !self.searchText.isEmpty else {
+ return self.bookmarks
+ }
+
+ let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init)
+
+ return self.bookmarks.filter { bookmark in
+ // Always show tracker bookmarks (filter only their servers)
+ if bookmark.type == .tracker {
+ return true
+ }
+
+ // Filter server bookmarks by search text
+ return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords)
+ }
+ }
+
+ private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool {
+ let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased()
+
+ // All search words must match
+ return searchWords.allSatisfy { word in
+ searchableText.contains(word)
+ }
+ }
+
+ private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] {
+ let servers = self.trackerServers[bookmark] ?? []
+ print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers")
+
+ guard !self.searchText.isEmpty else {
+ return servers
+ }
+
+ let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init)
+
+ return servers.filter { server in
+ let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased()
+
+ // All search words must match
+ return searchWords.allSatisfy { word in
+ searchableText.contains(word)
+ }
+ }
+ }
+
+ var bonjourRowView: some View {
+ HStack(alignment: .center, spacing: 6) {
+ Button {
+ AppState.shared.bonjourState.isExpanded.toggle()
+ } label: {
+ Text(Image(systemName: AppState.shared.bonjourState.isExpanded ? "chevron.down" : "chevron.right"))
+ .bold()
+ .font(.system(size: 10))
+ .opacity(0.5)
+ .frame(alignment: .center)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 10)
+ .padding(.leading, 4)
+ .padding(.trailing, 2)
+
+ Image(systemName: "bonjour")
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.multicolor)
+ .frame(width: 16, height: 16, alignment: .center)
+ Text("Bonjour").bold().lineLimit(1).truncationMode(.tail)
+
+ if AppState.shared.bonjourState.isBrowsing {
+ ProgressView()
+ .controlSize(.mini)
+ }
+
+ Spacer(minLength: 0)
+
+ if AppState.shared.bonjourState.isExpanded && !AppState.shared.bonjourState.discoveredServers.isEmpty {
+ HStack(spacing: 4) {
+ Text(String(AppState.shared.bonjourState.discoveredServers.count))
+
+ SpinningGlobeView()
+ .fontWeight(.semibold)
+ .frame(width: 12, height: 12)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .foregroundStyle(.secondary)
+// .background(.quinary)
+ .clipShape(.capsule)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onChange(of: AppState.shared.bonjourState.isExpanded) { oldState, newState in
+ if newState {
+ AppState.shared.bonjourState.startBrowsing()
+ }
+ else {
+ AppState.shared.bonjourState.stopBrowsing()
+ }
+ }
+ }
+
+ var body: some View {
+ List(selection: $selection) {
+ ForEach(filteredBookmarks, id: \.self) { bookmark in
+ TrackerItemView(
+ bookmark: bookmark,
+ isExpanded: self.expandedTrackers.contains(bookmark),
+ isLoading: self.loadingTrackers.contains(bookmark),
+ count: self.trackerServers[bookmark]?.count ?? 0
+ ) {
+ self.toggleExpanded(for: bookmark)
+ }
+ .tag(TrackerSelection.bookmark(bookmark))
+
+ if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) {
+ ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in
+ TrackerBookmarkServerView(server: trackedServer)
+ .moveDisabled(true)
+ .deleteDisabled(true)
+ .tag(TrackerSelection.bookmarkServer(trackedServer))
+ .padding(.leading, 16 + 8 + 10)
+ }
+ }
+ }
+ .onMove { movedIndexes, destinationIndex in
+ Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext)
+ }
+ .onDelete { deletedIndexes in
+ Bookmark.delete(at: deletedIndexes, context: modelContext)
+ }
+
+ self.bonjourRowView
+ .tag(TrackerSelection.bonjourGroup)
+
+ if AppState.shared.bonjourState.isExpanded {
+ ForEach(AppState.shared.bonjourState.discoveredServers, id: \.self) { record in
+ BonjourServerRow(server: record)
+ .tag(TrackerSelection.bonjourServer(record))
+ .padding(.leading, 16 + 8 + 10)
+ }
+ }
+ }
+ .onDeleteCommand {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ Bookmark.delete(bookmark, context: modelContext)
+ default:
+ break
+ }
+
+// if let bookmark = selection,
+// bookmark.type != .temporary {
+// Bookmark.delete(bookmark, context: modelContext)
+// }
+ }
+ .environment(\.defaultMinListRowHeight, 34)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .onChange(of: AppState.shared.cloudKitReady) {
+ if attemptedPrepopulate {
+ print("Tracker: Already attempted to prepopulate bookmarks")
+ return
+ }
+
+ print("Tracker: Prepopulating bookmarks")
+
+ attemptedPrepopulate = true
+
+ // Make sure default bookmarks are there when empty.
+ Bookmark.populateDefaults(context: modelContext)
+ }
+ .onAppear {
+// Bookmark.deleteAll(context: modelContext)
+ }
+ .contextMenu(forSelectionType: TrackerSelection.self) { items in
+ if let item = items.first {
+ switch item {
+ case .bookmark(let bookmark):
+ self.bookmarkContextMenu(bookmark)
+ case .bookmarkServer(let server):
+ self.bookmarkServerContextMenu(server)
+ case .bonjourGroup:
+ EmptyView()
+ case .bonjourServer(let bonjourServer):
+ self.bonjourServerContextMenu(bonjourServer)
+ }
+ }
+ } primaryAction: { items in
+ guard let clickedItem = items.first else {
+ return
+ }
+
+ switch clickedItem {
+ case .bookmark(let bookmark):
+ if bookmark.type == .server {
+ if let s = bookmark.server {
+ openWindow(id: "server", value: s)
+ }
+ }
+ else if bookmark.type == .tracker {
+ if NSEvent.modifierFlags.contains(.option) {
+ trackerSheetBookmark = bookmark
+ }
+ else {
+ self.toggleExpanded(for: bookmark)
+ }
+ }
+
+ case .bookmarkServer(let bookmarkServer):
+ openWindow(id: "server", value: bookmarkServer.server)
+
+ case .bonjourGroup:
+ AppState.shared.bonjourState.isExpanded.toggle()
+
+ case .bonjourServer(let bonjourServer):
+ if let server = bonjourServer.server {
+ openWindow(id: "server", value: server)
+ }
+
+ }
+ }
+ .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in
+ switch result {
+ case .success(let fileURL):
+ print("Hotline Bookmark: Successfully exported:", fileURL)
+ case .failure(let err):
+ print("Hotline Bookmark: Failed to export:", err)
+ }
+
+ bookmarkExport = nil
+ bookmarkExportActive = false
+ }, onCancellation: {})
+ .onKeyPress(.rightArrow) {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ self.setExpanded(true, for: bookmark)
+ return .handled
+ }
+ default:
+ break
+ }
+
+ return .ignored
+ }
+ .onKeyPress(.leftArrow) {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ self.setExpanded(false, for: bookmark)
+ return .handled
+ }
+ default:
+ break
+ }
+
+ return .ignored
+ }
+ .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in
+ for provider in providers {
+ let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in
+ // HOTLINE CREATOR CODE: 1213484099
+ // HOTLINE BOOKMARK TYPE CODE: 1213489773
+
+ if let filePathData = dataRepresentation,
+ let filePath = String(data: filePathData, encoding: .utf8),
+ let fileURL = URL(string: filePath) {
+
+ print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false))
+
+ DispatchQueue.main.async {
+ if let newBookmark = Bookmark(fileURL: fileURL) {
+ print("Hotline Bookmark: Added bookmark.")
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+ else {
+ print("Hotline Bookmark: Failed to parse.")
+ }
+ }
+ }
+ }
+ }
+
+ return true
+ }
+ .sheet(item: $trackerSheetBookmark) { item in
+ TrackerBookmarkSheet(item)
+ }
+ .sheet(isPresented: $trackerSheetPresented) {
+ TrackerBookmarkSheet()
+ }
+ .sheet(item: $serverSheetBookmark) { item in
+ ServerBookmarkSheet(item)
+ }
+ .navigationTitle("Servers")
+ .toolbar {
+ if #available(macOS 26.0, *) {
+ ToolbarItem(placement: .navigation) {
+ self.hotlineLogoImage
+ }
+ .sharedBackgroundVisibility(.hidden)
+ }
+ else {
+ ToolbarItem(placement: .navigation) {
+ self.hotlineLogoImage
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ self.refreshing = true
+ self.refresh()
+ self.refreshing = false
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .disabled(refreshing)
+ .help("Refresh Trackers")
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ trackerSheetPresented = true
+ } label: {
+ Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted")
+ }
+ .help("Add Tracker")
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ openWindow(id: "server")
+ } label: {
+ Label("Connect to Server", systemImage: "globe.americas.fill")
+ }
+ .help("Connect to Server")
+ }
+ }
+ .onOpenURL(perform: { url in
+ if let s = Server(url: url) {
+ openWindow(id: "server", value: s)
+ }
+ })
+ .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
+ }
+
+ private var hotlineLogoImage: some View {
+ Image("Hotline")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFit()
+ .foregroundColor(Color(hex: 0xE10000))
+ .frame(width: 9)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
+
+ @ViewBuilder
+ func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View {
+ Button {
+ NSPasteboard.general.clearContents()
+ let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)"
+ NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string)
+ } label: {
+ Label("Copy Link", systemImage: "link")
+ }
+
+ Button {
+ NSPasteboard.general.clearContents()
+ let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)"
+ NSPasteboard.general.setString(displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+
+ Divider()
+
+ Button {
+ let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil)
+ Bookmark.add(newBookmark, context: modelContext)
+ } label: {
+ Label("Bookmark", systemImage: "bookmark")
+ }
+ }
+
+ @ViewBuilder
+ func bookmarkContextMenu(_ bookmark: Bookmark) -> some View {
+ Button {
+ let linkString: String = switch bookmark.type {
+ case .tracker: "hotlinetracker://\(bookmark.displayAddress)"
+ case .server: "hotline://\(bookmark.displayAddress)"
+ }
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(linkString, forType: .string)
+ } label: {
+ Label("Copy Link", systemImage: "link")
+ }
+
+ Button {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(bookmark.displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+
+ Divider()
+
+ if bookmark.type == .tracker {
+ Button {
+ trackerSheetBookmark = bookmark
+ } label: {
+ Label("Edit Tracker...", systemImage: "pencil")
+ }
+ }
+
+ if bookmark.type == .server {
+ Button {
+ serverSheetBookmark = bookmark
+ } label: {
+ Label("Edit Bookmark...", systemImage: "pencil")
+ }
+
+ Button {
+ bookmarkExport = BookmarkDocument(bookmark: bookmark)
+ bookmarkExportActive = true
+ } label: {
+ Label("Export Bookmark...", systemImage: "square.and.arrow.down")
+ }
+ }
+
+ Divider()
+
+ Button {
+ Bookmark.delete(bookmark, context: modelContext)
+ } label: {
+ if bookmark.type == .tracker {
+ Label("Delete Tracker", systemImage: "xmark")
+ }
+ else {
+ Label("Delete Bookmark", systemImage: "bookmark.slash")
+ }
+ }
+ }
+
+ @ViewBuilder
+ func bonjourServerContextMenu(_ bonjourServer: BonjourState.BonjourServer) -> some View {
+ Button {
+ guard let server = bonjourServer.server else {
+ return
+ }
+ NSPasteboard.general.clearContents()
+ let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)"
+ NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string)
+ } label: {
+ Label("Copy Link", systemImage: "link")
+ }
+
+ Button {
+ guard let server = bonjourServer.server else {
+ return
+ }
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(server.displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+
+ Divider()
+
+ Button {
+ guard let server = bonjourServer.server else {
+ return
+ }
+ let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil)
+ Bookmark.add(newBookmark, context: modelContext)
+ } label: {
+ Label("Bookmark", systemImage: "bookmark")
+ }
+ }
+
+ func refresh() {
+ // When a tracker is selected, refresh only that tracker.
+ if let trackerSelection = self.selection {
+ switch trackerSelection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ if self.expandedTrackers.contains(bookmark) {
+ // Already expanded, cancel old fetch and start new one
+ self.fetchTasks[bookmark]?.cancel()
+ let task = Task {
+ await self.fetchServers(for: bookmark)
+ }
+ self.fetchTasks[bookmark] = task
+ } else {
+ // Not expanded, expand it (which also fetches)
+ self.setExpanded(true, for: bookmark)
+ }
+ return
+ }
+ break
+ default:
+ break
+ }
+ }
+
+ // Otherwise refresh/expand all trackers.
+ for bookmark in self.bookmarks {
+ if bookmark.type == .tracker {
+ if self.expandedTrackers.contains(bookmark) {
+ // Already expanded, cancel old fetch and start new one
+ self.fetchTasks[bookmark]?.cancel()
+ let task = Task {
+ await self.fetchServers(for: bookmark)
+ }
+ self.fetchTasks[bookmark] = task
+ } else {
+ // Not expanded, expand it (which also fetches)
+ self.setExpanded(true, for: bookmark)
+ }
+ }
+ }
+ }
+
+ func toggleExpanded(for bookmark: Bookmark) {
+ guard bookmark.type == .tracker else { return }
+ self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark)
+ }
+
+ func setExpanded(_ expanded: Bool, for bookmark: Bookmark) {
+ guard bookmark.type == .tracker else { return }
+
+ if expanded && !self.expandedTrackers.contains(bookmark) {
+ self.expandedTrackers.insert(bookmark)
+ let task = Task {
+ await self.fetchServers(for: bookmark)
+ }
+ self.fetchTasks[bookmark] = task
+ } else if !expanded && self.expandedTrackers.contains(bookmark) {
+ // Cancel ongoing fetch and clear data
+ self.fetchTasks[bookmark]?.cancel()
+ self.fetchTasks[bookmark] = nil
+ self.expandedTrackers.remove(bookmark)
+ self.trackerServers[bookmark] = nil
+ self.loadingTrackers.remove(bookmark)
+ }
+ }
+
+ private func fetchServers(for bookmark: Bookmark) async {
+ print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)")
+ self.loadingTrackers.insert(bookmark)
+ let servers = await bookmark.fetchServers()
+ print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()")
+ await MainActor.run {
+ print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]")
+ self.trackerServers[bookmark] = servers
+ self.loadingTrackers.remove(bookmark)
+ self.fetchTasks[bookmark] = nil // Clean up completed task
+ print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries")
+ print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers")
+ }
+ }
+}
+
+#if DEBUG
+private struct TrackerViewPreview: View {
+ @State var selection: TrackerSelection? = nil
+
+ var body: some View {
+ TrackerView(selection: $selection)
+ }
+}
+
+#Preview {
+ TrackerViewPreview()
+}
+#endif
diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift
new file mode 100644
index 0000000..1fefe5a
--- /dev/null
+++ b/Hotline/macOS/TransfersView.swift
@@ -0,0 +1,411 @@
+import SwiftUI
+
+struct TransfersView: View {
+ @Environment(\.appState) private var appState
+
+ @State private var selectedTransfers = Set<TransferInfo>()
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if self.appState.transfers.isEmpty {
+ self.emptyState
+ } else {
+ self.transfersList
+ }
+ }
+ .frame(minWidth: 500, minHeight: 200)
+ .navigationTitle("Transfers")
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ if self.selectedTransfers.isEmpty {
+ NSWorkspace.shared.open(URL.downloadsDirectory)
+ }
+ else {
+ let fileURLs = self.selectedTransfers.compactMap(\.fileURL)
+ if !fileURLs.isEmpty {
+ NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
+ }
+ }
+ } label: {
+ Label("Show Downloads", systemImage: "folder")
+ }
+ .help("Show Downloads")
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ for transfer in self.selectedTransfers {
+ self.appState.cancelTransfer(id: transfer.id)
+ }
+ self.selectedTransfers = []
+ } label: {
+ Label(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers", systemImage: "xmark")
+ }
+ .disabled(self.selectedTransfers.isEmpty)
+ .help(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers")
+ }
+ }
+ }
+
+ // MARK: - Empty State
+
+ private var emptyState: some View {
+ ContentUnavailableView {
+ Label("No Transfers", systemImage: "arrow.up.arrow.down")
+ } description: {
+ Text("Your Hotline file transfers will appear here")
+ }
+ }
+
+ // MARK: - Transfers List
+
+ private var transfersList: some View {
+ List(selection: self.$selectedTransfers) {
+ ForEach(self.appState.transfers) { transfer in
+ TransferRow(transfer: transfer)
+ .id(transfer)
+ }
+ }
+ .listStyle(.inset)
+ .environment(\.defaultMinListRowHeight, 56)
+ .contextMenu(forSelectionType: TransferInfo.self) { items in
+ self.contextMenuForItems(items)
+ } primaryAction: { items in
+ self.performPrimaryAction(for: items)
+ }
+ }
+
+ // MARK: - Double Click
+
+ private func performPrimaryAction(for items: Set<TransferInfo>) {
+ if let fileURL = items.first?.fileURL {
+ NSWorkspace.shared.open(fileURL)
+ }
+ }
+
+ // MARK: - Context Menu
+
+ @ViewBuilder
+ private func contextMenuForItems(_ items: Set<TransferInfo>) -> some View {
+ if items.allSatisfy(\.completed) {
+ let fileURLs: [URL] = items.compactMap(\.fileURL)
+
+ Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ self.selectedTransfers = []
+ }
+
+ Divider()
+
+ Button("Open", systemImage: "arrow.up.right.square") {
+ for fileURL in fileURLs {
+ NSWorkspace.shared.open(fileURL)
+ }
+ }
+
+ self.openWithMenu(for: fileURLs)
+
+ Button("Show in Finder", systemImage: "finder") {
+ NSWorkspace.shared.activateFileViewerSelecting(fileURLs)
+ }
+
+ Divider()
+
+ Button("Move to Trash", systemImage: "trash") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ NSWorkspace.shared.recycle(fileURLs)
+ self.selectedTransfers = []
+ }
+ } else {
+ Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+ self.selectedTransfers = []
+ }
+
+ Divider()
+
+ Button("Move to Trash", systemImage: "trash") {
+ self.appState.cancelTransfers(ids: items.map(\.id))
+
+ let fileURLs: [URL] = items.compactMap(\.fileURL)
+ if !fileURLs.isEmpty {
+ NSWorkspace.shared.recycle(fileURLs)
+ }
+
+ self.selectedTransfers = []
+ }
+ }
+ }
+
+ private func getOpenWithApps(for fileURLs: [URL], defaultAppURL: URL? = nil) -> [(name: String, url: URL)] {
+ // If no files provided, there is no common app to open them
+ guard !fileURLs.isEmpty else { return [] }
+
+ // Build a list of app URL sets for each file URL
+ let appSets: [Set<URL>] = fileURLs.map { url in
+ let apps = NSWorkspace.shared.urlsForApplications(toOpen: url)
+ return Set(apps)
+ }
+
+ // Compute the intersection across all file URL app sets
+ guard var intersection = appSets.first else { return [] }
+ for set in appSets.dropFirst() {
+ intersection.formIntersection(set)
+ }
+
+ // Optionally remove the default app from the list
+ if let defaultAppURL {
+ intersection.remove(defaultAppURL)
+ }
+
+ // Map to display names and sort by name
+ let result: [(name: String, url: URL)] = intersection.compactMap { url in
+ let appName = FileManager.default
+ .displayName(atPath: url.path)
+ .replacing(".app", with: "")
+ return (name: appName, url: url)
+ }.sorted { $0.name < $1.name }
+
+ return result
+ }
+
+ private func getDefaultApp(for fileURLs: [URL]) -> (name: String, url: URL)? {
+ // No files -> no default app
+ guard !fileURLs.isEmpty else { return nil }
+
+ // Single file: use the system default directly
+ if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) {
+ let name = FileManager.default
+ .displayName(atPath: url.path)
+ .replacing(".app", with: "")
+ return (name, url)
+ }
+
+ // Build the intersection of apps that can open ALL files
+ let appSets: [Set<URL>] = fileURLs.map { url in
+ Set(NSWorkspace.shared.urlsForApplications(toOpen: url))
+ }
+ guard var intersection = appSets.first else { return nil }
+ for set in appSets.dropFirst() {
+ intersection.formIntersection(set)
+ if intersection.isEmpty { return nil }
+ }
+
+ // Tally the system default app for each file
+ var defaultCounts: [URL: Int] = [:]
+ for fileURL in fileURLs {
+ if let def = NSWorkspace.shared.urlForApplication(toOpen: fileURL) {
+ defaultCounts[def, default: 0] += 1
+ }
+ }
+
+ // Prefer the app that's the default for the majority of files, provided it can open all
+ if let bestByMajority = intersection.max(by: { (a, b) -> Bool in
+ let ca = defaultCounts[a, default: 0]
+ let cb = defaultCounts[b, default: 0]
+ if ca == cb {
+ // Tie-breaker deferred to later
+ return false
+ }
+ return ca < cb
+ }),
+ defaultCounts[bestByMajority, default: 0] > 0 {
+ let name = FileManager.default
+ .displayName(atPath: bestByMajority.path)
+ .replacing(".app", with: "")
+ return (name, bestByMajority)
+ }
+
+ return nil
+ }
+
+ private func openWithMenu(for fileURLs: [URL]) -> some View {
+ Menu("Open With") {
+ let defaultApp: (name: String, url: URL)? = self.getDefaultApp(for: fileURLs)
+ let apps: [(name: String, url: URL)] = self.getOpenWithApps(for: fileURLs, defaultAppURL: defaultApp?.url)
+
+ if let defaultApp {
+ Button {
+ NSWorkspace.shared.open(fileURLs, withApplicationAt: defaultApp.url, configuration: NSWorkspace.OpenConfiguration())
+ } label: {
+ Label {
+ Text(defaultApp.name)
+ } icon: {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: defaultApp.url.path))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+
+ if !apps.isEmpty {
+ Divider()
+ }
+ }
+
+ if !apps.isEmpty {
+ ForEach(apps, id: \.url) { app in
+ Button {
+ NSWorkspace.shared.open(fileURLs, withApplicationAt: app.url, configuration: NSWorkspace.OpenConfiguration())
+ } label: {
+ Label {
+ Text(app.name)
+ } icon: {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+ }
+ }
+ }
+ }
+
+}
+
+// MARK: - Transfer Row
+
+struct TransferRow: View {
+ @Environment(\.appState) private var appState
+
+ @Bindable var transfer: TransferInfo
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ if self.transfer.isFolder {
+ self.folderIconView
+ }
+ else {
+ self.fileIconView
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(self.transfer.title)
+ .font(.headline)
+ .lineLimit(1)
+ .truncationMode(.tail)
+
+ Spacer()
+
+ if !self.transfer.done {
+ self.statsView
+ }
+ }
+
+ // Progress bar and status
+ if self.transfer.cancelled {
+ Text("Cancelled")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ else if self.transfer.failed {
+ Text("Failed")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ else if self.transfer.completed {
+ Text("Downloaded")
+ .font(.subheadline)
+ .foregroundStyle(.fileComplete)
+ }
+ else {
+ if self.transfer.progress == 0 {
+ ProgressView()
+ .progressViewStyle(.linear)
+ .controlSize(.large)
+ }
+ else {
+ ProgressView(value: self.transfer.progress, total: 1.0)
+ .progressViewStyle(.linear)
+ .controlSize(.large)
+ }
+
+ }
+ }
+ }
+ }
+
+ // MARK: -
+
+ private var statsView: some View {
+ HStack(spacing: 8) {
+ // Progress percentage
+ // Text("\(Int(self.transfer.progress * 100))%")
+
+ // Speed
+ if let speed = self.transfer.displaySpeed {
+ Label(speed, systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down")
+ }
+
+ // Time remaining
+ if let timeRemaining = self.transfer.displayTimeRemaining {
+ Label(timeRemaining, systemImage: "clock")
+ }
+
+ // File size
+ Label(self.transfer.displaySize, systemImage: "document")
+ }
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
+
+ private var folderIconView: some View {
+ FolderIconView()
+ .frame(width: 32, height: 32)
+ .overlay(alignment: .bottomTrailing) {
+ if self.transfer.cancelled || self.transfer.failed {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .symbolRenderingMode(.multicolor)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else if self.transfer.completed {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .fileComplete)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else {
+ FileIconView(filename: self.transfer.title, fileType: nil)
+ .frame(width: 16, height: 16)
+ }
+ }
+ }
+
+ private var fileIconView: some View {
+ FileIconView(filename: self.transfer.title, fileType: nil)
+ .frame(width: 32, height: 32)
+ .overlay(alignment: .bottomTrailing) {
+ if self.transfer.cancelled || self.transfer.failed {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .resizable()
+ .symbolRenderingMode(.multicolor)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ else if self.transfer.completed {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(.white, .fileComplete)
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ }
+ }
+ }
+}
+
+// MARK: - Preview
+
+#Preview {
+ TransfersView()
+ .environment(AppState.shared)
+}
+
diff --git a/Hotline/macOS/UserClientInfoSheet.swift b/Hotline/macOS/UserClientInfoSheet.swift
new file mode 100644
index 0000000..8fa4db8
--- /dev/null
+++ b/Hotline/macOS/UserClientInfoSheet.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+struct UserClientInfoSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let info: HotlineUserClientInfo
+
+ var body: some View {
+ ScrollView(.vertical) {
+ Text(self.info.details)
+ .fontDesign(.monospaced)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .padding()
+ }
+ .frame(width: 350, height: 400)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("OK") {
+ self.dismiss()
+ }
+ }
+ }
+ }
+}