From dfba6aae5cf56d30efce2d4267d3276ced024cd6 Mon Sep 17 00:00:00 2001 From: gonzaloaune Date: Mon, 24 Aug 2026 11:52:11 +0000 Subject: [PATCH 1/4] Generated with Hive: Make status bar item creation idempotent to prevent tray icon disappearance on macOS --- com.stakwork.sphinx.desktop/AppDelegate.swift | 30 ++++-- .../StatusBarItemIdempotencyTests.swift | 101 ++++++++++++++++++ sphinx.xcodeproj/project.pbxproj | 4 + 3 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 com.stakwork.sphinx.desktopTests/StatusBarItemIdempotencyTests.swift diff --git a/com.stakwork.sphinx.desktop/AppDelegate.swift b/com.stakwork.sphinx.desktop/AppDelegate.swift index 7ee58787..c5f01d91 100644 --- a/com.stakwork.sphinx.desktop/AppDelegate.swift +++ b/com.stakwork.sphinx.desktop/AppDelegate.swift @@ -167,6 +167,12 @@ import SphinxErrorReporter func addStatusBarItem() { + // Create the status item exactly once for the app's lifetime. + // NSStatusItem is reassigned unconditionally on every window transition otherwise, + // which deallocates the prior item (ARC) and drops the tray icon from the menu bar. + // Do NOT reintroduce unconditional reassignment or nil this property on logout/re-lock. + guard statusBarItem == nil else { return } + let statusBar = NSStatusBar.system statusBarItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) statusBarItem.button?.image = NSImage(named: "extraIcon") @@ -174,17 +180,17 @@ import SphinxErrorReporter statusBarItem.button?.action = #selector(activateApp) if let button = statusBarItem?.button { - if let buttonFrame = button.superview?.frame { - - dragDropView = StatusBarButton(frame: buttonFrame) - dragDropView?.onDrop = { [weak self] urls, text in - self?.handleDrop(urls: urls, text: text) - } - - button.addSubview(dragDropView!) - dragDropView?.frame = button.bounds - dragDropView?.autoresizingMask = [.width, .height] + // Attach drag-and-drop unconditionally whenever button exists. + // The old guard on button.superview?.frame was never used (buttonFrame was discarded); + // it only prevented attachment if the superview wasn't laid out yet on the single + // creation invocation, which would permanently lose drag-and-drop under the new guard. + dragDropView = StatusBarButton(frame: button.bounds) + dragDropView?.onDrop = { [weak self] urls, text in + self?.handleDrop(urls: urls, text: text) } + button.addSubview(dragDropView!) + dragDropView?.frame = button.bounds + dragDropView?.autoresizingMask = [.width, .height] } setupMenu() @@ -525,7 +531,9 @@ import SphinxErrorReporter } func setBadge(count: Int) { - statusBarItem.button?.image = NSImage(named: count > 0 ? "extraIconBadge" : "extraIcon") + // Use optional chaining so an early badge update (before addStatusBarItem() runs) + // is a no-op instead of a crash via the force-unwrapped statusBarItem. + statusBarItem?.button?.image = NSImage(named: count > 0 ? "extraIconBadge" : "extraIcon") let title = count > 0 ? "\(count)" : "" NSApp.dockTile.badgeLabel = title diff --git a/com.stakwork.sphinx.desktopTests/StatusBarItemIdempotencyTests.swift b/com.stakwork.sphinx.desktopTests/StatusBarItemIdempotencyTests.swift new file mode 100644 index 00000000..994eab22 --- /dev/null +++ b/com.stakwork.sphinx.desktopTests/StatusBarItemIdempotencyTests.swift @@ -0,0 +1,101 @@ +// +// StatusBarItemIdempotencyTests.swift +// com.stakwork.sphinx.desktopTests +// +// Regression guard for the fix that makes addStatusBarItem() idempotent. +// +// Background: addStatusBarItem() used to unconditionally reassign +// `statusBarItem = statusBar.statusItem(withLength:)` on every call, which +// caused ARC to deallocate the previous NSStatusItem and drop the tray icon +// from the menu bar on every window transition (splash → PIN → dashboard, +// re-lock → login). The fix adds a `guard statusBarItem == nil else { return }` +// at the top of the function so the item is created exactly once per app +// lifetime. This test asserts that invariant holds. +// + +import XCTest +@testable import com_stakwork_sphinx_desktop + +final class StatusBarItemIdempotencyTests: XCTestCase { + + private var appDelegate: AppDelegate! + + override func setUp() { + super.setUp() + appDelegate = AppDelegate() + } + + override func tearDown() { + appDelegate = nil + super.tearDown() + } + + // MARK: - Idempotency + + /// Calling addStatusBarItem() twice must produce the same NSStatusItem + /// instance both times. If this test fails it means the guard was removed + /// or the property was reassigned before the second call. + func testAddStatusBarItem_IsIdempotent() { + appDelegate.addStatusBarItem() + let firstItem = appDelegate.statusBarItem + + appDelegate.addStatusBarItem() + let secondItem = appDelegate.statusBarItem + + XCTAssertNotNil(firstItem, "statusBarItem must be non-nil after the first call") + XCTAssertNotNil(secondItem, "statusBarItem must be non-nil after the second call") + + // ObjectIdentifier uniquely identifies the object instance in memory. + // If they differ, ARC created a second NSStatusItem and the tray icon + // would have vanished on the second call. + XCTAssertEqual( + ObjectIdentifier(firstItem!), + ObjectIdentifier(secondItem!), + "addStatusBarItem() must not replace an existing NSStatusItem: " + + "the same instance must be retained across multiple calls." + ) + } + + /// A third (or more) invocation must still return the original item, + /// covering the splash → PIN → dashboard three-call path. + func testAddStatusBarItem_IsIdempotentAcrossMultipleCalls() { + appDelegate.addStatusBarItem() + let originalId = ObjectIdentifier(appDelegate.statusBarItem!) + + for _ in 1...5 { + appDelegate.addStatusBarItem() + } + + XCTAssertEqual( + ObjectIdentifier(appDelegate.statusBarItem!), + originalId, + "statusBarItem must remain the same instance across all subsequent calls." + ) + } + + // MARK: - Badge safety (no crash before first creation) + + /// setBadge(count:) must not crash when called before addStatusBarItem() + /// has run. Prior to the hardening fix, the force-unwrapped statusBarItem + /// would trap in this scenario. + func testSetBadgeCount_BeforeStatusItemCreated_IsNoop() { + // statusBarItem is nil here (addStatusBarItem has not been called). + // This must not crash. + XCTAssertNoThrow(appDelegate.setBadge(count: 3), + "setBadge(count:) must not crash when statusBarItem is nil") + XCTAssertNoThrow(appDelegate.setBadge(count: 0), + "setBadge(count:) must not crash when statusBarItem is nil") + } + + /// setBadge(count:) must continue to work normally after the status item + /// has been created. + func testSetBadgeCount_AfterStatusItemCreated_DoesNotCrash() { + appDelegate.addStatusBarItem() + XCTAssertNotNil(appDelegate.statusBarItem) + + XCTAssertNoThrow(appDelegate.setBadge(count: 5), + "setBadge(count:) must not crash after statusBarItem is created") + XCTAssertNoThrow(appDelegate.setBadge(count: 0), + "setBadge(count:) must not crash after statusBarItem is created") + } +} diff --git a/sphinx.xcodeproj/project.pbxproj b/sphinx.xcodeproj/project.pbxproj index 81d90c6c..90e9c066 100644 --- a/sphinx.xcodeproj/project.pbxproj +++ b/sphinx.xcodeproj/project.pbxproj @@ -751,6 +751,7 @@ SOMCT0020000000001SOMCT02 /* SphinxOnionManagerConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SOMCT0010000000001SOMCT01 /* SphinxOnionManagerConnectionTests.swift */; }; SOMSG0020000000001SOMSG02 /* SphinxOnionManagerSeedGenerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SOMSG0010000000001SOMSG01 /* SphinxOnionManagerSeedGenerationTests.swift */; }; TRTTS0020000000001TRTTS02 /* TribeTimestampTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TRTTS0010000000001TRTTS01 /* TribeTimestampTests.swift */; }; + SBIDT0020000000001SBIDT02 /* StatusBarItemIdempotencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SBIDT0010000000001SBIDT01 /* StatusBarItemIdempotencyTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1846,6 +1847,7 @@ SOMCT0010000000001SOMCT01 /* SphinxOnionManagerConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SphinxOnionManagerConnectionTests.swift; sourceTree = ""; }; SOMSG0010000000001SOMSG01 /* SphinxOnionManagerSeedGenerationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SphinxOnionManagerSeedGenerationTests.swift; sourceTree = ""; }; TRTTS0010000000001TRTTS01 /* TribeTimestampTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TribeTimestampTests.swift; sourceTree = ""; }; + SBIDT0010000000001SBIDT01 /* StatusBarItemIdempotencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarItemIdempotencyTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2262,6 +2264,7 @@ TRTTS0010000000001TRTTS01 /* TribeTimestampTests.swift */, SOMSG0010000000001SOMSG01 /* SphinxOnionManagerSeedGenerationTests.swift */, SOMCT0010000000001SOMCT01 /* SphinxOnionManagerConnectionTests.swift */, + SBIDT0010000000001SBIDT01 /* StatusBarItemIdempotencyTests.swift */, 4734D3AD2417E3D500D6957E /* Info.plist */, ); path = com.stakwork.sphinx.desktopTests; @@ -5130,6 +5133,7 @@ TRTTS0020000000001TRTTS02 /* TribeTimestampTests.swift in Sources */, SOMSG0020000000001SOMSG02 /* SphinxOnionManagerSeedGenerationTests.swift in Sources */, SOMCT0020000000001SOMCT02 /* SphinxOnionManagerConnectionTests.swift in Sources */, + SBIDT0020000000001SBIDT02 /* StatusBarItemIdempotencyTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 3a36c5404c64e04322ac9c17f4ce7f10e3c38929 Mon Sep 17 00:00:00 2001 From: Gonzalo Aune Date: Thu, 27 Aug 2026 14:32:27 +0100 Subject: [PATCH 2/4] fixes for local stack --- .../API/API+ContentItemsExtension.swift | 221 ++++++++++++++---- com.stakwork.sphinx.desktop/AppDelegate.swift | 7 + .../Configuration/UserData.swift | 2 +- .../Custom Classes/ContentItemsManager.swift | 78 ++++++- .../Extensions/NSImageView.swift | 24 +- .../Helpers/Audio/AudioRecorderHelper.swift | 5 +- ...stPlayerController+Delegates&Actions.swift | 2 +- .../S3 Uploader/S3UploaderManager.swift | 6 +- .../Helpers/MessagesPreloaderHelper.swift | 20 +- ...ewChatTableDataSource+AudioExtension.swift | 24 +- ...ableDataSource+CellDelegateExtension.swift | 50 +--- ...atTableDataSource+PreloaderExtension.swift | 35 +-- ...ataSource+ResultsControllerExtension.swift | 51 +++- ...wChatTableDataSource+ScrollExtension.swift | 1 + .../NewChatTableDataSource.swift | 25 +- .../ThreadTableDataSource.swift | 10 +- .../JitsiCallWebViewController.swift | 4 +- .../Live Kit/Views/RoomContext.swift | 1 + .../Live Kit/Views/RoomContextView.swift | 1 + .../Signup/Custom Views/SignupFieldView.swift | 18 +- sphinx.xcodeproj/project.pbxproj | 8 +- 21 files changed, 413 insertions(+), 180 deletions(-) diff --git a/com.stakwork.sphinx.desktop/API/API+ContentItemsExtension.swift b/com.stakwork.sphinx.desktop/API/API+ContentItemsExtension.swift index 8f46dc2b..be3adf2a 100644 --- a/com.stakwork.sphinx.desktop/API/API+ContentItemsExtension.swift +++ b/com.stakwork.sphinx.desktop/API/API+ContentItemsExtension.swift @@ -7,19 +7,40 @@ // extension API { - func checkItemNodeExists(url: String) async throws -> CheckNodeResponse { + ///Submits a piece of content to the personal graph. + /// + ///Replaces the removed POST /add_node. The v2 contract differs in every part: + /// - flat body ({content_type, source_link}) instead of nested node_type/node_data + /// - response is {status, nodes[], status_messages[]} instead of {success, data{}} + /// - rejections come back as {errorCode, message} + /// + ///NOTE ON AUTH: the v2/content route requires an identity — a Sphinx signature, a + ///Stakwork admin token, or a valid L402 macaroon — and returns 401 without one. + ///`free=true` only skips boltwall's *payment* determination (and needs TESTING_FREE=true + ///set on the boltwall container); it does not satisfy the route's identity check, + ///so the signed sig/msg pair below is what actually authenticates this client. + func checkItemNodeExists(url: String, contentType: String) async throws -> CheckNodeResponse { guard let baseUrl = UserData.sharedInstance.getPersonalGraphBoltwallUrl() else { throw NodeError.missingUrl } - let apiUrl = "\(baseUrl)/add_node?sig=&msg=" - - var nodeDataParams = [String: AnyObject]() - nodeDataParams["source_link"] = url.fixedYoutubeUrl as AnyObject - + + guard let signatureQuery = API.graphSignatureQuery() else { + throw NodeError.missingToken + } + + let apiUrl = "\(baseUrl)/v2/content?free=true&\(signatureQuery)" + var params = [String: AnyObject]() - params["node_type"] = "Multimedia" as AnyObject - params["node_data"] = nodeDataParams as AnyObject - + params["content_type"] = contentType as AnyObject + params["source_link"] = url.fixedYoutubeUrl as AnyObject + + ///Required by /v2/content for every non-radar content_type — omitting it is a + ///400 MISSING_WEBHOOK_URL. It is where Stakwork posts back when the run finishes; + ///this client doesn't receive it (it polls via checkItemNodeStatus), so it points + ///at the swarm's own boltwall, matching the RADAR_*_WEBHOOK values the swarm sets. + ///TODO: confirm the intended callback path with the backend team. + params["webhook_url"] = "\(baseUrl)/v2/content" as AnyObject + guard let request = createRequest( apiUrl, params: params as NSDictionary, @@ -27,48 +48,101 @@ extension API { ) else { throw NodeError.invalidRequest } - - let response = try await performSphinxRequest(request) - - guard let dictionary = response as? NSDictionary, - let dataDic = dictionary["data"] as? NSDictionary else { + + let response = try await performSphinxRequest(request, label: "addContent(v2/content)") + + guard let dictionary = response as? NSDictionary else { + API.graphLog(" ✗ v2/content: expected a dictionary, got \(type(of: response)): \(response)") throw NodeError.invalidResponse } - - if let success = dictionary["success"] as? Bool, success { - guard let projectId = dataDic["project_id"] as? Int, - let refId = dataDic["ref_id"] as? String else { - throw NodeError.missingData - } - - return CheckNodeResponse( - success: true, - refId: refId, - projectId: projectId - ) - } else { - guard let nodeKey = dataDic["node_key"] as? String, - let refId = dataDic["ref_id"] as? String else { - throw NodeError.missingData + + ///Structured rejection: {"errorCode": "...", "message": "..."} + if let errorCode = dictionary["errorCode"] as? String { + let message = dictionary["message"] as? String ?? errorCode + + ///"Already in the graph" is not a failure — under the old /add_node it came + ///back as a plain success:false + node_key result. v2 reports it as a + ///"Warning" that jarvis then flattens into {errorCode, message}, dropping + ///the data.ref_id it carried, so only the node_key survives. Surface it as + ///its own case so the caller can stop instead of retrying a duplicate. + let alreadyExists = "Node already exists in the graph" + if errorCode == alreadyExists || message.contains(alreadyExists) { + let nodeKey = dictionary["node_key"] as? String + ?? message.components(separatedBy: "node_key: ").last + API.graphLog(" • v2/content: already in the graph (node_key: \(nodeKey ?? "unknown"))") + throw NodeError.alreadyExists(nodeKey: nodeKey) } - - return CheckNodeResponse( - success: false, - refId: refId, - nodeKey: nodeKey - ) + + API.graphLog(" ✗ v2/content rejected: \(errorCode) — \(message)") + throw NodeError.rejected(code: errorCode, message: message) + } + + guard let status = dictionary["status"] as? String, status == "Success" else { + API.graphLog(" ✗ v2/content: expected status \"Success\", got \(dictionary)") + throw NodeError.invalidResponse + } + + ///nodes[0] carries the same payload the old response nested under "data". + guard let nodes = dictionary["nodes"] as? [NSDictionary], + let node = nodes.first, + let refId = node["ref_id"] as? String else { + let messages = dictionary["status_messages"] ?? "none" + API.graphLog(" ✗ v2/content: no node with a ref_id in \(dictionary["nodes"] ?? "nil"), status_messages: \(messages)") + throw NodeError.missingData + } + + return CheckNodeResponse( + success: true, + refId: refId, + nodeKey: node["node_key"] as? String, + projectId: node["project_id"] as? Int + ) + } + + ///Set to false to silence the personal-graph request/response logging below. + nonisolated(unsafe) static var logContentItemRequests = true + + static func graphLog(_ message: String) { + guard API.logContentItemRequests else { return } + print("[PersonalGraph] \(message)") + } + + ///Truncates a raw body so a huge HTML error page doesn't flood the console. + private func previewBody(_ data: Data?) -> String { + guard let data = data, !data.isEmpty else { return "" } + + guard let text = String(data: data, encoding: .utf8) else { + return "<\(data.count) bytes of non-UTF8 data>" } + + let limit = 2000 + return text.count > limit ? String(text.prefix(limit)) + "… (\(text.count) chars total)" : text } // Helper to convert sphinxRequest to async - private func performSphinxRequest(_ request: URLRequest) async throws -> Any { + private func performSphinxRequest(_ request: URLRequest, label: String) async throws -> Any { struct AnyBox: @unchecked Sendable { let value: Any } + + API.graphLog("→ \(label): \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "?")") + + if let body = request.httpBody, let bodyText = String(data: body, encoding: .utf8) { + API.graphLog(" request body: \(bodyText)") + } + let box = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in sphinxRequest(request) { response in + let status = response.response?.statusCode + API.graphLog("← \(label): HTTP \(status.map(String.init) ?? "no status")") + API.graphLog(" raw body: \(self.previewBody(response.data))") + switch response.result { case .success(let data): continuation.resume(returning: AnyBox(value: data)) case .failure(let error): + API.graphLog(" ✗ transport/decoding failure: \(error)") + if let underlying = error.underlyingError { + API.graphLog(" ✗ underlying: \(underlying)") + } continuation.resume(throwing: error) } } @@ -83,7 +157,11 @@ extension API { case invalidRequest case invalidResponse case missingData - + ///The endpoint answered with a structured {errorCode, message} rejection. + case rejected(code: String, message: String) + ///The content is already in the graph. Terminal, and not a failure. + case alreadyExists(nodeKey: String?) + var errorDescription: String? { switch self { case .missingUrl: @@ -96,9 +174,61 @@ extension API { return "Error getting response data" case .missingData: return "Missing required data in response" + case .rejected(let code, let message): + return "\(code): \(message)" + case .alreadyExists: + return "Already added to the graph" } } } + + ///Builds the `sig`/`msg` query pair boltwall uses to identify the caller. + /// + ///Boltwall reads both from the query string (`const { msg, sig } = req.query`), + ///base64-decodes `msg`, and recovers the pubkey from a Lightning signed message. + ///It only requires that both are non-empty and that the signature verifies — there + ///is no replay window — so any freshly signed value works as proof of key ownership. + ///Returns nil when there's no seed (logged out), leaving the caller to fail loudly. + static func graphSignatureQuery() -> String? { + let som = SphinxOnionManager.sharedInstance + + guard let seed = som.getAccountSeed() else { + API.graphLog(" ✗ cannot sign request: no account seed available") + return nil + } + + ///Base64 of the signed value, since boltwall decodes msg as base64. + let message = Data(som.getTimeWithEntropy().utf8).base64EncodedString() + + do { + let sig = try Sphinx.signBase64( + seed: seed, + idx: 0, + time: som.getTimeWithEntropy(), + network: som.network, + msg: message + ) + return "sig=\(sig.urlSafe)&msg=\(message.urlSafe)" + } catch { + API.graphLog(" ✗ cannot sign request: \(error)") + return nil + } + } + + ///Maps a ContentItem.ContentType to the `content_type` that POST /v2/content expects. + ///The backend turns content_type into a node type via CONTENT_TYPE_TO_NODE_TYPE; + ///the "Multimedia" node_type the old /add_node call hardcoded no longer exists. + static func graphContentType(for itemType: String?) -> String { + switch itemType { + case ContentItem.ContentType.video.rawValue: + return "audio_video" + case ContentItem.ContentType.externalURL.rawValue: + return "webpage" + default: + // image / fileURL / text all arrive as an uploaded file URL + return "document" + } + } func checkItemNodeStatus(refId: String) async throws -> NodeStatusResponse { guard let baseUrl = UserData.sharedInstance.getPersonalGraphBoltwallUrl() else { @@ -115,10 +245,11 @@ extension API { } // Convert sphinxRequest to async - let response = try await performSphinxRequest(request) - + let response = try await performSphinxRequest(request, label: "checkItemNodeStatus(refId: \(refId))") + guard let dictionary = response as? NSDictionary, let properties = dictionary["properties"] as? NSDictionary else { + API.graphLog(" ✗ checkItemNodeStatus: expected a dictionary with a \"properties\" key, got \(type(of: response)): \(response)") throw NodeError.invalidResponse } @@ -173,12 +304,13 @@ extension API { throw NodeError.invalidRequest } - let data = try await performSphinxRequest(request) - + let data = try await performSphinxRequest(request, label: "checkProjectStatus(projectId: \(projectId))") + guard let dictionary = data as? NSDictionary, let responseData = dictionary["data"] as? NSDictionary, let success = dictionary["success"] as? Bool, success else { + API.graphLog(" ✗ checkProjectStatus: expected success=true with a \"data\" key, got \(type(of: data)): \(data)") throw NodeError.invalidResponse } @@ -270,13 +402,14 @@ extension API { } // Perform request - let data = try await performSphinxRequest(request) - + let data = try await performSphinxRequest(request, label: "createGraphMindsetRunForItem(refId: \(refId))") + // Parse response guard let dictionary = data as? NSDictionary, let responseData = dictionary["data"] as? NSDictionary, let success = dictionary["success"] as? Bool, success else { + API.graphLog(" ✗ createGraphMindsetRunForItem: expected success=true with a \"data\" key, got \(type(of: data)): \(data)") throw NodeError.invalidResponse } diff --git a/com.stakwork.sphinx.desktop/AppDelegate.swift b/com.stakwork.sphinx.desktop/AppDelegate.swift index c5f01d91..6f66a1e5 100644 --- a/com.stakwork.sphinx.desktop/AppDelegate.swift +++ b/com.stakwork.sphinx.desktop/AppDelegate.swift @@ -340,6 +340,13 @@ import SphinxErrorReporter func createKeyWindowWith(vc: NSViewController, windowState: WindowState, closeOther: Bool = false, hideBar: Bool = false) { if closeOther { for window in NSApplication.shared.windows { + // NSApplication.shared.windows includes the status item's own + // NSStatusBarWindow. Closing it hides the tray icon from the menu bar, + // and addStatusBarItem() is idempotent so it never rebuilds it. + // Skip it, or the icon disappears on every window transition. + if window === statusBarItem?.button?.window { + continue + } window.close() } } diff --git a/com.stakwork.sphinx.desktop/Configuration/UserData.swift b/com.stakwork.sphinx.desktop/Configuration/UserData.swift index d44d5e2c..cf1afb1e 100644 --- a/com.stakwork.sphinx.desktop/Configuration/UserData.swift +++ b/com.stakwork.sphinx.desktop/Configuration/UserData.swift @@ -217,7 +217,7 @@ class UserData: @unchecked Sendable { func getPersonalGraphUrl() -> String? { if let url = getPersonalGraphValue(with: KeychainManager.KeychainKeys.personalGraphUrl) { - return "\(url):8000/mindset" + return "\(url):3100" } return nil } diff --git a/com.stakwork.sphinx.desktop/Custom Classes/ContentItemsManager.swift b/com.stakwork.sphinx.desktop/Custom Classes/ContentItemsManager.swift index 1936b04e..a33349e9 100644 --- a/com.stakwork.sphinx.desktop/Custom Classes/ContentItemsManager.swift +++ b/com.stakwork.sphinx.desktop/Custom Classes/ContentItemsManager.swift @@ -122,6 +122,10 @@ class ContentItemsManager { } nonisolated private func processItemWithRetry(_ item: ContentItem, context: NSManagedObjectContext) async -> Bool { + ///Kept so a later "already exists" collision can report what actually went wrong + ///on the first attempt instead of the duplicate it caused. + var firstFailure: String? = nil + for attempt in 1...ContentItemsManager.maxRetries { do { var response: API.CheckNodeResponse? = nil @@ -135,7 +139,10 @@ class ContentItemsManager { return true } } else { - response = try await API.sharedInstance.checkItemNodeExists(url: item.value) + response = try await API.sharedInstance.checkItemNodeExists( + url: item.value, + contentType: API.graphContentType(for: item.type) + ) } @@ -154,9 +161,55 @@ class ContentItemsManager { print("✓ Item \(item.uuid?.uuidString ?? "Empty UUID") processed (attempt \(attempt))") return true + } catch API.NodeError.alreadyExists(let nodeKey) { + ///Only benign on the FIRST attempt, where it means the content was + ///genuinely ingested earlier. On a later attempt it is our own doing: + ///the backend creates the Neo4j node before dispatching to Stakwork, so + ///a failed attempt leaves the node behind and every retry then collides + ///with it. Reporting that as success would hide the original failure. + if attempt == 1 { + print("• Item \(item.uuid?.uuidString ?? "Empty UUID") is already in the graph (node_key: \(nodeKey ?? "unknown"))") + + await context.performSafely { + item.status = Int16(ContentItem.ContentItemStatus.success.rawValue) + item.errorMessage = nil + item.lastProcessedAt = Date() + } + return true + } + + print("✗ Item \(item.uuid?.uuidString ?? "Empty UUID") collided with the node its own attempt 1 left behind; reporting the original failure") + + await context.performSafely { + item.status = Int16(ContentItem.ContentItemStatus.error.rawValue) + item.errorMessage = firstFailure ?? "Node already exists in the graph" + } + return false + + } catch let error as API.NodeError { + ///A structured {errorCode, message} rejection is a business error, not a + ///transient one — retrying cannot change the answer, and because node + ///creation is not idempotent it actively corrupts the diagnostic by + ///turning attempt 2 into a misleading "already exists". Stop here. + print("✗ processItem rejected for item \(item.uuid?.uuidString ?? "Empty UUID") (attempt \(attempt), not retrying)") + print(" value: \(item.value)") + print(" error: \(error)") + + await context.performSafely { + item.status = Int16(ContentItem.ContentItemStatus.error.rawValue) + item.errorMessage = error.localizedDescription + } + return false + } catch { - print("✗ Attempt \(attempt) failed for item \(item.uuid?.uuidString ?? "Empty UUID"): \(error)") - + print("✗ processItem attempt \(attempt)/\(ContentItemsManager.maxRetries) failed for item \(item.uuid?.uuidString ?? "Empty UUID")") + print(" value: \(item.value)") + print(" error: \(error)") + + if firstFailure == nil { + firstFailure = error.localizedDescription + } + if attempt == ContentItemsManager.maxRetries { await context.performSafely { item.status = Int16(ContentItem.ContentItemStatus.error.rawValue) @@ -205,16 +258,21 @@ class ContentItemsManager { return true } catch { + print("✗ checkItem attempt \(attempt)/\(ContentItemsManager.maxRetries) failed for item \(item.uuid?.uuidString ?? "Empty UUID")") + print(" refId: \(referenceId), projectId: \(item.projectId ?? "none")") + print(" error: \(error)") + if attempt < ContentItemsManager.maxRetries { try? await Task.sleep(nanoseconds: UInt64(attempt) * 1_000_000_000) } } } - + return false } - func add(value: String) { + ///Works entirely on a background Core Data context, so it stays off the main actor + nonisolated func add(value: String) { let context = CoreDataManager.sharedManager.persistentContainer.newBackgroundContext() context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump @@ -245,15 +303,23 @@ class ContentItemsManager { nonisolated private func processAddedItem(_ contentitem: ContentItem, url: URL, context: NSManagedObjectContext) async { if contentitem.shouldBeUploaded() { + print("↑ Uploading dropped file to S3: \(url.path)") + print(" S3 endpoint: \(UserData.sharedInstance.getPersonalGraphS3Url() ?? "MISSING — no personal graph url set")") + if let resultUrl = await S3UploaderManager.sharedInstance.uploadFileToS3(fileURL: url) { + print("↑ Upload succeeded: \(resultUrl)") + await context.performSafely { contentitem.value = resultUrl contentitem.status = Int16(ContentItem.ContentItemStatus.uploaded.rawValue) } let _ = await processItemWithRetry(contentitem, context: context) } else { + print("✗ Upload to S3 failed for \(url.path) — see S3Uploader logs above for the cause") + await context.performSafely { contentitem.status = Int16(ContentItem.ContentItemStatus.error.rawValue) + contentitem.errorMessage = "Upload to S3 failed" } } } else { @@ -271,7 +337,7 @@ class ContentItemsManager { } } - func createTextFile(content: String, fileName: String) -> URL? { + nonisolated func createTextFile(content: String, fileName: String) -> URL? { // Get documents directory guard let documentsDirectory = FileManager.default.urls( for: .documentDirectory, diff --git a/com.stakwork.sphinx.desktop/Extensions/NSImageView.swift b/com.stakwork.sphinx.desktop/Extensions/NSImageView.swift index b6150c58..dbd8e6e9 100644 --- a/com.stakwork.sphinx.desktop/Extensions/NSImageView.swift +++ b/com.stakwork.sphinx.desktop/Extensions/NSImageView.swift @@ -14,22 +14,26 @@ extension NSImageView { do { let data = try Data(contentsOf: url) - let imageLayer = CAShapeLayer() - imageLayer.contentsGravity = .resizeAspectFill - imageLayer.frame = self.bounds - - DispatchQueue.global(qos: .background).async { + let bounds = self.bounds + + DispatchQueue.global(qos: .background).async { [weak self] in if let animation = data.createGIFAnimation() { DispatchQueue.main.async { - imageLayer.contents = nil + guard let self = self else { + return + } + + let imageLayer = CAShapeLayer() + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.frame = bounds imageLayer.add(animation, forKey: "contents") + + self.wantsLayer = true + self.layer?.masksToBounds = false + self.layer?.addSublayer(imageLayer) } } } - - self.wantsLayer = true - self.layer?.masksToBounds = false - self.layer?.addSublayer(imageLayer) } catch { print("Error") } diff --git a/com.stakwork.sphinx.desktop/Helpers/Audio/AudioRecorderHelper.swift b/com.stakwork.sphinx.desktop/Helpers/Audio/AudioRecorderHelper.swift index 80f61c16..5dfd1f44 100644 --- a/com.stakwork.sphinx.desktop/Helpers/Audio/AudioRecorderHelper.swift +++ b/com.stakwork.sphinx.desktop/Helpers/Audio/AudioRecorderHelper.swift @@ -39,13 +39,14 @@ class AudioRecorderHelper : NSObject, @unchecked Sendable { self.delegate = delegate } - static func requestMicrophonePermission(completion: @escaping (Bool) -> Void) { + @MainActor + static func requestMicrophonePermission(completion: @escaping @MainActor (Bool) -> Void) { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: completion(true) case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { granted in - DispatchQueue.main.async { completion(granted) } + Task { @MainActor in completion(granted) } } default: completion(false) diff --git a/com.stakwork.sphinx.desktop/Helpers/Podcast Player Controller/PodcastPlayerController+Delegates&Actions.swift b/com.stakwork.sphinx.desktop/Helpers/Podcast Player Controller/PodcastPlayerController+Delegates&Actions.swift index 8f8432c9..c77719f1 100644 --- a/com.stakwork.sphinx.desktop/Helpers/Podcast Player Controller/PodcastPlayerController+Delegates&Actions.swift +++ b/com.stakwork.sphinx.desktop/Helpers/Podcast Player Controller/PodcastPlayerController+Delegates&Actions.swift @@ -205,7 +205,7 @@ extension PodcastPlayerController { player?.pause() player?.automaticallyWaitsToMinimizeStalling = false - let addObserverToPlayerItem: () -> Void = { [weak self] in + let addObserverToPlayerItem: @MainActor () -> Void = { [weak self] in guard let self = self else { return } playerItem.addObserver(self, forKeyPath: "status", options: [.initial, .new], context: nil) } diff --git a/com.stakwork.sphinx.desktop/Managers/S3 Uploader/S3UploaderManager.swift b/com.stakwork.sphinx.desktop/Managers/S3 Uploader/S3UploaderManager.swift index 75fff1a2..5150291c 100644 --- a/com.stakwork.sphinx.desktop/Managers/S3 Uploader/S3UploaderManager.swift +++ b/com.stakwork.sphinx.desktop/Managers/S3 Uploader/S3UploaderManager.swift @@ -69,9 +69,13 @@ class S3UploaderManager: @unchecked Sendable { ) } } catch { + // localizedDescription alone hides the AWS SDK's actual reason + // (endpoint unreachable, 403, bucket missing…), so dump the whole error. print("❌ Upload failed: \(error.localizedDescription)") + print(" full error: \(error)") + print(" file: \(fileURL.path)") } - + return resultURL } } diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Helpers/MessagesPreloaderHelper.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Helpers/MessagesPreloaderHelper.swift index 730046b1..2aacd742 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Helpers/MessagesPreloaderHelper.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Helpers/MessagesPreloaderHelper.swift @@ -53,7 +53,9 @@ class MessagesPreloaderHelper: @unchecked Sendable { } var chatMessages: [Int: PreloadedMessagesState] = [:] - var chatScrollState: [Int: ScrollState] = [:] + ///Keyed by data source, not just by chat, since a chat and its open threads + ///are displayed by different data sources with different scroll positions + var chatScrollState: [String: ScrollState] = [:] var tribesData: [String: MessageTableCellState.TribeData] = [:] var linksData: [String: MessageTableCellState.LinkData] = [:] @@ -80,23 +82,23 @@ class MessagesPreloaderHelper: @unchecked Sendable { firstRowId: Int, difference: CGFloat, isAtBottom: Bool, - for chatId: Int + for scrollStateKey: String ) { - self.chatScrollState[chatId] = ScrollState( + self.chatScrollState[scrollStateKey] = ScrollState( firstRowId: firstRowId, difference: difference, isAtBottom: isAtBottom ) } - + func reset( - for chatId: Int + for scrollStateKey: String ) { - self.chatScrollState.removeValue(forKey: chatId) + self.chatScrollState.removeValue(forKey: scrollStateKey) } - + func getScrollState( - for chatId: Int, + for scrollStateKey: String, pinnedMessageId: Int? = nil ) -> ScrollState? { if let pinnedMessageId = pinnedMessageId { @@ -106,7 +108,7 @@ class MessagesPreloaderHelper: @unchecked Sendable { isAtBottom: false ) } - if let scrollState = chatScrollState[chatId] { + if let scrollState = chatScrollState[scrollStateKey] { return scrollState } return nil diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+AudioExtension.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+AudioExtension.swift index 81cda5d8..be32c9b2 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+AudioExtension.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+AudioExtension.swift @@ -80,17 +80,7 @@ extension NewChatTableDataSource : AudioPlayerHelperDelegate { if rowIndex == NewChatTableDataSource.kThreadHeaderRowIndex { delegate?.shouldReloadThreadHeader() } else { - var snapshot = self.dataSource.snapshot() - - if snapshot.itemIdentifiers.contains(tableCellState.1) { - // Use async instead of sync to avoid blocking main thread - dataSourceQueue.async { [weak self] in - snapshot.reloadItems([tableCellState.1]) - DispatchQueue.main.async { - self?.dataSource.apply(snapshot, animatingDifferences: false) - } - } - } + reloadSnapshotItem(tableCellState.1) } } } @@ -208,17 +198,7 @@ extension NewChatTableDataSource : PlayerDelegate { ) ) - var snapshot = self.dataSource.snapshot() - - if snapshot.itemIdentifiers.contains(tableCellState.1) { - // Use async instead of sync to avoid blocking main thread - dataSourceQueue.async { [weak self] in - snapshot.reloadItems([tableCellState.1]) - DispatchQueue.main.async { - self?.dataSource.apply(snapshot, animatingDifferences: false) - } - } - } + reloadSnapshotItem(tableCellState.1) } } } diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+CellDelegateExtension.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+CellDelegateExtension.swift index 241c7c9f..81b4ce55 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+CellDelegateExtension.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+CellDelegateExtension.swift @@ -56,19 +56,7 @@ extension NewChatTableDataSource : ChatCollectionViewItemDelegate, @preconcurren ) { self.saveSnapshotCurrentState() - var snapshot = self.dataSource.snapshot() - - if snapshot.itemIdentifiers.contains(tableCellState.1) { - // Use async instead of sync to avoid blocking main thread - dataSourceQueue.async { [weak self] in - snapshot.reloadItems([tableCellState.1]) - - DispatchQueue.main.async { - // Disable animation for smoother scrolling - self?.dataSource.apply(snapshot, animatingDifferences: false) - } - } - } + reloadSnapshotItem(tableCellState.1) } } @@ -706,18 +694,7 @@ extension NewChatTableDataSource { ) self.saveSnapshotCurrentState() - var snapshot = self.dataSource.snapshot() - - if snapshot.itemIdentifiers.contains(tableCellState.1) { - // Use async instead of sync to avoid blocking main thread - dataSourceQueue.async { [weak self] in - snapshot.reloadItems([tableCellState.1]) - - DispatchQueue.main.async { - self?.dataSource.apply(snapshot, animatingDifferences: false) - } - } - } + reloadSnapshotItem(tableCellState.1) } } @@ -733,17 +710,7 @@ extension NewChatTableDataSource { if updatedUploadProgressData.progress < 100 { self.uploadingProgress[messageId] = updatedUploadProgressData - var snapshot = self.dataSource.snapshot() - - if snapshot.itemIdentifiers.contains(tableCellState.1) { - // Use async instead of sync to avoid blocking main thread - self.dataSourceQueue.async { [weak self] in - snapshot.reloadItems([tableCellState.1]) - DispatchQueue.main.async { - self?.dataSource.apply(snapshot, animatingDifferences: false) - } - } - } + reloadSnapshotItem(tableCellState.1) } else { self.uploadingProgress.removeValue(forKey: messageId) } @@ -771,17 +738,8 @@ extension NewChatTableDataSource { if !(self.collectionView.indexPathsForVisibleItems().map { $0.item }).contains(rowIndex) { return } - var snapshot = self.dataSource.snapshot() - if snapshot.itemIdentifiers.contains(tableCellState.1) { - // Use async instead of sync to avoid blocking main thread - self.dataSourceQueue.async { [weak self] in - snapshot.reloadItems([tableCellState.1]) - DispatchQueue.main.async { - self?.dataSource.apply(snapshot, animatingDifferences: false) - } - } - } + self.reloadSnapshotItem(tableCellState.1) } } } diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+PreloaderExtension.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+PreloaderExtension.swift index 66121994..50bd5c71 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+PreloaderExtension.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+PreloaderExtension.swift @@ -91,20 +91,20 @@ extension NewChatTableDataSource { } func deleteSnapshotCurrentState() { - guard let chatId = chat?.id else { + guard let _ = chat?.id else { return } - + self.preloaderHelper.reset( - for: chatId + for: scrollStateKey ) } - + @objc func restoreScrollLastPosition() { - guard let chatId = chat?.id else { return } + guard let _ = chat?.id else { return } if let scrollState = self.preloaderHelper.getScrollState( - for: chatId, + for: scrollStateKey, pinnedMessageId: pinnedMessageId ), !scrollState.isAtBottom { @@ -137,11 +137,8 @@ extension NewChatTableDataSource { } ///Scroll to bottom if it didn't scroll to spefici position - let collectionViewContentSize = collectionView.collectionViewLayout?.collectionViewContentSize.height ?? 0 - let rawOffset = collectionViewContentSize - collectionViewScroll.frame.height + collectionViewScroll.contentInsets.top - let offset = max(0, rawOffset) + let offset = scrollToBottomOffset() scrollViewDesiredOffset = offset - collectionViewScroll.documentYOffset = offset scrolledAtBottom = true @@ -157,14 +154,26 @@ extension NewChatTableDataSource { } } + ///Scrolls the collection view to the very bottom of its current content and returns the applied offset + @discardableResult + func scrollToBottomOffset() -> CGFloat { + let collectionViewContentSize = collectionView.collectionViewLayout?.collectionViewContentSize.height ?? 0 + let rawOffset = collectionViewContentSize - collectionViewScroll.frame.height + collectionViewScroll.contentInsets.top + let offset = max(0, rawOffset) + + collectionViewScroll.documentYOffset = offset + + return offset + } + func saveScrollPosition() { guard let _ = collectionView.enclosingScrollView else { return } if collectionView.alphaValue == 0 { return } - guard let chatId = chat?.id else { + guard let _ = chat?.id else { return } - + let collectionViewOffsetY = collectionViewScroll.documentYOffset + collectionViewScroll.contentInsets.top ///Find first visible item @@ -186,7 +195,7 @@ extension NewChatTableDataSource { firstRowId: firstRowId, difference: collectionViewOffsetY - firstVisibleRowY, isAtBottom: collectionView.isAtBottom(), - for: chatId + for: scrollStateKey ) } } diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ResultsControllerExtension.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ResultsControllerExtension.swift index 11f594c2..5b00ee38 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ResultsControllerExtension.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ResultsControllerExtension.swift @@ -71,20 +71,26 @@ extension NewChatTableDataSource { let wasAtBottom = collectionView.getDistanceToBottom() < 10 scrolledAtBottom = false - - let loadingMoreItems = self.dataSource.snapshot().numberOfItems < snapshot.numberOfItems - + + ///The stored scroll position is only meaningful on the initial load and when paginating, + ///where older items are inserted above the current ones. A snapshot that grew because a + ///message was sent or received appends at the bottom instead, so the chat must stay pinned there + let shouldRestoreScrollPosition = isFirstLoad || isPaginating + DispatchQueue.main.async { - if loadingMoreItems { self.saveSnapshotCurrentState() } - + if shouldRestoreScrollPosition { self.saveSnapshotCurrentState() } + self.dataSource.apply(snapshot, animatingDifferences: animated) { - if loadingMoreItems { + if shouldRestoreScrollPosition { self.restoreScrollLastPosition() } else if wasAtBottom { + self.scrollToBottomOffset() self.scrolledAtBottom = true self.delegate?.didScrollToBottom() } - + + self.isPaginating = false + let wasFirstLoad = self.isFirstLoad self.isFirstLoad = false @@ -828,15 +834,34 @@ extension NewChatTableDataSource : @preconcurrency NSFetchedResultsControllerDel return objects.last?.id } + ///Oldest first, matching the order the rows are displayed on + func sortedByDate( + messages: [TransactionMessage] + ) -> [TransactionMessage] { + return messages.sorted(by: { firstMessage, secondMessage in + let firstDate = firstMessage.date ?? Date.distantPast + let secondDate = secondMessage.date ?? Date.distantPast + + if firstDate == secondDate { + return firstMessage.id < secondMessage.id + } + + return firstDate < secondDate + }) + } + func configureResultsController(items: Int) { guard let chat = chat else { + isPaginating = false return } - + if messagesCountFetched < messagesCountRequested { + ///Fetch skipped, so no snapshot will follow to clear the flag + isPaginating = false return } - + messagesCountRequested = items var fetchRequest = getFetchRequestFor( @@ -911,7 +936,13 @@ extension NewChatTableDataSource : @preconcurrency NSFetchedResultsControllerDel } self.messagesCountFetched = messages.count - self.messagesArray = messages.filter({ !$0.isApprovedRequest() }).reversed() + ///Sorted explicitly instead of just reversing the results controller order. + ///A just inserted message is not positioned by date on the results controller, + ///so a provisional message, which has a negative id, ends up on the first row + ///until the real message replaces it + self.messagesArray = self.sortedByDate( + messages: messages.filter({ !$0.isApprovedRequest() }) + ) if let lastMessage = self.messagesArray.last, lastMessage.isCallLink() { if lastMessage.id != self.lastSeenCallMessageId { diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ScrollExtension.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ScrollExtension.swift index 308f143f..c018d774 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ScrollExtension.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource+ScrollExtension.swift @@ -102,6 +102,7 @@ extension NewChatTableDataSource: NSCollectionViewDelegate { } func loadMoreItems(itemsCount: Int) { + isPaginating = true collectionViewScroll.contentView.animator().setBoundsOrigin(collectionViewScroll.contentView.bounds.origin) configureResultsController(items: messagesCountRequested + itemsCount) } diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource.swift index e622c943..df7727d8 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/New Chat Table Data Source/NewChatTableDataSource.swift @@ -134,6 +134,9 @@ class NewChatTableDataSource : NSObject { var messagesCountFetched = 0 var fetchMinIndex = 0 var loadingMoreItems = false + ///True from the moment a pagination fetch is requested until its snapshot is applied. + ///Only then the stored scroll position must be restored, since older items are inserted above + var isPaginating = false var allItemsLoaded = false var scrolledAtBottom = false var scrollViewDesiredOffset: CGFloat? = nil @@ -160,7 +163,15 @@ class NewChatTableDataSource : NSObject { return false } } - + + ///Key the stored scroll position is saved under. A chat and each of its open threads + ///are shown by separate data sources, so they must not share a single entry + var scrollStateKey: String { + get { + return "chat-\(chat?.id ?? -1)" + } + } + init( chat: Chat?, contact: UserContact?, @@ -241,6 +252,18 @@ class NewChatTableDataSource : NSObject { } } + ///Reloads a single item on the current snapshot if it's still present on it + func reloadSnapshotItem(_ item: MessageTableCellState) { + var snapshot = dataSource.snapshot() + + guard snapshot.itemIdentifiers.contains(item) else { + return + } + + snapshot.reloadItems([item]) + dataSource.apply(snapshot, animatingDifferences: false) + } + /// Updates the messageId to index mapping for O(1) lookups func updateMessageIdIndexMap() { messageIdToIndexMap.removeAll(keepingCapacity: true) diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Thread Table Data Source/ThreadTableDataSource.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Thread Table Data Source/ThreadTableDataSource.swift index bb58bfaa..e84f1ce0 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Thread Table Data Source/ThreadTableDataSource.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Chat/Data Source/New Chat Data Source/Thread Table Data Source/ThreadTableDataSource.swift @@ -19,7 +19,13 @@ class ThreadTableDataSource : NewChatTableDataSource { return true } } - + + override var scrollStateKey: String { + get { + return "thread-\(chat?.id ?? -1)-\(threadUUID ?? "")" + } + } + override var allItemsLoaded: Bool { get { return true } set { } @@ -84,7 +90,7 @@ class ThreadTableDataSource : NewChatTableDataSource { guard let self else { return nil } - + return self.getCellFor( dataSourceItem: dataSourceItem, indexPath: indexPath diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Jitsi Calls/JitsiCallWebViewController.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Jitsi Calls/JitsiCallWebViewController.swift index 1eea4a78..3a19c42b 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Jitsi Calls/JitsiCallWebViewController.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Jitsi Calls/JitsiCallWebViewController.swift @@ -53,13 +53,13 @@ class JitsiCallWebViewController: NSViewController, WKUIDelegate, WKScriptMessag view.window?.delegate = self } - func requestMicrophoneAccess(completion: @escaping (Bool) -> Void) { + func requestMicrophoneAccess(completion: @escaping @MainActor (Bool) -> Void) { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: completion(true) case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { granted in - DispatchQueue.main.async { + Task { @MainActor in completion(granted) } } diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContext.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContext.swift index 5a396691..4df65fe6 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContext.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContext.swift @@ -302,6 +302,7 @@ final class RoomContext: NSObject, ObservableObject, @unchecked Sendable { weak var screenShareTrack: LocalTrackPublication? @available(macOS 12.3, *) + @MainActor func setScreenShareMacOS(isEnabled: Bool, screenShareSource: MacOSScreenCaptureSource? = nil) async throws { if isEnabled, let screenShareSource { let windowsToExcludeIds = await WindowsManager.sharedInstance.getWindowsToExclude() diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContextView.swift b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContextView.swift index 1703bbf2..d2b179a5 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContextView.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Live Kit/Views/RoomContextView.swift @@ -186,6 +186,7 @@ struct RoomContextView: View { }) } + @MainActor func enableMic() { AudioRecorderHelper.requestMicrophonePermission { granted in if granted { diff --git a/com.stakwork.sphinx.desktop/Scenes/Signup/Custom Views/SignupFieldView.swift b/com.stakwork.sphinx.desktop/Scenes/Signup/Custom Views/SignupFieldView.swift index 00fd549f..0df23d9d 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Signup/Custom Views/SignupFieldView.swift +++ b/com.stakwork.sphinx.desktop/Scenes/Signup/Custom Views/SignupFieldView.swift @@ -87,21 +87,27 @@ extension SignupFieldView { var cleaned = text.filter { char in String(char).rangeOfCharacter(from: allowedChars) != nil } - + // Step 2: Remove port (anything like :8080, :3000, etc.) // But keep : in http:// and https:// + // + // This is deliberate and load-bearing: the stored value is a portless base + // host. UserData derives four service URLs from it by appending fixed ports + // (:8000/mindset, :4566, :8444/api, :3333). A user-supplied port would + // produce "host:8080:8000/mindset". Do not relax this without reworking + // getPersonalGraph*Url() to carry a custom port. if cleaned.contains("://") { // Find where the protocol ends if let protocolRange = cleaned.range(of: "://") { let scheme = String(cleaned[.. afterColon.startIndex { let beforeColon = String(rest[.. Date: Mon, 31 Aug 2026 13:18:05 -0300 Subject: [PATCH 3/4] Crash fixes --- ...hinxOnionManager+OnionStateExtension.swift | 36 ++++++++++--------- .../SphinxOnionManager.swift | 1 + 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager+OnionStateExtension.swift b/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager+OnionStateExtension.swift index bcfa4105..f9a51188 100644 --- a/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager+OnionStateExtension.swift +++ b/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager+OnionStateExtension.swift @@ -15,34 +15,36 @@ extension SphinxOnionManager { let allDefaults = userDefaults.dictionaryRepresentation() let inMemoryMutationKeys = mutationKeys - for (key, value) in allDefaults { - if inMemoryMutationKeys.contains(key), let value = value as? [UInt8] { - onionState[key] = value + onionStateQueue.sync { + for (key, value) in allDefaults { + if inMemoryMutationKeys.contains(key), let value = value as? [UInt8] { + onionState[key] = value + } } } } - + func loadOnionStateAsData() -> Data { let state = loadOnionState() - + var mpDic = [MessagePackValue:MessagePackValue]() for (key, value) in state { mpDic[MessagePackValue(key)] = MessagePackValue(Data(value)) } - + let stateBytes = [UInt8](pack(MessagePackValue(mpDic))) return Data(stateBytes) } - + func storeOnionState(inc: [UInt8]) -> [NSNumber] { let muts = try? unpack(Data(inc)) - + guard let mutsDictionary = (muts?.value as? MessagePackValue)?.dictionaryValue else { return [] } - + persist_muts(muts: mutsDictionary) return [] @@ -50,7 +52,7 @@ extension SphinxOnionManager { private func persist_muts(muts: [MessagePackValue: MessagePackValue]) { var keys: [String] = [] - + for mut in muts { if let key = mut.key.stringValue, let data = mut.value.dataValue { let value = [UInt8](data) @@ -60,24 +62,24 @@ extension SphinxOnionManager { UserDefaults.standard.set(value, forKey: key) UserDefaults.standard.synchronize() - onionState[key] = value + onionStateQueue.sync { onionState[key] = value } } } - + keys.append(contentsOf: mutationKeys) mutationKeys = Array(Set(keys)) } - + func handleStateToDelete(stateToDelete:[String]){ for key in stateToDelete { UserDefaults.standard.removeObject(forKey: key) UserDefaults.standard.synchronize() - - onionState.removeValue(forKey: key) + + onionStateQueue.sync { onionState.removeValue(forKey: key) } } } - + func loadOnionState() -> [String: [UInt8]] { - return onionState + return onionStateQueue.sync { onionState } } } diff --git a/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager.swift b/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager.swift index 52c6b7f3..f4701c24 100644 --- a/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager.swift +++ b/com.stakwork.sphinx.desktop/Crypter/SphinxOnionManager/SphinxOnionManager.swift @@ -117,6 +117,7 @@ class SphinxOnionManager : NSObject, @unchecked Sendable { public static let kFailedStatus = "FAILED" var onionState: [String: [UInt8]] = [:] + let onionStateQueue = DispatchQueue(label: "sphinx.onionState", qos: .userInitiated) var mutationKeys: [String] { get { From e1859637a86178b85084f9a7a4e92b01692dcd13 Mon Sep 17 00:00:00 2001 From: Tomas Timinskas Date: Mon, 31 Aug 2026 13:18:28 -0300 Subject: [PATCH 4/4] New build 313 --- .../Dashboard/Custom Views/Base.lproj/NewMenuListView.xib | 2 +- sphinx.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Custom Views/Base.lproj/NewMenuListView.xib b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Custom Views/Base.lproj/NewMenuListView.xib index 21f0f283..7efd9fff 100644 --- a/com.stakwork.sphinx.desktop/Scenes/Dashboard/Custom Views/Base.lproj/NewMenuListView.xib +++ b/com.stakwork.sphinx.desktop/Scenes/Dashboard/Custom Views/Base.lproj/NewMenuListView.xib @@ -188,7 +188,7 @@ - + diff --git a/sphinx.xcodeproj/project.pbxproj b/sphinx.xcodeproj/project.pbxproj index 0112cbda..dd2f1095 100644 --- a/sphinx.xcodeproj/project.pbxproj +++ b/sphinx.xcodeproj/project.pbxproj @@ -5911,7 +5911,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 313; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 8297M44YTW; @@ -5948,7 +5948,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 313; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 8297M44YTW; ENABLE_HARDENED_RUNTIME = YES;