Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 177 additions & 44 deletions com.stakwork.sphinx.desktop/API/API+ContentItemsExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,68 +7,142 @@
//

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,
method: "POST"
) 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 "<empty body>" }

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<AnyBox, Error>) 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)
}
}
Expand All @@ -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:
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down
37 changes: 26 additions & 11 deletions com.stakwork.sphinx.desktop/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,24 +167,30 @@ 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")
statusBarItem.button?.imageScaling = .scaleProportionallyDown
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()
Expand Down Expand Up @@ -334,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()
}
}
Expand Down Expand Up @@ -525,7 +538,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
Expand Down
2 changes: 1 addition & 1 deletion com.stakwork.sphinx.desktop/Configuration/UserData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading