Skip to content

Commit 089b3ec

Browse files
committed
Basic websocket and ws toast functionality
1 parent e4347b1 commit 089b3ec

File tree

8 files changed

+483
-12
lines changed

8 files changed

+483
-12
lines changed

Django Files.xcodeproj/project.pbxproj

+3-2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
API/Short.swift,
8888
API/Stats.swift,
8989
API/Upload.swift,
90+
API/Websocket.swift,
9091
Models/DjangoFilesSession.swift,
9192
);
9293
target = 4C82CB7E2D624E8700C0893B /* UploadAndCopy */;
@@ -448,7 +449,7 @@
448449
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This lets you save or upload photos to your Django Files";
449450
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
450451
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
451-
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
452+
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
452453
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
453454
LD_RUNPATH_SEARCH_PATHS = (
454455
"$(inherited)",
@@ -494,7 +495,7 @@
494495
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This lets you save or upload photos to your Django Files";
495496
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
496497
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
497-
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
498+
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
498499
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
499500
LD_RUNPATH_SEARCH_PATHS = (
500501
"$(inherited)",

Django Files/API/DFAPI.swift

+30
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
import Foundation
99
import HTTPTypes
1010
import HTTPTypesFoundation
11+
import UIKit
12+
13+
// Custom imports
14+
import SwiftUI // Needed for ToastManager
1115

1216
// Add an import for the models file
1317
// This line should be modified if the module structure is different
@@ -16,6 +20,9 @@ import HTTPTypesFoundation
1620
struct DFAPI {
1721
private static let API_PATH = "/api/"
1822

23+
// Add a shared WebSocket instance
24+
private static var sharedWebSocket: DFWebSocket?
25+
1926
enum DjangoFilesAPIs: String {
2027
case stats = "stats/"
2128
case upload = "upload/"
@@ -373,6 +380,29 @@ struct DFAPI {
373380
}
374381
return nil
375382
}
383+
384+
// Create and connect to a WebSocket, also setting up WebSocketToastObserver
385+
public func connectToWebSocket() -> DFWebSocket {
386+
let webSocket = self.createWebSocket()
387+
388+
// Instead of directly accessing WebSocketToastObserver, post a notification
389+
// that the observer will pick up
390+
NotificationCenter.default.post(
391+
name: Notification.Name("DFWebSocketConnectionRequest"),
392+
object: nil,
393+
userInfo: ["api": self]
394+
)
395+
396+
// Store as the shared instance
397+
DFAPI.sharedWebSocket = webSocket
398+
399+
return webSocket
400+
}
401+
402+
// Get the shared WebSocket or create a new one if none exists
403+
public static func getSharedWebSocket() -> DFWebSocket? {
404+
return sharedWebSocket
405+
}
376406
}
377407

378408
class DjangoFilesUploadDelegate: NSObject, StreamDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate, URLSessionStreamDelegate{

Django Files/API/Websocket.swift

+317
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
//
2+
// Websocket.swift
3+
// Django Files
4+
//
5+
// Created by Ralph Luaces on 5/1/25.
6+
//
7+
8+
import Foundation
9+
import Combine
10+
11+
protocol DFWebSocketDelegate: AnyObject {
12+
func webSocketDidConnect(_ webSocket: DFWebSocket)
13+
func webSocketDidDisconnect(_ webSocket: DFWebSocket, withError error: Error?)
14+
func webSocket(_ webSocket: DFWebSocket, didReceiveMessage data: DFWebSocketMessage)
15+
}
16+
17+
// Message types that can be received from the server
18+
struct DFWebSocketMessage: Codable {
19+
let event: String
20+
let message: String?
21+
let bsClass: String?
22+
let delay: String?
23+
let id: Int?
24+
let name: String?
25+
let user: Int?
26+
let expr: String?
27+
let `private`: Bool?
28+
let password: String?
29+
let old_name: String?
30+
let objects: [DFWebSocketObject]?
31+
}
32+
33+
struct DFWebSocketObject: Codable {
34+
let id: Int
35+
let name: String
36+
let expr: String?
37+
let `private`: Bool?
38+
}
39+
40+
class DFWebSocket: NSObject {
41+
private var webSocketTask: URLSessionWebSocketTask?
42+
private var pingTimer: Timer?
43+
private var reconnectTimer: Timer?
44+
private var session: URLSession!
45+
46+
private var isConnected = false
47+
private var isReconnecting = false
48+
49+
// URL components for the WebSocket connection
50+
private let server: URL
51+
private let token: String
52+
53+
weak var delegate: DFWebSocketDelegate?
54+
55+
init(server: URL, token: String) {
56+
self.server = server
57+
self.token = token
58+
super.init()
59+
60+
// Create a URLSession with the delegate set to self
61+
let configuration = URLSessionConfiguration.default
62+
session = URLSession(configuration: configuration, delegate: nil, delegateQueue: .main)
63+
64+
// Connect to the WebSocket server
65+
connect()
66+
}
67+
68+
deinit {
69+
disconnect()
70+
}
71+
72+
// MARK: - Connection Management
73+
74+
func connect() {
75+
// Create the WebSocket URL
76+
var components = URLComponents(url: server, resolvingAgainstBaseURL: true)!
77+
78+
// Determine if we need wss or ws
79+
let isSecure = components.scheme == "https"
80+
components.scheme = isSecure ? "wss" : "ws"
81+
82+
// Set the path for the WebSocket
83+
components.path = "/ws/home/"
84+
85+
guard let url = components.url else {
86+
print("Invalid WebSocket URL")
87+
return
88+
}
89+
90+
print("WebSocket: Connecting to \(url.absoluteString)...")
91+
92+
// Create the WebSocket task
93+
var request = URLRequest(url: url)
94+
request.setValue(token, forHTTPHeaderField: "Authorization")
95+
96+
webSocketTask = session.webSocketTask(with: request)
97+
webSocketTask?.resume()
98+
99+
// Set up message receiving
100+
receiveMessage()
101+
102+
// Setup ping timer to keep connection alive
103+
setupPingTimer()
104+
105+
// Post a notification that we're attempting connection
106+
NotificationCenter.default.post(
107+
name: Notification.Name("DFWebSocketToastNotification"),
108+
object: nil,
109+
userInfo: ["message": "Connecting to WebSocket..."]
110+
)
111+
112+
print("WebSocket: Connection attempt started")
113+
}
114+
115+
func disconnect() {
116+
pingTimer?.invalidate()
117+
pingTimer = nil
118+
119+
reconnectTimer?.invalidate()
120+
reconnectTimer = nil
121+
122+
webSocketTask?.cancel(with: .normalClosure, reason: nil)
123+
webSocketTask = nil
124+
125+
isConnected = false
126+
}
127+
128+
private func reconnect() {
129+
guard !isReconnecting else { return }
130+
131+
isReconnecting = true
132+
print("WebSocket Disconnected! Reconnecting...")
133+
134+
// Clean up existing connection
135+
webSocketTask?.cancel(with: .normalClosure, reason: nil)
136+
webSocketTask = nil
137+
pingTimer?.invalidate()
138+
pingTimer = nil
139+
140+
// Schedule reconnection
141+
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
142+
guard let self = self else { return }
143+
self.isReconnecting = false
144+
self.connect()
145+
}
146+
}
147+
148+
private func setupPingTimer() {
149+
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
150+
self?.ping()
151+
}
152+
}
153+
154+
private func ping() {
155+
webSocketTask?.sendPing { [weak self] error in
156+
print("websocket ping")
157+
if let error = error {
158+
print("WebSocket ping error: \(error)")
159+
self?.reconnect()
160+
}
161+
}
162+
}
163+
164+
// MARK: - Message Handling
165+
166+
private func receiveMessage() {
167+
webSocketTask?.receive { [weak self] result in
168+
guard let self = self else { return }
169+
170+
switch result {
171+
case .success(let message):
172+
switch message {
173+
case .string(let text):
174+
self.handleMessage(text)
175+
case .data(let data):
176+
if let text = String(data: data, encoding: .utf8) {
177+
self.handleMessage(text)
178+
}
179+
@unknown default:
180+
break
181+
}
182+
183+
// Continue listening for more messages
184+
self.receiveMessage()
185+
186+
case .failure(let error):
187+
print("WebSocket receive error: \(error)")
188+
self.delegate?.webSocketDidDisconnect(self, withError: error)
189+
self.reconnect()
190+
}
191+
}
192+
}
193+
194+
private func handleMessage(_ messageText: String) {
195+
print("WebSocket message received: \(messageText)")
196+
197+
guard let data = messageText.data(using: .utf8) else { return }
198+
199+
do {
200+
let message = try JSONDecoder().decode(DFWebSocketMessage.self, from: data)
201+
202+
// Post a notification for toast messages if the event is appropriate
203+
if message.event == "toast" || message.event == "notification" {
204+
let userInfo: [String: Any] = ["message": message.message ?? "New notification"]
205+
NotificationCenter.default.post(
206+
name: Notification.Name("DFWebSocketToastNotification"),
207+
object: nil,
208+
userInfo: userInfo
209+
)
210+
} else if message.event == "file-new" {
211+
NotificationCenter.default.post(
212+
name: Notification.Name("DFWebSocketToastNotification"),
213+
object: nil,
214+
userInfo: ["message": "New file (\(message.name ?? "Untitled.file"))"]
215+
)
216+
} else if message.event == "file-delete" {
217+
NotificationCenter.default.post(
218+
name: Notification.Name("DFWebSocketToastNotification"),
219+
object: nil,
220+
userInfo: ["message": "File (\(message.name ?? "Untitled.file")) deleted."]
221+
)
222+
} else {
223+
// For debugging - post a notification for all message types
224+
print("WebSocket: Received message with event: \(message.event)")
225+
let displayText = "WebSocket: \(message.event) - \(message.message ?? "No message")"
226+
227+
// Post notification for all WebSocket events during debugging
228+
NotificationCenter.default.post(
229+
name: Notification.Name("DFWebSocketToastNotification"),
230+
object: nil,
231+
userInfo: ["message": displayText]
232+
)
233+
}
234+
235+
// Process the message
236+
DispatchQueue.main.async {
237+
self.delegate?.webSocket(self, didReceiveMessage: message)
238+
}
239+
} catch {
240+
print("Failed to decode WebSocket message: \(error)")
241+
242+
// Try to show the raw message as a toast for debugging
243+
NotificationCenter.default.post(
244+
name: Notification.Name("DFWebSocketToastNotification"),
245+
object: nil,
246+
userInfo: ["message": "Raw WebSocket message: \(messageText)"]
247+
)
248+
}
249+
}
250+
251+
// MARK: - Sending Messages
252+
253+
func send(message: String) {
254+
webSocketTask?.send(.string(message)) { error in
255+
if let error = error {
256+
print("WebSocket send error: \(error)")
257+
}
258+
}
259+
}
260+
261+
func send<T: Encodable>(object: T) {
262+
do {
263+
let data = try JSONEncoder().encode(object)
264+
if let json = String(data: data, encoding: .utf8) {
265+
send(message: json)
266+
}
267+
} catch {
268+
print("Failed to encode WebSocket message: \(error)")
269+
}
270+
}
271+
}
272+
273+
// MARK: - URLSessionWebSocketDelegate
274+
275+
extension DFWebSocket: URLSessionWebSocketDelegate {
276+
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
277+
print("WebSocket Connected.")
278+
isConnected = true
279+
280+
// Post a notification that connection was successful
281+
NotificationCenter.default.post(
282+
name: Notification.Name("DFWebSocketToastNotification"),
283+
object: nil,
284+
userInfo: ["message": "WebSocket Connected"]
285+
)
286+
287+
delegate?.webSocketDidConnect(self)
288+
}
289+
290+
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
291+
isConnected = false
292+
let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown reason"
293+
print("WebSocket Closed with code: \(closeCode), reason: \(reasonString)")
294+
295+
// Post a notification about the disconnection
296+
NotificationCenter.default.post(
297+
name: Notification.Name("DFWebSocketToastNotification"),
298+
object: nil,
299+
userInfo: ["message": "WebSocket Disconnected: \(closeCode)"]
300+
)
301+
302+
// Only trigger reconnect for abnormal closures
303+
if closeCode != .normalClosure && closeCode != .goingAway {
304+
delegate?.webSocketDidDisconnect(self, withError: nil)
305+
reconnect()
306+
}
307+
}
308+
}
309+
310+
// MARK: - Extension to DFAPI
311+
312+
extension DFAPI {
313+
func createWebSocket() -> DFWebSocket {
314+
return DFWebSocket(server: self.url, token: self.token)
315+
}
316+
}
317+

0 commit comments

Comments
 (0)