diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4fd887ef08f2..4cb8f0624728 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1034,18 +1034,18 @@ final class NodeAppModel { OpenClawCanvasPresentParams() let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if url.isEmpty { - self.screen.showDefaultCanvas() + self.screen.presentDefaultCanvas() } else { - self.screen.navigate(to: url) + self.screen.present(urlString: url) } return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.hide.rawValue: - self.screen.showDefaultCanvas() + self.screen.hideCanvas() return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.navigate.rawValue: let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) let trimmedURL = params.url.trimmingCharacters(in: .whitespacesAndNewlines) - self.screen.navigate(to: trimmedURL) + self.screen.present(urlString: trimmedURL) return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.evalJS.rawValue: let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON) diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 7a8c98767823..6e9f9e3dfa7e 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -200,6 +200,36 @@ struct RootTabs: View { RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) } } + .overlay { + if self.appModel.screen.isCanvasPresented { + self.canvasPresentationOverlay + .transition(.opacity) + .zIndex(20) + } + } + } + + private var canvasPresentationOverlay: some View { + ZStack(alignment: .topTrailing) { + Color.black.ignoresSafeArea() + ScreenWebView(controller: self.appModel.screen) + .ignoresSafeArea() + Button { + self.appModel.screen.hideCanvas() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 30, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.32), radius: 8, y: 2) + .frame(width: 48, height: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close canvas") + .safeAreaPadding(.top, 8) + .padding(.trailing, 12) + } } private func rootLifecycle(_ content: some View) -> some View { diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 6432849a5d49..6cb60a973188 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -10,6 +10,7 @@ final class ScreenController { var urlString: String = "" var errorText: String? + var isCanvasPresented: Bool = false /// Callback invoked when an openclaw:// deep link is tapped in the canvas var onDeepLink: ((URL) -> Void)? @@ -75,7 +76,23 @@ final class ScreenController { self.reload() } + func presentDefaultCanvas() { + self.isCanvasPresented = true + self.showDefaultCanvas() + } + + func present(urlString: String) { + self.isCanvasPresented = true + self.navigate(to: urlString) + } + + func hideCanvas() { + self.isCanvasPresented = false + self.showDefaultCanvas() + } + func showLocalA2UI() { + self.isCanvasPresented = true guard let url = Self.localA2UIURL else { self.showDefaultCanvas() return diff --git a/apps/ios/Tests/ScreenControllerTests.swift b/apps/ios/Tests/ScreenControllerTests.swift index d4b220e35de4..c145f0d48063 100644 --- a/apps/ios/Tests/ScreenControllerTests.swift +++ b/apps/ios/Tests/ScreenControllerTests.swift @@ -45,6 +45,23 @@ private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoo #expect(screen.urlString.isEmpty) } + @Test @MainActor func canvasPresentationTracksExplicitPresentAndHide() { + let screen = ScreenController() + + #expect(screen.isCanvasPresented == false) + + screen.showDefaultCanvas() + #expect(screen.isCanvasPresented == false) + + screen.presentDefaultCanvas() + #expect(screen.isCanvasPresented == true) + #expect(screen.urlString.isEmpty) + + screen.hideCanvas() + #expect(screen.isCanvasPresented == false) + #expect(screen.urlString.isEmpty) + } + @Test @MainActor func evalExecutesJavaScript() async throws { let screen = ScreenController() let (coordinator, _) = try mountScreen(screen)