Canvas: simplify show + report status

This commit is contained in:
Peter Steinberger
2025-12-17 10:37:35 +01:00
parent 43e257e7de
commit c5867b2876
7 changed files with 187 additions and 69 deletions

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 <id> --command <name> [--params-json <json>]
Canvas:
clawdis-mac canvas show [--session <key>] [--path </...>]
clawdis-mac canvas show [--session <key>] [--target </...|https://...>]
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
clawdis-mac canvas hide [--session <key>]
clawdis-mac canvas goto --path </...> [--session <key>]
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
clawdis-mac canvas eval --js <code> [--session <key>]
clawdis-mac canvas snapshot [--out <path>] [--session <key>]

View File

@@ -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/<session>/`).
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)

View File

@@ -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"))