iOS: allow Tailnet/MagicDNS canvas actions

This commit is contained in:
Peter Steinberger
2025-12-19 00:52:52 +01:00
parent d6c5ee86c5
commit 0e3e4f269d
4 changed files with 34 additions and 17 deletions

View File

@@ -6,11 +6,6 @@ import WebKit
@MainActor
@Observable
final class ScreenController {
enum Mode: Sendable {
case canvas
case web
}
let webView: WKWebView
private let navigationDelegate: ScreenNavigationDelegate
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
@@ -18,10 +13,6 @@ final class ScreenController {
var urlString: String = ""
var errorText: String?
var mode: Mode {
self.urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .canvas : .web
}
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
var onDeepLink: ((URL) -> Void)?
@@ -65,10 +56,15 @@ final class ScreenController {
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
guard let url = Self.canvasScaffoldURL else { return }
self.errorText = nil
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
return
} else {
guard let url = URL(string: trimmed) else { return }
guard let url = URL(string: trimmed) else {
self.errorText = "Invalid URL: \(trimmed)"
return
}
self.errorText = nil
if url.isFileURL {
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
} else {
@@ -221,6 +217,10 @@ final class ScreenController {
}
if host == "localhost" { return true }
if host.hasSuffix(".local") { return true }
if host.hasSuffix(".ts.net") { return true }
if host.hasSuffix(".tailscale.net") { return true }
// Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
if !host.contains("."), !host.contains(":") { return true }
if let ipv4 = Self.parseIPv4(host) {
return Self.isLocalNetworkIPv4(ipv4)
}
@@ -306,6 +306,22 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
decisionHandler(.allow)
}
func webView(
_: WKWebView,
didFailProvisionalNavigation _: WKNavigation?,
withError error: any Error)
{
Task { @MainActor in
self.controller?.errorText = error.localizedDescription
}
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
Task { @MainActor in
self.controller?.errorText = error.localizedDescription
}
}
}
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {

View File

@@ -21,5 +21,5 @@ struct ScreenTab: View {
}
}
// Navigation/mode selection is agent-driven; no local controls here.
// Navigation is agent-driven; no local URL bar here.
}

View File

@@ -6,7 +6,6 @@ import WebKit
@Test @MainActor func canvasModeConfiguresWebViewForTouch() {
let screen = ScreenController()
#expect(screen.mode == .canvas)
#expect(screen.webView.isOpaque == true)
#expect(screen.webView.backgroundColor == .black)
@@ -17,11 +16,11 @@ import WebKit
#expect(scrollView.bounces == false)
}
@Test @MainActor func navigateDefaultsToWebMode() {
@Test @MainActor func navigateSlashShowsDefaultCanvas() {
let screen = ScreenController()
screen.navigate(to: "not a url")
screen.navigate(to: "/")
#expect(screen.mode == .web)
#expect(screen.urlString.isEmpty)
}
@Test @MainActor func evalExecutesJavaScript() async throws {
@@ -46,6 +45,8 @@ import WebKit
let screen = ScreenController()
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://clawd.local:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18793/")!) == true) // Tailscale CGNAT

View File

@@ -151,7 +151,7 @@ When iOS is backgrounded:
### App structure
- Single fullscreen Canvas surface (WKWebView).
- One settings entry point: a **gear button** that opens a settings sheet.
- All navigation/mode selection is **agent-driven** (no local URL bar).
- All navigation is **agent-driven** (no local URL bar).
### Components
- `BridgeDiscovery`: Bonjour browse + resolve (Network.framework `NWBrowser`)
@@ -232,4 +232,4 @@ open Clawdis.xcodeproj
## Open questions
- Should `connect.params.client.mode` be `"node"` with `platform="ios ..."` or a distinct mode `"ios-node"`? (Presence filtering currently excludes `"cli"` only.)
- Do we want a “permissions” model per node (voice only vs voice+canvas) at pairing time?
- Should “website mode” allow arbitrary https, or enforce an allowlist to reduce risk?
- Should loading arbitrary websites via `canvas.navigate` allow any https URL, or enforce an allowlist to reduce risk?