diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index fea87a022..ca94a727b 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -403,9 +403,24 @@ final class NodeAppModel { case ClawdisCanvasCommand.snapshot.rawValue: let params = try? Self.decodeParams(ClawdisCanvasSnapshotParams.self, from: req.paramsJSON) - let maxWidth = params?.maxWidth.map { CGFloat($0) } - let base64 = try await self.screen.snapshotPNGBase64(maxWidth: maxWidth) - let payload = try Self.encodePayload(["format": "png", "base64": base64]) + let format = params?.format ?? .jpeg + let maxWidth: CGFloat? = { + if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) } + // Keep default snapshots comfortably below the gateway client's maxPayload. + // For full-res, clients should explicitly request a larger maxWidth. + return switch format { + case .png: 900 + case .jpeg: 1600 + } + }() + let base64 = try await self.screen.snapshotBase64( + maxWidth: maxWidth, + format: format, + quality: params?.quality) + let payload = try Self.encodePayload([ + "format": format == .jpeg ? "jpeg" : "png", + "base64": base64, + ]) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) case ClawdisCanvasA2UICommand.reset.rawValue: diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 11598a8d1..7b8de9b38 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -147,11 +147,54 @@ final class ScreenController { return data.base64EncodedString() } + func snapshotBase64( + maxWidth: CGFloat? = nil, + format: ClawdisCanvasSnapshotFormat, + quality: Double? = nil) async throws -> String + { + let config = WKSnapshotConfiguration() + if let maxWidth { + config.snapshotWidth = NSNumber(value: Double(maxWidth)) + } + let image: UIImage = try await withCheckedThrowingContinuation { cont in + self.webView.takeSnapshot(with: config) { image, error in + if let error { + cont.resume(throwing: error) + return + } + guard let image else { + cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "snapshot failed", + ])) + return + } + cont.resume(returning: image) + } + } + + let data: Data? + switch format { + case .png: + data = image.pngData() + case .jpeg: + let q = (quality ?? 0.82).clamped(to: 0.1...1.0) + data = image.jpegData(compressionQuality: q) + } + guard let data else { + throw NSError(domain: "Screen", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "snapshot encode failed", + ]) + } + return data.base64EncodedString() + } + // SwiftPM flattens resource directories; ensure resource filenames are unique. private static let canvasScaffoldURL: URL? = ClawdisKitResources.bundle.url( forResource: "scaffold", withExtension: "html") - private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url(forResource: "index", withExtension: "html") + private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url( + forResource: "index", + withExtension: "html") fileprivate func isTrustedCanvasUIURL(_ url: URL) -> Bool { guard url.isFileURL else { return false } @@ -170,6 +213,14 @@ final class ScreenController { } } +extension Double { + fileprivate func clamped(to range: ClosedRange) -> Double { + if self < range.lowerBound { return range.lowerBound } + if self > range.upperBound { return range.upperBound } + return self + } +} + // MARK: - Navigation Delegate /// Handles navigation policy to intercept clawdis:// deep links from canvas