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 @MainActor
@Observable @Observable
final class ScreenController { final class ScreenController {
enum Mode: Sendable {
case canvas
case web
}
let webView: WKWebView let webView: WKWebView
private let navigationDelegate: ScreenNavigationDelegate private let navigationDelegate: ScreenNavigationDelegate
private let a2uiActionHandler: CanvasA2UIActionMessageHandler private let a2uiActionHandler: CanvasA2UIActionMessageHandler
@@ -18,10 +13,6 @@ final class ScreenController {
var urlString: String = "" var urlString: String = ""
var errorText: 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 /// Callback invoked when a clawdis:// deep link is tapped in the canvas
var onDeepLink: ((URL) -> Void)? var onDeepLink: ((URL) -> Void)?
@@ -65,10 +56,15 @@ final class ScreenController {
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { if trimmed.isEmpty {
guard let url = Self.canvasScaffoldURL else { return } guard let url = Self.canvasScaffoldURL else { return }
self.errorText = nil
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
return return
} else { } 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 { if url.isFileURL {
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
} else { } else {
@@ -221,6 +217,10 @@ final class ScreenController {
} }
if host == "localhost" { return true } if host == "localhost" { return true }
if host.hasSuffix(".local") { 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) { if let ipv4 = Self.parseIPv4(host) {
return Self.isLocalNetworkIPv4(ipv4) return Self.isLocalNetworkIPv4(ipv4)
} }
@@ -306,6 +306,22 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
decisionHandler(.allow) 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 { 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() { @Test @MainActor func canvasModeConfiguresWebViewForTouch() {
let screen = ScreenController() let screen = ScreenController()
#expect(screen.mode == .canvas)
#expect(screen.webView.isOpaque == true) #expect(screen.webView.isOpaque == true)
#expect(screen.webView.backgroundColor == .black) #expect(screen.webView.backgroundColor == .black)
@@ -17,11 +16,11 @@ import WebKit
#expect(scrollView.bounces == false) #expect(scrollView.bounces == false)
} }
@Test @MainActor func navigateDefaultsToWebMode() { @Test @MainActor func navigateSlashShowsDefaultCanvas() {
let screen = ScreenController() 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 { @Test @MainActor func evalExecutesJavaScript() async throws {
@@ -46,6 +45,8 @@ import WebKit
let screen = ScreenController() let screen = ScreenController()
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18793/")!) == true) #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://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://192.168.0.10:18793/")!) == true)
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.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 #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 ### App structure
- Single fullscreen Canvas surface (WKWebView). - Single fullscreen Canvas surface (WKWebView).
- One settings entry point: a **gear button** that opens a settings sheet. - 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 ### Components
- `BridgeDiscovery`: Bonjour browse + resolve (Network.framework `NWBrowser`) - `BridgeDiscovery`: Bonjour browse + resolve (Network.framework `NWBrowser`)
@@ -232,4 +232,4 @@ open Clawdis.xcodeproj
## Open questions ## Open questions
- Should `connect.params.client.mode` be `"node"` with `platform="ios ..."` or a distinct mode `"ios-node"`? (Presence filtering currently excludes `"cli"` only.) - 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? - 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?