From c5867b287692efeac7624b60a21277563b1419ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 10:37:35 +0100 Subject: [PATCH] Canvas: simplify show + report status --- .../macos/Sources/Clawdis/CanvasManager.swift | 130 ++++++++++++++++-- apps/macos/Sources/Clawdis/CanvasWindow.swift | 4 - .../Clawdis/ControlRequestHandler.swift | 22 +-- .../macos/Sources/Clawdis/DebugSettings.swift | 2 +- .../macos/Sources/ClawdisCLI/ClawdisCLI.swift | 35 ++--- apps/macos/Sources/ClawdisIPC/IPC.swift | 55 ++++++-- .../ControlRequestHandlerTests.swift | 8 -- 7 files changed, 187 insertions(+), 69 deletions(-) diff --git a/apps/macos/Sources/Clawdis/CanvasManager.swift b/apps/macos/Sources/Clawdis/CanvasManager.swift index b43f5e65b..171f1fcf5 100644 --- a/apps/macos/Sources/Clawdis/CanvasManager.swift +++ b/apps/macos/Sources/Clawdis/CanvasManager.swift @@ -18,13 +18,38 @@ final class CanvasManager { }() func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String { + try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory + } + + func showDetailed(sessionKey: String, target: String? = nil, placement: CanvasPlacement? = nil) throws -> CanvasShowResult { let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedTarget = target? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + + let isWebTarget = Self.isWebTarget(normalizedTarget) + if let controller = self.panelController, self.panelSessionKey == session { controller.presentAnchoredPanel(anchorProvider: anchorProvider) controller.applyPreferredPlacement(placement) - controller.goto(path: path ?? "/") - return controller.directoryPath + + // Existing session: only navigate when an explicit target was provided. + if let normalizedTarget { + controller.goto(path: normalizedTarget) + return self.makeShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: normalizedTarget, + isWebTarget: isWebTarget) + } + + return CanvasShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: nil, + status: .shown, + url: nil) } self.panelController?.close() @@ -39,8 +64,16 @@ final class CanvasManager { self.panelController = controller self.panelSessionKey = session controller.applyPreferredPlacement(placement) - controller.showCanvas(path: path ?? "/") - return controller.directoryPath + + // New session: default to "/" so the user sees either the welcome page or `index.html`. + let effectiveTarget = normalizedTarget ?? "/" + controller.showCanvas(path: effectiveTarget) + + return self.makeShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: effectiveTarget, + isWebTarget: isWebTarget) } func hide(sessionKey: String) { @@ -53,10 +86,6 @@ final class CanvasManager { self.panelController?.hideCanvas() } - func goto(sessionKey: String, path: String, placement: CanvasPlacement? = nil) throws { - _ = try self.show(sessionKey: sessionKey, path: path, placement: placement) - } - func eval(sessionKey: String, javaScript: String) async throws -> String { _ = try self.show(sessionKey: sessionKey, path: nil) guard let controller = self.panelController else { return "" } @@ -79,4 +108,89 @@ final class CanvasManager { } // placement interpretation is handled by the window controller. + + // MARK: - Helpers + + private static func isWebTarget(_ target: String?) -> Bool { + guard let target, let url = URL(string: target), let scheme = url.scheme?.lowercased() else { return false } + return scheme == "https" || scheme == "http" + } + + private func makeShowResult( + directory: String, + target: String?, + effectiveTarget: String, + isWebTarget: Bool) -> CanvasShowResult + { + if isWebTarget, let url = URL(string: effectiveTarget) { + return CanvasShowResult( + directory: directory, + target: target, + effectiveTarget: effectiveTarget, + status: .web, + url: url.absoluteString) + } + + let sessionDir = URL(fileURLWithPath: directory) + let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget) + let host = sessionDir.lastPathComponent + let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString + return CanvasShowResult( + directory: directory, + target: target, + effectiveTarget: effectiveTarget, + status: status, + url: canvasURL) + } + + private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { + let fm = FileManager.default + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? trimmed + var path = withoutQuery + if path.hasPrefix("/") { path.removeFirst() } + path = path.removingPercentEncoding ?? path + + // Root special-case: welcome page when no index exists. + if path.isEmpty { + let a = sessionDir.appendingPathComponent("index.html", isDirectory: false) + let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false) + if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok } + return .welcome + } + + // Direct file or directory. + var candidate = sessionDir.appendingPathComponent(path, isDirectory: false) + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { + if isDir.boolValue { + return Self.indexExists(in: candidate) ? .ok : .notFound + } + return .ok + } + + // Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists. + if !path.isEmpty, !path.hasSuffix("/") { + candidate = sessionDir.appendingPathComponent(path, isDirectory: true) + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + return Self.indexExists(in: candidate) ? .ok : .notFound + } + } + + return .notFound + } + + private static func indexExists(in dir: URL) -> Bool { + let fm = FileManager.default + let a = dir.appendingPathComponent("index.html", isDirectory: false) + if fm.fileExists(atPath: a.path) { return true } + let b = dir.appendingPathComponent("index.htm", isDirectory: false) + return fm.fileExists(atPath: b.path) + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } } diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index fbfb442b7..094df92bd 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -101,8 +101,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS self.presentAnchoredPanel(anchorProvider: anchorProvider) if let path { self.goto(path: path) - } else { - self.goto(path: "/") } return } @@ -112,8 +110,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS NSApp.activate(ignoringOtherApps: true) if let path { self.goto(path: path) - } else { - self.goto(path: "/") } self.onVisibilityChanged?(true) } diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 42b4e4b3d..1e4e9a748 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -61,9 +61,6 @@ enum ControlRequestHandler { case let .canvasHide(session): return await self.handleCanvasHide(session: session) - case let .canvasGoto(session, path, placement): - return await self.handleCanvasGoto(session: session, path: path, placement: placement) - case let .canvasEval(session, javaScript): return await self.handleCanvasEval(session: session, javaScript: javaScript) @@ -196,10 +193,11 @@ enum ControlRequestHandler { { guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } do { - let dir = try await MainActor.run { - try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement) + let res = try await MainActor.run { + try CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement) } - return Response(ok: true, message: dir) + let payload = try? JSONEncoder().encode(res) + return Response(ok: true, message: res.directory, payload: payload) } catch { return Response(ok: false, message: error.localizedDescription) } @@ -210,18 +208,6 @@ enum ControlRequestHandler { return Response(ok: true) } - private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response { - guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } - do { - try await MainActor.run { - try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement) - } - return Response(ok: true) - } catch { - return Response(ok: false, message: error.localizedDescription) - } - } - private static func handleCanvasEval(session: String, javaScript: String) async -> Response { guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") } do { diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index e2f3b5f87..e74db78a8 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -830,7 +830,7 @@ extension DebugSettings { """ try html.write(to: url, atomically: true, encoding: .utf8) self.canvasStatus = "wrote: \(url.path)" - try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/") + _ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") } catch { self.canvasError = error.localizedDescription } diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift index 2f4b3c3e1..90cea7ae1 100644 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift @@ -243,10 +243,10 @@ struct ClawdisCLI { switch sub { case "show": var session = "main" - var path: String? - let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) + var target: String? + let placement = self.parseCanvasPlacement(args: &args, session: &session, target: &target) return ParsedCLIRequest( - request: .canvasShow(session: session, path: path, placement: placement), + request: .canvasShow(session: session, path: target, placement: placement), kind: .generic) case "hide": var session = "main" @@ -258,14 +258,6 @@ struct ClawdisCLI { } } return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic) - case "goto": - var session = "main" - var path: String? - let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path) - guard let path else { throw CLIError.help } - return ParsedCLIRequest( - request: .canvasGoto(session: session, path: path, placement: placement), - kind: .generic) case "eval": var session = "main" var js: String? @@ -359,7 +351,7 @@ struct ClawdisCLI { private static func parseCanvasPlacement( args: inout [String], session: inout String, - path: inout String?) -> CanvasPlacement? + target: inout String?) -> CanvasPlacement? { var x: Double? var y: Double? @@ -369,7 +361,7 @@ struct ClawdisCLI { let arg = args.removeFirst() switch arg { case "--session": session = args.popFirst() ?? session - case "--path": path = args.popFirst() + case "--target", "--path": target = args.popFirst() case "--x": x = args.popFirst().flatMap(Double.init) case "--y": y = args.popFirst().flatMap(Double.init) case "--width": width = args.popFirst().flatMap(Double.init) @@ -388,6 +380,19 @@ struct ClawdisCLI { return } + if case .canvasShow = parsed.request { + if let message = response.message, !message.isEmpty { + FileHandle.standardOutput.write(Data((message + "\n").utf8)) + } + if let payload = response.payload, let info = try? JSONDecoder().decode(CanvasShowResult.self, from: payload) { + FileHandle.standardOutput.write(Data(("STATUS:\(info.status.rawValue)\n").utf8)) + if let url = info.url, !url.isEmpty { + FileHandle.standardOutput.write(Data(("URL:\(url)\n").utf8)) + } + } + return + } + switch parsed.kind { case .generic: if let payload = response.payload, let text = String(data: payload, encoding: .utf8), !text.isEmpty { @@ -468,11 +473,9 @@ struct ClawdisCLI { clawdis-mac node invoke --node --command [--params-json ] Canvas: - clawdis-mac canvas show [--session ] [--path ] + clawdis-mac canvas show [--session ] [--target ] [--x --y ] [--width --height ] clawdis-mac canvas hide [--session ] - clawdis-mac canvas goto --path [--session ] - [--x --y ] [--width --height ] clawdis-mac canvas eval --js [--session ] clawdis-mac canvas snapshot [--out ] [--session ] diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 6aadc0185..f03ed4a98 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -55,6 +55,47 @@ public struct CanvasPlacement: Codable, Sendable { } } +// MARK: - Canvas show result + +public enum CanvasShowStatus: String, Codable, Sendable { + /// Panel was shown, but no navigation occurred (no target passed and session already existed). + case shown + /// Target was an http(s) URL. + case web + /// Local canvas target resolved to an existing file. + case ok + /// Local canvas target did not resolve to a file (404 page). + case notFound + /// Local canvas root ("/") has no index, so the welcome page is shown. + case welcome +} + +public struct CanvasShowResult: Codable, Sendable { + /// Session directory on disk (e.g. `~/Library/Application Support/Clawdis/canvas//`). + public var directory: String + /// Target as provided by the caller (may be nil/empty). + public var target: String? + /// Target actually navigated to (nil when no navigation occurred; defaults to "/" for a newly created session). + public var effectiveTarget: String? + public var status: CanvasShowStatus + /// URL that was loaded (nil when no navigation occurred). + public var url: String? + + public init( + directory: String, + target: String?, + effectiveTarget: String?, + status: CanvasShowStatus, + url: String?) + { + self.directory = directory + self.target = target + self.effectiveTarget = effectiveTarget + self.status = status + self.url = url + } +} + public enum Request: Sendable { case notify( title: String, @@ -74,7 +115,6 @@ public enum Request: Sendable { case rpcStatus case canvasShow(session: String, path: String?, placement: CanvasPlacement?) case canvasHide(session: String) - case canvasGoto(session: String, path: String, placement: CanvasPlacement?) case canvasEval(session: String, javaScript: String) case canvasSnapshot(session: String, outPath: String?) case nodeList @@ -131,7 +171,6 @@ extension Request: Codable { case rpcStatus case canvasShow case canvasHide - case canvasGoto case canvasEval case canvasSnapshot case nodeList @@ -188,12 +227,6 @@ extension Request: Codable { try container.encode(Kind.canvasHide, forKey: .type) try container.encode(session, forKey: .session) - case let .canvasGoto(session, path, placement): - try container.encode(Kind.canvasGoto, forKey: .type) - try container.encode(session, forKey: .session) - try container.encode(path, forKey: .path) - try container.encodeIfPresent(placement, forKey: .placement) - case let .canvasEval(session, javaScript): try container.encode(Kind.canvasEval, forKey: .type) try container.encode(session, forKey: .session) @@ -278,12 +311,6 @@ extension Request: Codable { let session = try container.decode(String.self, forKey: .session) self = .canvasHide(session: session) - case .canvasGoto: - let session = try container.decode(String.self, forKey: .session) - let path = try container.decode(String.self, forKey: .path) - let placement = try container.decodeIfPresent(CanvasPlacement.self, forKey: .placement) - self = .canvasGoto(session: session, path: path, placement: placement) - case .canvasEval: let session = try container.decode(String.self, forKey: .session) let javaScript = try container.decode(String.self, forKey: .javaScript) diff --git a/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift b/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift index 892e4b806..9ac3d0943 100644 --- a/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/ControlRequestHandlerTests.swift @@ -146,14 +146,6 @@ struct ControlRequestHandlerTests { #expect(show.ok == false) #expect(show.message == "Canvas disabled by user") - let goto = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { - try await Self.withDefaultOverride(canvasEnabledKey, value: false) { - try await ControlRequestHandler.process(request: .canvasGoto(session: "s", path: "/tmp", placement: nil)) - } - } - #expect(goto.ok == false) - #expect(goto.message == "Canvas disabled by user") - let eval = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) { try await Self.withDefaultOverride(canvasEnabledKey, value: false) { try await ControlRequestHandler.process(request: .canvasEval(session: "s", javaScript: "1+1"))