diff --git a/Cargo.lock b/Cargo.lock index d8c6b77dd..6de5e3279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" dependencies = [ "accesskit", "accesskit_consumer 0.36.0", - "atspi-common", + "atspi-common 0.13.0", "phf", "serde", "zvariant", @@ -81,7 +81,7 @@ dependencies = [ "async-channel", "async-executor", "async-task", - "atspi", + "atspi 0.29.0", "futures-lite", "futures-util", "serde", @@ -673,8 +673,20 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" dependencies = [ - "atspi-common", - "atspi-proxies", + "atspi-common 0.13.0", + "atspi-proxies 0.13.0", +] + +[[package]] +name = "atspi" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf601cccedfffec598ec2db1f9d6745885458bccc0e8916d7023f017c94b3d0" +dependencies = [ + "atspi-common 0.14.0", + "atspi-connection", + "atspi-proxies 0.14.0", + "zbus", ] [[package]] @@ -693,13 +705,52 @@ dependencies = [ "zvariant", ] +[[package]] +name = "atspi-common" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a79bed3f5b408ce3152f36e07327a845e6ed5d7e2821a89264037dbcc11daf" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fab8e4f574f5a7d3af280b38eff25fb6f47a537dac9ae39ce152f52b19fb10b" +dependencies = [ + "atspi-common 0.14.0", + "atspi-proxies 0.14.0", + "futures-lite", + "zbus", +] + [[package]] name = "atspi-proxies" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ - "atspi-common", + "atspi-common 0.13.0", + "serde", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53403acd3ab2fdb5914f6558da22e540fc07656fce5510f8c02be0e6ef68413e" +dependencies = [ + "atspi-common 0.14.0", "serde", "zbus", ] @@ -3374,7 +3425,7 @@ dependencies = [ "wayland-protocols-plasma", "wayland-protocols-wlr", "x11-clipboard", - "x11rb", + "x11rb 0.13.2", "xkbcommon", "zed-scap", "zed-xim", @@ -11159,7 +11210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" dependencies = [ "libc", - "x11rb", + "x11rb 0.13.2", ] [[package]] @@ -11172,16 +11223,33 @@ dependencies = [ "gethostname", "libc", "rustix 1.1.4", - "x11rb-protocol", + "x11rb-protocol 0.13.2", "xcursor", ] +[[package]] +name = "x11rb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol 0.14.0", +] + [[package]] name = "x11rb-protocol" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x11rb-protocol" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -11532,7 +11600,7 @@ dependencies = [ "ahash", "hashbrown 0.14.5", "log", - "x11rb", + "x11rb 0.13.2", "xim-ctext", "xim-parser", ] @@ -11830,11 +11898,18 @@ version = "0.2.61" dependencies = [ "alacritty_terminal", "anyhow", + "ashpd", + "async-io", "async-trait", + "atspi 0.30.0", "base64 0.22.1", + "block", "block2 0.6.2", "chrono", + "core-foundation 0.10.1", + "core-graphics 0.24.0", "fontdb", + "foreign-types", "futures", "gpui", "gpui-base", @@ -11849,9 +11924,11 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-web-kit", + "png 0.18.1", "pulldown-cmark", "raw-window-handle", "reqwest", + "roxmltree 0.21.1", "serde", "serde_json", "sha2", @@ -11868,6 +11945,7 @@ dependencies = [ "usvg", "uuid", "wry", + "x11rb 0.14.0", "zeron-doc", "zeron-engine", "zeron-harness", diff --git a/apps/ios/Zeron/App/DemoDataset.swift b/apps/ios/Zeron/App/DemoDataset.swift index a86c99d5a..710d77d77 100644 --- a/apps/ios/Zeron/App/DemoDataset.swift +++ b/apps/ios/Zeron/App/DemoDataset.swift @@ -5,6 +5,7 @@ import Foundation import Observation +import UIKit @MainActor @Observable @@ -181,7 +182,9 @@ final class DemoDataset { func sessionStore(for chatId: String) -> SessionStore { if let existing = stores[chatId] { return existing } let store = SessionStore(chatId: chatId, config: Self.dummyConfig, offline: true) - if ProcessInfo.processInfo.arguments.contains("-longprompt") { + if ProcessInfo.processInfo.arguments.contains("-appshots") { + seedAppshots(store) + } else if ProcessInfo.processInfo.arguments.contains("-longprompt") { store.setEntries([ MessageEntry(id: "long-prompt", role: .user, parts: [.text(id: "t0", text: (1...18).map { "Requirement \($0): keep the transcript visible through every keyboard, streaming, and navigation transition." }.joined(separator: "\n"))], @@ -201,6 +204,39 @@ final class DemoDataset { return store } + /// Neutral, offline captures for native simulator presentation checks. + private func seedAppshots(_ store: SessionStore) { + let names = ["Safari", "Notes", "Finder"] + let titles = ["Fieldnotes · Product planning", "Design review · Notes", "Workspace ideas"] + let sizes = [CGSize(width: 960, height: 540), CGSize(width: 400, height: 800), CGSize(width: 600, height: 600)] + let paths = ["/demo/appshot-wide.png", "/demo/appshot-tall.png", "/demo/appshot-square.png"] + var context = AppshotContext.marker + "\n" + for index in names.indices { + let size = sizes[index] + let format = UIGraphicsImageRendererFormat(); format.scale = 1 + let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in + UIColor(red: 0.96, green: 0.96, blue: 0.93, alpha: 1).setFill() + UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill() + let ink = UIColor(red: 0.19, green: 0.30, blue: 0.23, alpha: 1) + ("Make room for good ideas." as NSString).draw(in: CGRect(x: 24, y: 40, width: size.width - 48, height: 70), withAttributes: [.font: UIFont.systemFont(ofSize: 28, weight: .semibold), .foregroundColor: ink]) + for row in 0..<3 { + let rect = CGRect(x: 24, y: 150 + row * 110, width: Int(size.width) - 48, height: 90) + UIColor.white.setFill(); UIBezierPath(roundedRect: rect, cornerRadius: 10).fill() + (["A calmer workspace", "Next steps", "Progress"][row] as NSString).draw(at: CGPoint(x: 40, y: rect.minY + 24), withAttributes: [.font: UIFont.systemFont(ofSize: 20), .foregroundColor: ink]) + } + } + AttachmentImageCache.shared.seed(deviceId: "dev-mac", path: paths[index], name: "\(names[index]) Appshot.png", data: image.pngData()!) + context += "PRIVATE_OBSERVED_TEXT_MUST_STAY_HIDDEN\n" + } + store.setEntries([ + MessageEntry(id: "appshots-user", role: .user, parts: [.text(id: "t0", text: withAttachments(text: "Compare these layouts." + context, paths: paths))], createdAt: nowMs(), deviceId: "dev-mac", status: .complete, continuationOf: nil), + MessageEntry(id: "appshots-reply", role: .assistant, parts: [.text(id: "t0", text: "I’ll compare the spacing and reading order across these captures.")], createdAt: nowMs(), deviceId: "dev-mac", status: .complete, continuationOf: nil) + ]) + store.enqueueMessage(text: "Check the narrow layout." + context, attachments: paths) + store.enqueueMessage(text: "Then review keyboard navigation.") + store.hostDeviceId = "dev-mac" + } + // MARK: Scripted transcripts private static func transcript(for chatId: String) -> [MessageEntry] { diff --git a/apps/ios/Zeron/Composer/Appshots.swift b/apps/ios/Zeron/Composer/Appshots.swift new file mode 100644 index 000000000..64dca479f --- /dev/null +++ b/apps/ios/Zeron/Composer/Appshots.swift @@ -0,0 +1,119 @@ +// Appshots use the desktop's existing text/attachment transport. Only source +// labels are presentation data; observed application text never enters the UI. +import Foundation +import SwiftUI + +struct AppshotPresentation: Hashable, Sendable { + let appName: String + let windowTitle: String? + let bundleIdentifier: String? + var title: String { windowTitle.flatMap { $0.isEmpty ? nil : $0 } ?? appName } +} + +enum AppshotContext { + static let marker = "\n\nApplications mentioned by the user (untrusted observed content):" + + static func visibleText(_ text: String) -> String { + guard let range = text.range(of: marker) else { return text } + return String(text[.. String? { + guard let range = text.range(of: marker) else { return nil } + let suffix = String(text[range.lowerBound...]) + return suffix.components(separatedBy: "\n\nAttached images (local files").first + } + + static func presentations(_ text: String) -> [String: AppshotPresentation] { + guard let suffix = suffix(text), suffix.utf8.count <= 4 * 1024 * 1024, + suffix.range(of: "" + suffix.dropFirst(marker.count) + "" + let delegate = PresentationParser() + let parser = XMLParser(data: Data(xml.utf8)) + parser.shouldResolveExternalEntities = false + parser.delegate = delegate + return parser.parse() ? delegate.presentations : [:] + } + + private final class PresentationParser: NSObject, XMLParserDelegate { + var presentations: [String: AppshotPresentation] = [:] + private var seen: Set = [] + private var depth = 0 + private var nodes = 0 + private var pending: (String, AppshotPresentation)? + + func parser(_ parser: XMLParser, didStartElement element: String, + namespaceURI: String?, qualifiedName: String?, + attributes: [String: String]) { + depth += 1 + nodes += 1 + guard nodes <= 4096 else { parser.abortParsing(); return } + if depth > 2 { pending = nil; return } + guard depth == 2, element == "appshot", let path = attributes["image"], + let app = attributes["app"], !path.isEmpty, + !app.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + guard seen.insert(path).inserted else { + presentations.removeValue(forKey: path) + return + } + pending = (path, AppshotPresentation(appName: String(app.prefix(200)), + windowTitle: attributes["window-title"].map { String($0.prefix(512)) }, + bundleIdentifier: attributes["bundle-identifier"].map { String($0.prefix(256)) })) + } + + func parser(_ parser: XMLParser, didEndElement element: String, + namespaceURI: String?, qualifiedName: String?) { + if depth == 2, let (path, presentation) = pending { + presentations[path] = presentation + pending = nil + } + depth -= 1 + } + } +} + +/// Native iOS card. The app name remains useful when the desktop app's icon +/// is unavailable on the phone. Image loading uses the existing host relay. +struct AppshotCardView: View { + let deviceId: String + let attachment: UserImageAttachment + let source: AppshotPresentation + private let cache = AttachmentImageCache.shared + @State private var preview: AttachmentPreview? + + var body: some View { + Button { + if case .loaded(let name, let image) = cache.snapshot(deviceId: deviceId, path: attachment.path) { + preview = AttachmentPreview(name: name, image: image) + } else { cache.load(deviceId: deviceId, path: attachment.path) } + } label: { + VStack(spacing: 6) { + Group { + switch cache.snapshot(deviceId: deviceId, path: attachment.path) { + case .loaded(_, let image): + Image(uiImage: image).resizable().scaledToFit() + .mask(LinearGradient(stops: [.init(color: .black, location: 0), .init(color: .black, location: 0.72), .init(color: .clear, location: 1)], startPoint: .top, endPoint: .bottom)) + case .loading: ProgressView().tint(Theme.textMuted) + case .error: Label("Appshot unavailable", systemImage: "photo.badge.exclamationmark") + .font(Theme.sans(12)).foregroundStyle(Theme.textMuted) + } + } + .frame(height: 120) + Label("\(source.appName) · Appshot", systemImage: "macwindow") + .font(Theme.sans(11)).foregroundStyle(Theme.textMuted).lineLimit(1) + Text(source.title).font(Theme.sans(12.5, weight: .medium)) + .foregroundStyle(Theme.text).lineLimit(2).multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity).padding(8) + .contentShape(RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + .accessibilityLabel("Preview \(source.appName) Appshot: \(source.title)") + .accessibilityIdentifier("appshot-card-\(attachment.id)") + .task(id: "\(deviceId)|\(attachment.path)") { cache.load(deviceId: deviceId, path: attachment.path) } + .fullScreenCover(item: $preview) { AttachmentLightbox(preview: $0) } + } +} diff --git a/apps/ios/Zeron/Composer/Attachments.swift b/apps/ios/Zeron/Composer/Attachments.swift index 2e5c3336d..ffc4bb199 100644 --- a/apps/ios/Zeron/Composer/Attachments.swift +++ b/apps/ios/Zeron/Composer/Attachments.swift @@ -33,6 +33,7 @@ struct UserImageAttachment: Identifiable, Hashable { let id: String let path: String let name: String + var appshot: AppshotPresentation? = nil } struct ParsedUserMessage { @@ -48,6 +49,7 @@ private func nameFromPath(_ path: String) -> String { /// message-attachments.ts `parseUserMessageImages`: split the visible prompt /// from its attachment-ref trailer (case-insensitive marker, `- path` lines). func parseUserMessageImages(_ content: String) -> ParsedUserMessage { + let presentations = AppshotContext.presentations(content) let lines = content.components(separatedBy: "\n") var markerIx: Int? for (ix, raw) in lines.enumerated() where ix > 0 { @@ -60,7 +62,7 @@ func parseUserMessageImages(_ content: String) -> ParsedUserMessage { } } guard let markerIx else { - return ParsedUserMessage(text: content, attachments: []) + return ParsedUserMessage(text: AppshotContext.visibleText(content), attachments: []) } let attachments = lines[(markerIx + 1)...].compactMap { line -> String? in let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -68,14 +70,15 @@ func parseUserMessageImages(_ content: String) -> ParsedUserMessage { let path = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) return path.isEmpty ? nil : path }.enumerated().map { ix, path in - UserImageAttachment(id: "\(ix):\(path)", path: path, name: nameFromPath(path)) + UserImageAttachment(id: "\(ix):\(path)", path: path, name: nameFromPath(path), appshot: presentations[path]) } guard !attachments.isEmpty else { - return ParsedUserMessage(text: content, attachments: []) + return ParsedUserMessage(text: AppshotContext.visibleText(content), attachments: []) } let body = lines[..<(markerIx - 1)].joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines) - return ParsedUserMessage(text: body == attachmentOnlyText ? "" : body, + let visible = AppshotContext.visibleText(body) + return ParsedUserMessage(text: visible == attachmentOnlyText ? "" : visible, attachments: attachments) } @@ -464,16 +467,37 @@ struct UserAttachmentsStrip: View { let attachments: [UserImageAttachment] var body: some View { - HStack(spacing: 8) { - Spacer(minLength: 0) - ForEach(attachments) { att in - AttachmentThumbView(deviceId: deviceId, path: att.path) + if attachments.contains(where: { $0.appshot != nil }) { + // Each capture remains reachable on a phone, including mixed + // ordinary images. Fixed card width avoids squeezing three into + // one clipped row; scrolling does not alter transcript height. + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 8) { + ForEach(attachments) { att in + if let source = att.appshot { + AppshotCardView(deviceId: deviceId, attachment: att, source: source) + .frame(width: 220) + } else { + AttachmentThumbView(deviceId: deviceId, path: att.path) + } + } + } } + .defaultScrollAnchor(.trailing) + .frame(height: 196) + .accessibilityLabel("Appshots and images") + .accessibilityIdentifier("appshot-attachments") + } else { + HStack(spacing: 8) { + Spacer(minLength: 0) + ForEach(attachments) { att in + AttachmentThumbView(deviceId: deviceId, path: att.path) + } + } + .frame(height: 80) + .frame(maxWidth: .infinity, alignment: .trailing) + .clipped() } - // Fixed height: load-state flips never shift the transcript. - .frame(height: 80) - .frame(maxWidth: .infinity, alignment: .trailing) - .clipped() } } @@ -558,6 +582,15 @@ struct AttachmentLightbox: View { } .contentShape(Rectangle()) .onTapGesture { dismiss() } + .overlay(alignment: .topTrailing) { + Button { dismiss() } label: { + Image(systemName: "xmark").font(.system(size: 17, weight: .medium)) + .foregroundStyle(.white).frame(width: 44, height: 44) + .background(.black.opacity(0.6), in: Circle()) + } + .accessibilityLabel("Close image preview") + .padding(12) + } } .presentationBackground(.clear) } diff --git a/apps/ios/Zeron/Composer/QueuePanelView.swift b/apps/ios/Zeron/Composer/QueuePanelView.swift index 8deb7f0fc..1bf1feac3 100644 --- a/apps/ios/Zeron/Composer/QueuePanelView.swift +++ b/apps/ios/Zeron/Composer/QueuePanelView.swift @@ -29,9 +29,14 @@ struct QueuePanel: View { .foregroundStyle(Theme.textFaint) .padding(.leading, 4) } - ForEach(Array(queue.enumerated()), id: \.element.id) { index, item in - row(item, index: index, count: queue.count) + ScrollView { + LazyVStack(spacing: 6) { + ForEach(Array(queue.enumerated()), id: \.element.id) { index, item in + row(item, index: index, count: queue.count) + } + } } + .frame(height: CGFloat(min(queue.count, 3)) * 56 + CGFloat(max(0, min(queue.count, 3) - 1)) * 6) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) @@ -52,25 +57,32 @@ struct QueuePanel: View { ) } }() - let content = HStack(spacing: 8) { + let sources = AppshotContext.presentations(item.text) + let sourceNames = item.attachments.map { sources[$0].map { "\($0.appName) Appshot" } ?? "Image" } + let content = HStack(spacing: 6) { Text("\(index + 1)") .font(Theme.mono(10)) .foregroundStyle(Theme.textFaint) .frame(minWidth: 12, alignment: .trailing) - Text(displayText) - .font(Theme.sans(12.5)) - .foregroundStyle(editing ? Theme.textMuted : Theme.text) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - if !item.attachments.isEmpty { - Image(systemName: "paperclip") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(Theme.textFaint) + if let path = item.attachments.first, !editing { + QueueAttachmentPreview(deviceId: store.hostDeviceId ?? store.deviceId, + path: path, paths: item.attachments) } + VStack(alignment: .leading, spacing: 2) { + Text(displayText) + .font(Theme.sans(12.5)) + .foregroundStyle(editing ? Theme.textMuted : Theme.text) + .lineLimit(1) + if !sourceNames.isEmpty && !editing { + Text(sourceNames.joined(separator: " · ")) + .font(Theme.sans(10.5)).foregroundStyle(Theme.textMuted).lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) controls(item, index: index, count: count, editing: editing) } .padding(.horizontal, 10) - .padding(.vertical, 7) + .padding(.vertical, 6) .background(whiteAlpha(dragging == item.id ? 0.10 : 0.04), in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Theme.border, lineWidth: 1)) @@ -117,33 +129,29 @@ struct QueuePanel: View { if case .editing = item.deliveryGate { return !editing } return false }() - return HStack(spacing: 2) { - iconButton("chevron.up", label: "Move up", enabled: index > 0 && !gated) { - store.moveQueued(id: item.id, by: -1) - } - iconButton("chevron.down", label: "Move down", - enabled: index < count - 1 && !gated) { - store.moveQueued(id: item.id, by: 1) - } + return HStack(spacing: 0) { iconButton(editing ? "xmark" : "pencil", label: editing ? "Stop editing" : "Edit", enabled: !lockedByOther && !pending) { - if editing { - onCancelEdit() - } else { - onEdit(item) - } + if editing { onCancelEdit() } else { onEdit(item) } } - if let primary { - Button(primary.label) { onAction(item, primary) } - .font(Theme.sans(11, weight: .medium)) - .buttonStyle(.plain) - .accessibilityLabel("Send now, interrupting the response") + iconButton("arrow.right", label: "Send now, interrupting the response", + enabled: primary != nil) { + if let primary { onAction(item, primary) } } - iconButton("trash", label: "Remove", enabled: supportsActions && !pending, - tone: Theme.textFaint) { - onAction(item, .remove) + Menu { + Button("Move up", systemImage: "chevron.up") { store.moveQueued(id: item.id, by: -1) } + .disabled(index == 0 || gated) + Button("Move down", systemImage: "chevron.down") { store.moveQueued(id: item.id, by: 1) } + .disabled(index >= count - 1 || gated) + Button("Remove", systemImage: "trash", role: .destructive) { onAction(item, .remove) } + .disabled(!supportsActions || pending) + } label: { + Image(systemName: "ellipsis").font(.system(size: 13)) + .foregroundStyle(Theme.textMuted).frame(width: 44, height: 44) + .contentShape(Rectangle()) } + .accessibilityLabel("More queue actions") } } @@ -158,7 +166,7 @@ struct QueuePanel: View { Image(systemName: symbol) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(enabled ? tone : Theme.textFaint.opacity(0.4)) - .frame(width: 26, height: 26) + .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -166,3 +174,74 @@ struct QueuePanel: View { .accessibilityLabel(label) } } + +/// The queue holds a tiny decoded preview; opening it uses the shared full +/// image. Its task identity is stable across row updates and cache eviction. +private struct QueueAttachmentPreview: View { + let deviceId: String + let path: String + let paths: [String] + private var extraCount: Int { paths.count - 1 } + private let cache = AttachmentImageCache.shared + @State private var preview: AttachmentPreview? + @State private var thumbnail: UIImage? + @State private var gallery = false + + private var sourceImage: UIImage? { + if case .loaded(_, let image) = cache.snapshot(deviceId: deviceId, path: path) { return image } + return nil + } + + var body: some View { + Button { + if extraCount > 0 { gallery = true; return } + if case .loaded(let name, let image) = cache.snapshot(deviceId: deviceId, path: path) { + preview = AttachmentPreview(name: name, image: image) + } else { cache.load(deviceId: deviceId, path: path) } + } label: { + ZStack(alignment: .bottomTrailing) { + Group { + if let thumbnail { + Image(uiImage: thumbnail).resizable().scaledToFill() + } else { Image(systemName: "photo").foregroundStyle(Theme.textFaint) } + } + .frame(width: 40, height: 28).clipped() + if extraCount > 0 { + Text("+\(extraCount)").font(Theme.sans(9, weight: .medium)) + .padding(.horizontal, 3).background(Theme.bg.opacity(0.9), in: RoundedRectangle(cornerRadius: 3)) + .foregroundStyle(Theme.text) + } + } + .clipShape(RoundedRectangle(cornerRadius: 5)) + .frame(width: 40, height: 44).contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(extraCount > 0 ? "Preview image, \(extraCount) more attachments" : "Preview image") + .task(id: "\(deviceId)|\(path)") { cache.load(deviceId: deviceId, path: path) } + .task(id: sourceImage) { + if let image = sourceImage, let small = await image.byPreparingThumbnail(ofSize: CGSize(width: 80, height: 56)) { + thumbnail = small + } + } + .fullScreenCover(item: $preview) { AttachmentLightbox(preview: $0) } + .sheet(isPresented: $gallery) { + NavigationStack { + ScrollView(.horizontal) { + LazyHStack(spacing: 12) { + ForEach(paths, id: \.self) { path in + VStack(spacing: 8) { + AttachmentThumbView(deviceId: deviceId, path: path) + if case .loaded(let name, _) = cache.snapshot(deviceId: deviceId, path: path) { + Text(name).font(Theme.sans(11)).foregroundStyle(Theme.textMuted).lineLimit(1) + } + }.frame(width: 112) + } + }.padding(20) + } + .navigationTitle("Queued images") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { gallery = false } } } + }.presentationDetents([.height(240), .medium]) + } + } +} diff --git a/apps/ios/Zeron/Models/MessageQueue.swift b/apps/ios/Zeron/Models/MessageQueue.swift index 842c7e8cf..89fe0ef0f 100644 --- a/apps/ios/Zeron/Models/MessageQueue.swift +++ b/apps/ios/Zeron/Models/MessageQueue.swift @@ -69,7 +69,8 @@ struct QueueComposerEdit { } func textToCommit(_ text: String) -> String? { - MessageQueue.editedText(text, hasAttachments: hasAttachments) + guard let edited = MessageQueue.editedText(text, hasAttachments: hasAttachments) else { return nil } + return edited + (AppshotContext.suffix(lease.text) ?? "") } } @@ -84,9 +85,10 @@ enum MessageQueue { /// trailer. Strip it only for legacy rows whose parsed paths exactly match /// the separate attachments field. static func visibleText(_ text: String, attachments: [String]) -> String { - guard !attachments.isEmpty else { return text } + let visible = AppshotContext.visibleText(text) + guard !attachments.isEmpty else { return visible } let parsed = parseUserMessageImages(text) - guard parsed.attachments.map(\.path) == attachments else { return text } + guard parsed.attachments.map(\.path) == attachments else { return visible.isEmpty ? attachmentOnlyText : visible } return parsed.text.isEmpty ? attachmentOnlyText : parsed.text } diff --git a/apps/ios/ZeronTests/AppshotTests.swift b/apps/ios/ZeronTests/AppshotTests.swift new file mode 100644 index 000000000..8d622baa5 --- /dev/null +++ b/apps/ios/ZeronTests/AppshotTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import Zeron + +@MainActor +final class AppshotTests: XCTestCase { + private let context = AppshotContext.marker + "\nPrivate observed text" + private let path = "/host/shot & detail.png" + + func testDesktopAppshotTransportRendersSourceWithoutObservedText() { + let content = withAttachments(text: "Review this" + context, paths: [path, "/host/ordinary.png"]) + let parsed = parseUserMessageImages(content) + XCTAssertEqual(parsed.text, "Review this") + XCTAssertEqual(parsed.attachments.count, 2) + XCTAssertEqual(parsed.attachments[0].appshot?.appName, "Notes & Ideas") + XCTAssertEqual(parsed.attachments[0].appshot?.title, "Planning & review") + XCTAssertNil(parsed.attachments[1].appshot) + XCTAssertEqual(MessageQueue.visibleText("Review this" + context, attachments: [path]), "Review this") + XCTAssertEqual(MessageQueue.visibleText(content, attachments: [path, "/host/ordinary.png"]), "Review this") + } + + func testPhoneQueueEditKeepsContextAndAttachmentOnlyMessage() { + let lease = QueueEditLease(rowId: "row", leaseId: "lease", text: "Review this" + context, + baseTextHash: "hash", expiresAtMs: 60_000) + let edit = QueueComposerEdit(lease: lease, originalDraft: "My draft", hasAttachments: true) + XCTAssertEqual(edit.textToCommit("Changed prompt"), "Changed prompt" + context) + XCTAssertEqual(edit.textToCommit(" \n"), attachmentOnlyText + context) + XCTAssertEqual(edit.originalDraft, "My draft") + let parsed = parseUserMessageImages(withAttachments(text: edit.textToCommit("")!, paths: [path])) + XCTAssertEqual(parsed.text, "") + XCTAssertNotNil(parsed.attachments.first?.appshot) + } + + func testAmbiguousAndMalformedContextNeverBecomesAVisiblePrompt() { + let duplicate = "Look" + context + "\nOther text" + XCTAssertTrue(AppshotContext.presentations(duplicate).isEmpty) + XCTAssertEqual(parseUserMessageImages(duplicate).text, "Look") + let malformed = "Look" + AppshotContext.marker + "\n]>&foo;" + XCTAssertTrue(AppshotContext.presentations(dtd).isEmpty) + XCTAssertEqual(parseUserMessageImages(dtd).text, "Look") + } + + func testPlainAttachmentsAndUserTextRemainUnchanged() { + XCTAssertEqual(parseUserMessageImages("My plain text").text, "My plain text") + let parsed = parseUserMessageImages(withAttachments(text: "A photo", paths: ["/host/a.png"])) + XCTAssertEqual(parsed.text, "A photo") + XCTAssertEqual(parsed.attachments.first?.name, "a.png") + XCTAssertNil(parsed.attachments.first?.appshot) + } +} diff --git a/apps/ios/ZeronUITests/AppshotUITests.swift b/apps/ios/ZeronUITests/AppshotUITests.swift new file mode 100644 index 000000000..b585b78c1 --- /dev/null +++ b/apps/ios/ZeronUITests/AppshotUITests.swift @@ -0,0 +1,47 @@ +import XCTest + +@MainActor +final class AppshotUITests: XCTestCase { + private func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + func testRemoteAppshotsRemainReadableAndOpenOnPhone() { + let app = XCUIApplication() + app.launchArguments = ["-demo", "-route", "chat:chat-tabs", "-appshots"] + XCUIDevice.shared.orientation = .portrait + app.launch() + let card = app.buttons["appshot-card-2:/demo/appshot-square.png"] + XCTAssertTrue(card.waitForExistence(timeout: 10)) + XCTAssertFalse(app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", "PRIVATE_OBSERVED_TEXT_MUST_STAY_HIDDEN")).firstMatch.exists) + XCTAssertTrue(app.staticTexts["Check the narrow layout."].exists) + capture("appshots-ios-portrait") + let strip = app.scrollViews["appshot-attachments"] + strip.swipeRight() + strip.swipeRight() + let first = app.buttons["appshot-card-0:/demo/appshot-wide.png"] + XCTAssertTrue(first.isHittable) + capture("appshots-ios-first-capture") + first.tap() + let close = app.buttons["Close image preview"] + XCTAssertTrue(close.waitForExistence(timeout: 5)) + capture("appshots-ios-lightbox") + close.tap() + let images = app.buttons["Preview image, 2 more attachments"] + XCTAssertTrue(images.waitForExistence(timeout: 5)) + images.tap() + XCTAssertTrue(app.navigationBars["Queued images"].waitForExistence(timeout: 5)) + capture("appshots-ios-queue-gallery") + app.buttons["Done"].tap() + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertTrue(images.waitForExistence(timeout: 5)) + capture("appshots-ios-landscape") + XCUIDevice.shared.orientation = .portrait + app.buttons["More queue actions"].firstMatch.tap() + XCTAssertTrue(app.buttons["Move down"].waitForExistence(timeout: 5)) + capture("appshots-ios-queue-actions") + } +} diff --git a/apps/zeron/src/main.rs b/apps/zeron/src/main.rs index e5372a756..8e0fcd46d 100644 --- a/apps/zeron/src/main.rs +++ b/apps/zeron/src/main.rs @@ -31,6 +31,9 @@ enum Command { /// Live sync introspection from the running engine: per-room connection /// state, last pushed-frame/ack ages, rejoin/probe/resync counters. Sync, + #[cfg(target_os = "linux")] + /// Trigger an Appshot in the running headed instance (desktop shortcut fallback). + Appshot, /// Manage `zeron headless` as a background service (launchd / systemd --user). Daemon { #[command(subcommand)] @@ -185,6 +188,11 @@ fn main() -> anyhow::Result<()> { let runtime = tokio::runtime::Runtime::new()?; runtime.block_on(sync_cli(engine_config_from_env().ipc_port)) } + #[cfg(target_os = "linux")] + Some(Command::Appshot) => { + zeron_ui::appshots::request_running_appshot(&engine_config_from_env().data_dir) + .map_err(anyhow::Error::msg) + } Some(Command::Update { check }) => { let runtime = tokio::runtime::Runtime::new()?; runtime.block_on(update_cli::update(&edge_url_from_env(), check)) diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index b152a5df0..25cbb7359 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [features] browser-fixture = [] +appshots-fixture = ["gpui_platform/test-support"] resource-profile = ["gpui/bench", "gpui_platform/test-support"] [[example]] @@ -21,6 +22,10 @@ required-features = ["browser-fixture"] name = "browser-fixture" required-features = ["browser-fixture"] +[[example]] +name = "appshots-fixture" +required-features = ["appshots-fixture"] + [dependencies] url = "2" zeron-proto.workspace = true @@ -44,7 +49,7 @@ serde_json.workspace = true pulldown-cmark.workspace = true alacritty_terminal.workspace = true base64 = "0.22" -image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff"] } +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] } usvg = "0.46" mermaid-rs-renderer = { version = "=0.3.1", default-features = false } unicode-segmentation.workspace = true @@ -56,6 +61,8 @@ chrono.workspace = true anyhow.workspace = true tracing.workspace = true sha2.workspace = true +roxmltree = "0.21.1" +png = "0.18" thiserror.workspace = true [target.'cfg(target_os = "macos")'.dependencies] @@ -69,10 +76,17 @@ objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSBitmapImage objc2-foundation = "0.3.2" objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKNavigationDelegate", "WKNavigationAction", "WKSnapshotConfiguration"] } reqwest.workspace = true -image = { version = "0.25", default-features = false, features = ["png", "ico", "jpeg", "webp"] } +core-foundation = "0.10" +core-graphics = { version = "0.24", features = ["elcapitan", "highsierra", "link"] } +foreign-types = "0.5" +block = "0.1" [target.'cfg(target_os = "linux")'.dependencies] -image = { version = "0.25", default-features = false, features = ["png"] } +ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "global_shortcuts", "screenshot"] } +atspi = { version = "0.30.0", features = ["zbus"] } +async-io = "2" +url = "2" +x11rb = { version = "0.14.0", features = ["image"] } [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/ui/assets/sounds/appshot.wav b/crates/ui/assets/sounds/appshot.wav new file mode 100644 index 000000000..9a8b1753b Binary files /dev/null and b/crates/ui/assets/sounds/appshot.wav differ diff --git a/crates/ui/examples/appshots-fixture.rs b/crates/ui/examples/appshots-fixture.rs new file mode 100644 index 000000000..a08f27986 --- /dev/null +++ b/crates/ui/examples/appshots-fixture.rs @@ -0,0 +1,229 @@ +//! Native Appshots layout evidence with isolated data. No agent messages are sent. +use gpui::{AppContext, AsyncApp, Bounds, WindowBounds, WindowOptions, px, size}; +use std::{path::PathBuf, sync::Arc, time::Duration}; +use zeron_ui::*; +async fn pause(cx: &mut AsyncApp, ms: u64) { + cx.background_executor() + .timer(Duration::from_millis(ms)) + .await; +} +fn capture( + window: gpui::AnyWindowHandle, + cx: &mut AsyncApp, + directory: &std::path::Path, + name: &str, +) -> anyhow::Result<()> { + window.update(cx, |_, w, cx| { + w.draw(cx).clear(); + w.render_to_image()? + .save(directory.join(format!("{name}.png")))?; + Ok(()) + })? +} +fn press(window: gpui::AnyWindowHandle, cx: &mut AsyncApp, key: &str) -> anyhow::Result<()> { + window.update(cx, |_, w, cx| { + w.draw(cx).clear(); + let keystroke = gpui::Keystroke::parse(key).unwrap(); + w.dispatch_event( + gpui::PlatformInput::KeyDown(gpui::KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }), + cx, + ); + w.dispatch_event( + gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { keystroke }), + cx, + ); + })?; + Ok(()) +} +fn port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_env_filter("warn").init(); + let output = PathBuf::from(std::env::args().nth(1).expect("output directory")); + let inputs = PathBuf::from( + std::env::args() + .nth(2) + .expect("fixture PNG directory: wide, tall, square"), + ); + std::fs::create_dir_all(&output)?; + let temp = tempfile::tempdir()?; + let runtime = tokio::runtime::Runtime::new()?; + let core = runtime.block_on(async { + zeron_engine::EngineCore::assemble( + &temp.path().join("engine"), + Arc::new(zeron_engine::default_registry()), + zeron_proto::HarnessId::ClaudeCode, + None, + ) + })?; + core.workspace + .create_chat("appshots-fixture", None, Some(&core.device_id), None, None)?; + core.workspace + .rename_chat("appshots-fixture", "Review the workspace design")?; + let ipc_port = port(); + let _ipc = runtime.block_on(zeron_engine::serve_ipc(ipc_port, core.rpc_service()))?; + let data = temp.path().join("ui"); + std::fs::create_dir(&data)?; + let boot = EngineBootConfig { + data_dir: data.clone(), + ipc_port, + edge_url: String::new(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::ClaudeCode, + }; + let handle = runtime.block_on(state::EngineHandle::bootstrap(boot.clone()))?; + let chats = core.workspace.read_chats()?; + let device = core.device_id.clone(); + let mut shots = Vec::new(); + let mut paths = Vec::new(); + for (name, title, bundle) in [ + ("wide", "Fieldnotes · Product planning", "com.apple.Safari"), + ("tall", "Design review · Notes", "com.apple.Notes"), + ("square", "Workspace ideas", "com.apple.finder"), + ] { + let bytes = std::fs::read(inputs.join(format!("{name}.png")))?; + let (screenshot, dimensions) = + appshots::stage_appshot_png(name, bytes).map_err(|e| anyhow::anyhow!(e.to_string()))?; + let shot = appshots::CapturedAppshot { + id: name.into(), + app_name: match name { + "wide" => "Safari", + "tall" => "Notes", + _ => "Finder", + } + .into(), + bundle_identifier: Some(bundle.into()), + window_title: Some(title.into()), + accessibility: appshots::AccessibilitySnapshot::unavailable(), + screenshot, + screenshot_dimensions: Some(dimensions), + app_icon: None, + captured_at: chrono::Utc::now(), + }; + let path = inputs + .join(format!("{name}.png")) + .to_string_lossy() + .into_owned(); + attachments::store_loaded( + &device, + &path, + shot.screenshot.name.clone().into(), + shot.screenshot.image.clone(), + ); + paths.push(path); + shots.push(shot); + } + let body = appshots::with_appshots( + "Compare these layouts and suggest a clearer hierarchy.", + &shots, + &shots + .iter() + .zip(&paths) + .map(|(s, p)| (s.screenshot.id.clone(), p.clone())) + .collect(), + ); + let message = attachments::with_attachments(&body, &paths); + let queue = vec![ + zeron_doc::QueuedMessage { + id: "queued-review".into(), + text: body, + attachments: paths.clone(), + hold_for_turn_end: true, + issued_by: device.clone(), + issued_at: 1788900000000, + edited_at: None, + delivery_gate: None, + }, + zeron_doc::QueuedMessage { + id: "queued-text".into(), + text: "Then check the spacing and keyboard navigation.".into(), + attachments: vec![], + hold_for_turn_end: true, + issued_by: device.clone(), + issued_at: 1788900000001, + edited_at: None, + delivery_gate: None, + }, + ]; + let failure = Arc::new(std::sync::Mutex::new(None)); + let result = failure.clone(); + gpui_platform::application().with_assets(icons::Assets).run(move |cx| { + gpui_tokio::init(cx); gpui_base::init(cx); + let settings=settings::UiSettings::default(); settings::init(settings.clone(),data.clone(),cx); + let fonts=typography::register_fonts(cx); typography::init(settings.ui_font_family.clone(),settings.ui_font_size,fonts,cx); + theme_library::init(data.clone(),cx); appearance::init(appearance::AppearanceMode::Dark,settings.theme_selection,settings.accent,settings.surface,cx); + history::init(settings.git_history_columns,settings.git_history_column_widths,settings.git_history_column_order,settings.git_history_author_display,cx); + composer::init(cx,settings.composer_send_behavior); terminal::panel::init(cx); app_menus::init(cx); + let state=cx.new(|_| { let mut s=state::AppState::new(); s.fixture_attachment_engine(handle); s.connection=zeron_proto::view::ConnectionStatus::Ready; s.workspace_scope=Some(zeron_proto::WorkspaceScope::Development); s.local_device_id=Some(device.clone()); s.devices=vec![serde_json::from_value(serde_json::json!({"id":device,"name":"This device","platform":std::env::consts::OS,"lastSeenAt":null})).unwrap()]; s.chats=chats; s.selected_chat=Some("appshots-fixture".into()); s.auto_selected=true; s.chats_synced=true; s.spaces_synced=true; s }); + let window=cx.open_window(WindowOptions {window_background:theme::Theme::of(cx).window_background_appearance(),window_bounds:Some(WindowBounds::Windowed(Bounds::new(gpui::point(px(20.),px(40.)),size(px(1100.),px(850.))))),..Default::default()},|_,cx|cx.new(|cx|shell::Shell::new(state.clone(),boot,cx))).unwrap(); + state.update(cx,|_,cx|cx.notify()); cx.activate(true); + cx.spawn(async move |cx| { + let run:anyhow::Result<()>=async { + pause(cx,1000).await; + state.update(cx,|s,cx| {s.receive_transcript_frame(zeron_doc::TranscriptFrame::Reset {reset:serde_json::from_value(serde_json::json!([ + {"id":"user","role":"user","parts":[{"id":"text","kind":"text","text":message}],"createdAt":1788900000000_i64,"deviceId":device}, + {"id":"assistant","role":"assistant","parts":[{"id":"text","kind":"text","text":"The three Appshots show a consistent visual style. I’ll compare the spacing and reading order, then check how the layout adapts to smaller screens."}],"createdAt":1788900001000_i64,"deviceId":device,"status":"complete"} + ])).unwrap()},cx).unwrap();cx.notify();}); + pause(cx,900).await; + window.update(cx,|s,_,cx|s.fixture_appshots_transcript_start(cx))?; + pause(cx,400).await;capture(window.into(),cx,&output,"appshots-transcript-dark-1100")?; + state.update(cx,|s,cx|{s.queue=queue;cx.notify();}); + let evidence_shots=shots.clone(); + let composer=window.update(cx,|s,_,_|s.fixture_appshots_composer())?; + composer.update(cx,|c,cx| {for mut shot in shots { + shot.app_icon=appshots::presentation_icon(&appshots::AppshotPresentation {app_name:shot.app_name.clone(),window_title:shot.window_title.clone(),bundle_identifier:shot.bundle_identifier.clone()}); + c.stage_appshot(shot,cx); + } cx.notify();}); + pause(cx,1800).await; capture(window.into(), cx, &output,"appshots-chat-dark-1100")?; + cx.update(|cx|appearance::set_mode(appearance::AppearanceMode::Light,cx));pause(cx,500).await;capture(window.into(), cx, &output,"appshots-chat-light-1100")?; + for width in [700.,390.,320.] { + window.update(cx,|s,w,cx|{ s.fixture_appshots_sidebar(true,cx); w.resize(size(px(width),px(850.))); })?; + pause(cx,700).await;capture(window.into(), cx, &output,&format!("appshots-chat-light-{width}"))?; + } + window.update(cx,|s,w,cx| {s.fixture_appshots_sidebar(false,cx);w.resize(size(px(1100.),px(850.)));s.fixture_appshots_settings(true,cx);})?; + pause(cx,900).await;capture(window.into(), cx, &output,"appshots-settings-light-1100")?; + cx.update(|cx|appearance::set_mode(appearance::AppearanceMode::Dark,cx));pause(cx,500).await;capture(window.into(), cx, &output,"appshots-settings-dark-1100")?; + press(window.into(),cx,"tab")?;press(window.into(),cx,"space")?; + pause(cx,500).await;capture(window.into(),cx,&output,"appshots-settings-enabled-keyboard")?; + for key in ["tab","tab","tab","tab","space"] { press(window.into(),cx,key)?; } + pause(cx,500).await;capture(window.into(),cx,&output,"appshots-settings-last-session")?; + for key in ["shift-tab","shift-tab","enter"] { press(window.into(),cx,key)?; } + pause(cx,500).await;capture(window.into(),cx,&output,"appshots-settings-recording")?; + press(window.into(),cx,"escape")?; + window.update(cx,|_,w,_|w.resize(size(px(600.),px(850.))))?;pause(cx,700).await;capture(window.into(), cx, &output,"appshots-settings-dark-600")?; + window.update(cx,|s,w,cx| {w.resize(size(px(1100.),px(850.)));s.fixture_appshots_settings(false,cx);})?; + composer.update(cx,|c,cx|c.fixture_clear_appshots(cx)); + let state_paths=vec![paths[0].clone(), "pending://appshot-test/notes.png".into(), "/missing/appshot-test.png".into()]; + attachments::store_error(&device,&state_paths[2]); + let state_body=appshots::with_appshots("Appshots keep their source labels while images are loading or unavailable.",&evidence_shots,&evidence_shots.iter().zip(&state_paths).map(|(s,p)|(s.screenshot.id.clone(),p.clone())).collect()); + let state_message=attachments::with_attachments(&state_body,&state_paths); + state.update(cx,|s,cx|{s.queue.clear();s.receive_transcript_frame(zeron_doc::TranscriptFrame::Reset{reset:serde_json::from_value(serde_json::json!([ + {"id":"states-user","role":"user","parts":[{"id":"text","kind":"text","text":state_message}],"createdAt":1788900000000_i64,"deviceId":device} + ])).unwrap()},cx).unwrap();cx.notify();}); + pause(cx,700).await;capture(window.into(),cx,&output,"appshots-transcript-transfer-states")?; + window.update(cx,|s,w,cx|{s.fixture_appshots_sidebar(true,cx);w.resize(size(px(390.),px(850.)));s.fixture_appshots_transcript_start(cx);})?; + pause(cx,600).await;capture(window.into(),cx,&output,"appshots-transcript-narrow-390")?; + std::fs::write(output.join("result.txt"),"Rendered production Shell/Composer/Transcript/Settings with neutral fixture captures. Narrow desktop widths are not physical iOS validation. No native capture or remote transport claims.\n")?; + Ok(()) + }.await; + if let Err(error)=run {eprintln!("Appshots fixture failed: {error:#}");*result.lock().unwrap()=Some(format!("{error:#}"));} + let _=window.update(cx,|_,w,_|w.remove_window());pause(cx,100).await;cx.update(|cx|cx.quit()); + }).detach(); + }); + runtime.block_on(core.shutdown()); + if let Some(error) = failure.lock().unwrap().take() { + anyhow::bail!(error); + } + Ok(()) +} diff --git a/crates/ui/src/appshots.rs b/crates/ui/src/appshots.rs new file mode 100644 index 000000000..20d12f5e9 --- /dev/null +++ b/crates/ui/src/appshots.rs @@ -0,0 +1,1231 @@ +//! Appshots: user-triggered captures of the frontmost application window. +//! +//! Capture stays on the headed/viewer device. The screenshot reuses the +//! ordinary attachment transport; accessibility-derived application context +//! is serialized into the prompt as explicitly untrusted observed data. + +use std::collections::HashMap; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::attachments::StagedAttachment; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; + +mod shortcut; +pub(crate) use shortcut::capture_allowed; +pub use shortcut::{set_recording, set_shortcut, validate_shortcut}; + +pub const fn is_desktop() -> bool { + cfg!(any(target_os = "macos", target_os = "linux")) +} + +static CAPTURE_SOUND_ENABLED: AtomicBool = AtomicBool::new(false); + +pub fn set_capture_sound_enabled(enabled: bool) { + CAPTURE_SOUND_ENABLED.store(enabled, Ordering::Relaxed); + if enabled { + crate::sound::prepare_appshot(); + } +} + +/// Acknowledge saved pixels immediately; optional semantic enrichment may still be running. +fn capture_ready() { + if CAPTURE_SOUND_ENABLED.load(Ordering::Relaxed) { + crate::sound::play_appshot(); + } +} + +static ENABLED: AtomicBool = AtomicBool::new(false); + +/// Bounds native capture buffers before platform APIs allocate or stage them. +pub const MAX_CAPTURE_DIMENSION: u32 = 8_192; +pub const MAX_CAPTURE_PIXELS: u64 = 32 * 1024 * 1024; +pub const MAX_CAPTURE_RGBA_BYTES: u64 = 128 * 1024 * 1024; +/// A composer may retain several captures while the user prepares a prompt, +/// but it must not become an unbounded store of decoded image data. +pub const MAX_STAGED_APPSHOT_BYTES: u64 = 4 * crate::attachments::MAX_ATTACHMENT_BYTES; + +pub const CONTEXT_MARKER: &str = "Applications mentioned by the user (untrusted observed content):"; +#[cfg(target_os = "macos")] +const SCREEN_RECORDING_SETTINGS_URL: &str = + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; +#[cfg(target_os = "macos")] +const ACCESSIBILITY_SETTINGS_URL: &str = + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppshotPlatform { + MacOs, + LinuxWayland, + LinuxX11, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityState { + Checking, + Ready, + PermissionRequired, + SetupRequired, + UserSelection, + Unavailable, +} + +impl CapabilityState { + pub fn badge(self) -> &'static str { + match self { + Self::Checking => "Checking", + Self::Ready => "Ready", + Self::PermissionRequired => "Required", + Self::SetupRequired => "Set up", + Self::UserSelection => "Select window", + Self::Unavailable => "Unavailable", + } + } + + pub fn is_ready(self) -> bool { + matches!(self, Self::Ready | Self::UserSelection) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureTarget { + ActiveWindow, + PortalWindowPicker, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppshotCapabilities { + pub platform: AppshotPlatform, + pub global_shortcut: CapabilityState, + pub window_capture: CapabilityState, + pub application_text: CapabilityState, + pub target: CaptureTarget, +} + +impl AppshotCapabilities { + pub fn setup_description(self) -> &'static str { + match self.platform { + AppshotPlatform::MacOs => { + "Set up once, one permission at a time. Screen Recording captures the window; Accessibility optionally adds off-screen application text." + } + AppshotPlatform::LinuxWayland => { + "Your desktop owns capture and shortcut consent. Zeron checks each portal capability separately and explains any required fallback." + } + AppshotPlatform::LinuxX11 => { + "X11 normally needs no capture permission. Zeron prefers an active-window screenshot portal when available and otherwise uses native X11 capture." + } + AppshotPlatform::Unsupported => { + "This platform does not currently provide an Appshot capture backend." + } + } + } + + pub fn shortcut_description(self) -> &'static str { + match self.platform { + AppshotPlatform::MacOs | AppshotPlatform::LinuxX11 + if self.global_shortcut != CapabilityState::Ready => + { + "This shortcut is unavailable. Choose a different key combination." + } + AppshotPlatform::MacOs | AppshotPlatform::LinuxX11 => { + "The shortcut works while another application has focus." + } + AppshotPlatform::LinuxWayland if self.global_shortcut == CapabilityState::Ready => { + "Your desktop portal controls the binding. Confirm changes in its shortcut settings." + } + AppshotPlatform::LinuxWayland => { + "Bind `zeron appshot` in your desktop's Keyboard Shortcuts settings." + } + AppshotPlatform::Unsupported => "This platform has no Appshot shortcut backend.", + } + } + + pub fn capture_description(self) -> &'static str { + match (self.platform, self.target) { + (AppshotPlatform::MacOs, _) => { + "Screen Recording lets Zeron capture the frontmost window. macOS may request one restart." + } + (AppshotPlatform::LinuxX11, _) => { + "Zeron uses native X11 capture when active-window portal capture is unavailable. Obscured or protected windows may be incomplete." + } + (AppshotPlatform::LinuxWayland, CaptureTarget::ActiveWindow) => { + "Your screenshot portal supports the active-window target. A system consent surface may appear." + } + (AppshotPlatform::LinuxWayland, CaptureTarget::PortalWindowPicker) => { + "Your portal requires choosing a window for each capture." + } + (AppshotPlatform::Unsupported, _) => { + "Active-window capture is unavailable on this platform." + } + } + } + + pub fn semantic_description(self) -> &'static str { + match self.platform { + AppshotPlatform::MacOs => { + "Accessibility adds visible and off-screen application text. Screenshots work without it." + } + AppshotPlatform::LinuxWayland => { + "This portal does not identify the captured window, so Appshots include the screenshot only." + } + AppshotPlatform::LinuxX11 => { + "Native X11 captures can include AT-SPI text when the process and window can be matched uniquely. Portal captures include the screenshot only." + } + AppshotPlatform::Unsupported => { + "Semantic application text is unavailable on this platform." + } + } + } +} + +#[async_trait::async_trait] +pub trait AppshotBackend: Sync { + fn capabilities(&self) -> AppshotCapabilities; + fn start_global_shortcut( + &self, + activation_dir: &Path, + ) -> futures::channel::mpsc::UnboundedReceiver<()>; + async fn capture_active_window(&self) -> Result; + fn request_capture_access(&self) {} + fn request_semantic_access(&self) {} + fn capture_settings_url(&self) -> Option<&'static str> { + None + } + fn semantic_settings_url(&self) -> Option<&'static str> { + None + } +} + +#[cfg(target_os = "macos")] +static BACKEND: macos::MacOsBackend = macos::MacOsBackend; +#[cfg(target_os = "linux")] +static BACKEND: linux::LinuxBackend = linux::LinuxBackend; + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +struct UnsupportedBackend; + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +#[async_trait::async_trait] +impl AppshotBackend for UnsupportedBackend { + fn capabilities(&self) -> AppshotCapabilities { + AppshotCapabilities { + platform: AppshotPlatform::Unsupported, + global_shortcut: CapabilityState::Unavailable, + window_capture: CapabilityState::Unavailable, + application_text: CapabilityState::Unavailable, + target: CaptureTarget::ActiveWindow, + } + } + + fn start_global_shortcut( + &self, + _activation_dir: &Path, + ) -> futures::channel::mpsc::UnboundedReceiver<()> { + futures::channel::mpsc::unbounded().1 + } + + async fn capture_active_window(&self) -> Result { + Err(CaptureError::CaptureFailed( + "Appshots are not available on this platform.".into(), + )) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +static BACKEND: UnsupportedBackend = UnsupportedBackend; + +fn backend() -> &'static dyn AppshotBackend { + &BACKEND +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum AppshotDestination { + #[default] + Automatic, + LastSession, + NewSession, +} + +impl AppshotDestination { + pub const ALL: [Self; 3] = [Self::Automatic, Self::LastSession, Self::NewSession]; + + pub fn label(self) -> &'static str { + match self { + Self::Automatic => "Automatic", + Self::LastSession => "Last session", + Self::NewSession => "New session", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccessibilitySnapshot { + pub format_version: u32, + pub content: String, + pub truncated: bool, +} + +impl AccessibilitySnapshot { + pub fn unavailable() -> Self { + Self { + format_version: 1, + content: String::new(), + truncated: false, + } + } +} + +#[derive(Clone)] +pub struct CapturedAppshot { + pub id: String, + pub app_name: String, + pub bundle_identifier: Option, + pub window_title: Option, + pub accessibility: AccessibilitySnapshot, + pub screenshot: StagedAttachment, + /// Pixel dimensions of the captured PNG. The composer uses these to size + /// the native image layer explicitly instead of relying on intrinsic + /// image layout, which can escape a clipped Appshot stage. + pub screenshot_dimensions: Option<(u32, u32)>, + /// Presentation-only. The icon is never uploaded or serialized into the + /// model context. + pub app_icon: Option>, + pub captured_at: DateTime, +} + +/// Read the width and height from a PNG's IHDR chunk without decoding the +/// image. Appshot captures are always PNGs, so this keeps layout metadata +/// cheap and available before GPUI decodes the image asynchronously. +pub fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + if bytes.len() < 24 || &bytes[..8] != PNG_SIGNATURE || &bytes[12..16] != b"IHDR" { + return None; + } + let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?); + let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?); + (width > 0 && height > 0).then_some((width, height)) +} + +pub fn validate_capture_dimensions(width: u32, height: u32) -> Result { + if width == 0 || height == 0 || width > MAX_CAPTURE_DIMENSION || height > MAX_CAPTURE_DIMENSION + { + return Err(CaptureError::CaptureFailed(format!( + "The captured window dimensions ({width}×{height}) are not supported." + ))); + } + let pixels = u64::from(width) + .checked_mul(u64::from(height)) + .ok_or_else(|| CaptureError::CaptureFailed("The captured window is too large.".into()))?; + let rgba_bytes = pixels + .checked_mul(4) + .ok_or_else(|| CaptureError::CaptureFailed("The captured window is too large.".into()))?; + if pixels > MAX_CAPTURE_PIXELS || rgba_bytes > MAX_CAPTURE_RGBA_BYTES { + return Err(CaptureError::CaptureFailed(format!( + "The captured window ({width}×{height}) exceeds Zeron's capture budget." + ))); + } + usize::try_from(rgba_bytes) + .map_err(|_| CaptureError::CaptureFailed("The captured window is too large.".into())) +} + +/// Turn OS-provided application names into one safe filename component. +pub fn safe_app_name(value: &str) -> String { + let mut safe = String::with_capacity(value.len().min(100)); + for ch in value.chars() { + if safe.chars().count() >= 100 { + break; + } + if ch.is_control() || matches!(ch, '/' | '\\' | ':') { + safe.push('-'); + } else { + safe.push(ch); + } + } + let safe = safe.split_whitespace().collect::>().join(" "); + let safe = safe.trim_matches(['.', '-', ' ']); + if safe.is_empty() { + "Application".into() + } else { + safe.into() + } +} + +/// Native capture surfaces can contain transparent padding (for example Chrome +/// can return a wider backing surface than its visible window). Crop only rows +/// and columns that contain no nonzero alpha. Do this before deriving thumbnail +/// dimensions, so preview, upload and restored queue drafts share the same image. +/// Opaque margins, rounded corners and even alpha=1 pixels remain untouched. +fn trim_appshot_padding(bytes: &[u8]) -> Result>, CaptureError> { + let invalid = |error: png::DecodingError| { + CaptureError::CaptureFailed(format!("Could not decode the captured window: {error}")) + }; + if bytes.len() as u64 > crate::attachments::MAX_ATTACHMENT_BYTES { + return Err(CaptureError::CaptureFailed( + "The captured window is larger than Zeron's 24 MB image limit.".into(), + )); + } + let (width, height) = png_dimensions(bytes).ok_or_else(|| { + CaptureError::CaptureFailed("The captured window is not a valid PNG image.".into()) + })?; + validate_capture_dimensions(width, height)?; + let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + decoder.set_limits(png::Limits { + bytes: MAX_CAPTURE_RGBA_BYTES as usize, + }); + // Expand palette/transparency entries, but retain 16-bit channel precision. + decoder.set_transformations(png::Transformations::EXPAND); + let mut reader = decoder.read_info().map_err(invalid)?; + let (color, _) = reader.output_color_type(); + if reader.info().is_animated() + || !matches!(color, png::ColorType::Rgba | png::ColorType::GrayscaleAlpha) + { + return Ok(None); + } + let buffer_size = reader + .output_buffer_size() + .filter(|size| *size <= MAX_CAPTURE_RGBA_BYTES as usize) + .ok_or_else(|| CaptureError::CaptureFailed("The captured window is too large.".into()))?; + let mut pixels = vec![0; buffer_size]; + let frame = reader.next_frame(&mut pixels).map_err(invalid)?; + let sample_bytes = frame.bit_depth as usize / 8; + let pixel_bytes = frame.color_type.samples() * sample_bytes; + let alpha_offset = pixel_bytes - sample_bytes; + let visible = |x: u32, y: u32| { + let start = (y as usize * width as usize + x as usize) * pixel_bytes + alpha_offset; + pixels[start..start + sample_bytes] + .iter() + .any(|alpha| *alpha != 0) + }; + // Search inward from the edges. Ordinary opaque captures only inspect a + // handful of pixels instead of scanning the whole Retina-sized surface. + let Some(top) = (0..height).find(|y| (0..width).any(|x| visible(x, *y))) else { + return Ok(None); + }; + let bottom = (top..height) + .rfind(|y| (0..width).any(|x| visible(x, *y))) + .unwrap() + + 1; + let left = (0..width) + .find(|x| (top..bottom).any(|y| visible(*x, y))) + .unwrap(); + let right = (left..width) + .rfind(|x| (top..bottom).any(|y| visible(*x, y))) + .unwrap() + + 1; + if left == 0 && top == 0 && right == width && bottom == height { + return Ok(None); + } + let cropped_width = right - left; + let cropped_height = bottom - top; + let row_bytes = cropped_width as usize * pixel_bytes; + // Compact in place rather than allocating a second full-sized pixel buffer. + for row in 0..cropped_height as usize { + let start = ((row + top as usize) * width as usize + left as usize) * pixel_bytes; + pixels.copy_within(start..start + row_bytes, row * row_bytes); + } + pixels.truncate(row_bytes * cropped_height as usize); + let source = reader.info(); + let mut info = png::Info::with_size(cropped_width, cropped_height); + info.color_type = frame.color_type; + info.bit_depth = frame.bit_depth; + info.pixel_dims = source.pixel_dims; + info.source_gamma = source.source_gamma; + info.source_chromaticities = source.source_chromaticities; + info.srgb = source.srgb; + info.icc_profile = source.icc_profile.clone(); + info.coding_independent_code_points = source.coding_independent_code_points; + info.mastering_display_color_volume = source.mastering_display_color_volume; + info.content_light_level = source.content_light_level; + let mut output = AttachmentBudgetWriter { bytes: Vec::new() }; + png::Encoder::with_info(&mut output, info) + .and_then(|encoder| encoder.write_header()) + .and_then(|mut writer| writer.write_image_data(&pixels)) + .map_err(|error| { + CaptureError::CaptureFailed(format!("Could not encode the captured window: {error}")) + })?; + Ok(Some(output.bytes)) +} + +pub fn stage_appshot_png( + app_name: &str, + bytes: Vec, +) -> Result<(StagedAttachment, (u32, u32)), CaptureError> { + if bytes.len() as u64 > crate::attachments::MAX_ATTACHMENT_BYTES { + return Err(CaptureError::CaptureFailed( + "The captured window is larger than Zeron's 24 MB image limit.".into(), + )); + } + let bytes = trim_appshot_padding(&bytes)?.unwrap_or(bytes); + let dimensions = png_dimensions(&bytes).ok_or_else(|| { + CaptureError::CaptureFailed("The captured window is not a valid PNG image.".into()) + })?; + validate_capture_dimensions(dimensions.0, dimensions.1)?; + Ok(( + crate::attachments::stage_png_bytes( + format!("{} Appshot.png", safe_app_name(app_name)), + bytes, + ), + dimensions, + )) +} + +struct AttachmentBudgetWriter { + bytes: Vec, +} + +impl Write for AttachmentBudgetWriter { + fn write(&mut self, input: &[u8]) -> io::Result { + if self.bytes.len().saturating_add(input.len()) + > crate::attachments::MAX_ATTACHMENT_BYTES as usize + { + return Err(io::Error::new( + io::ErrorKind::FileTooLarge, + "encoded Appshot exceeds attachment budget", + )); + } + self.bytes.extend_from_slice(input); + Ok(input.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(target_os = "linux")] +pub fn encode_rgba_png( + width: u32, + height: u32, + rgba: &[u8], + platform: &str, +) -> Result, CaptureError> { + let expected = validate_capture_dimensions(width, height)?; + if rgba.len() != expected { + return Err(CaptureError::CaptureFailed(format!( + "{platform} returned an invalid pixel buffer." + ))); + } + let mut output = AttachmentBudgetWriter { bytes: Vec::new() }; + { + let mut encoder = png::Encoder::new(&mut output, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .and_then(|mut writer| writer.write_image_data(rgba)) + .map_err(|error| { + CaptureError::CaptureFailed(format!("{platform} Appshot encoding failed: {error}")) + })?; + } + Ok(output.bytes) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CaptureError { + PermissionRequired, + Cancelled, + SelfCapture, + NoEligibleWindow, + ShortcutUnavailable, + CaptureFailed(String), +} + +impl std::fmt::Display for CaptureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PermissionRequired => f.write_str( + "Window capture permission is required. Open Zeron Settings → Appshots for the platform-specific recovery step.", + ), + Self::Cancelled => f.write_str("Appshot capture cancelled."), + Self::SelfCapture => f.write_str("Switch to another app to capture an Appshot."), + Self::NoEligibleWindow => f.write_str("No application window is available to capture."), + Self::ShortcutUnavailable => f.write_str( + "The Appshot shortcut could not be registered because another app may be using it.", + ), + Self::CaptureFailed(message) => f.write_str(message), + } + } +} + +pub fn set_enabled(enabled: bool) { + if ENABLED.swap(enabled && is_desktop(), Ordering::Relaxed) != (enabled && is_desktop()) { + shortcut::enabled_changed(); + } +} + +pub fn enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} + +pub fn capabilities() -> AppshotCapabilities { + backend().capabilities() +} + +pub fn request_capture_access() { + backend().request_capture_access(); +} + +pub fn request_semantic_access() { + backend().request_semantic_access(); +} + +pub fn capture_settings_url() -> Option<&'static str> { + backend().capture_settings_url() +} + +pub fn semantic_settings_url() -> Option<&'static str> { + backend().semantic_settings_url() +} + +/// Register the platform global shortcut and, on Linux, the local activation +/// socket used by `zeron appshot` when the desktop owns shortcut setup. +pub fn start_global_shortcut( + activation_dir: PathBuf, +) -> futures::channel::mpsc::UnboundedReceiver<()> { + backend().start_global_shortcut(&activation_dir) +} + +pub async fn capture_active_window() -> Result { + let result = backend().capture_active_window().await; + #[cfg(target_os = "linux")] + if result.is_ok() { + capture_ready(); + } + result +} + +/// Ask the running headed Zeron process to capture an Appshot. Linux desktop +/// environments that do not implement the Global Shortcuts portal can bind +/// `zeron appshot` in their native Keyboard Shortcuts settings. +#[cfg(target_os = "linux")] +pub fn request_running_appshot(data_dir: &Path) -> Result<(), CaptureError> { + linux::request_running_appshot(data_dir) +} + +#[cfg(not(target_os = "linux"))] +pub fn request_running_appshot(_data_dir: &Path) -> Result<(), CaptureError> { + Err(CaptureError::ShortcutUnavailable) +} + +/// Complete the user-requested capture handoff after staging, on the UI thread. +pub fn foreground_after_capture() { + #[cfg(target_os = "macos")] + macos::foreground_after_capture(); +} + +pub fn xml_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + '\r' => escaped.push_str(" "), + '\n' => escaped.push_str(" "), + '\t' => escaped.push_str(" "), + // XML 1.0 cannot represent these characters, even as numeric + // references. Native application text may contain them; retain a + // visible replacement instead of making the whole capture unreadable. + '\u{0}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}' => escaped.push('\u{fffd}'), + _ => escaped.push(ch), + } + } + escaped +} + +/// Attach semantic Appshot context to a prompt. `image_paths` is keyed by the +/// staged screenshot id, allowing queued `pending://` refs to be rewritten by +/// the host exactly like ordinary attachment paths. +pub fn with_appshots( + text: &str, + appshots: &[CapturedAppshot], + image_paths: &HashMap, +) -> String { + if appshots.is_empty() { + return text.to_string(); + } + let mut out = text.to_string(); + out.push_str("\n\n"); + out.push_str(CONTEXT_MARKER); + for appshot in appshots { + let image = image_paths + .get(&appshot.screenshot.id) + .map(String::as_str) + .unwrap_or_default(); + out.push_str("\n\n"); + out.push_str(&xml_escape(&appshot.accessibility.content)); + out.push_str("\n"); + } + out +} + +/// Restore the serialized Appshots in a queued message onto its loaded images. +/// Reject malformed or unmatched metadata so editing cannot silently drop it. +/// Icons and capture timestamps are presentation-only and were never transported. +pub(crate) fn restore_queued_appshots( + text: &str, + paths: &[String], + attachments: &[StagedAttachment], +) -> Result<(Vec, Vec), String> { + let marker = format!("\n\n{CONTEXT_MARKER}"); + let Some((_, context)) = text.split_once(&marker) else { + return Ok((attachments.to_vec(), Vec::new())); + }; + let invalid = || { + "Couldn't restore this Appshot's context. Cancel and retry from the original device." + .to_string() + }; + if paths.len() != attachments.len() || context.len() > 4 * 1024 * 1024 { + return Err(invalid()); + } + // Older hosts retained the ordinary attachment trailer inside queue text. + let context = context + .split("\n\nAttached images (local files") + .next() + .unwrap_or(context); + let xml = format!("{context}"); + let doc = roxmltree::Document::parse_with_options( + &xml, + roxmltree::ParsingOptions { + allow_dtd: false, + nodes_limit: 4096, + ..Default::default() + }, + ) + .map_err(|_| invalid())?; + let mut used = std::collections::HashSet::new(); + let mut shots = Vec::new(); + for node in doc.root_element().children() { + if node.is_text() && node.text().unwrap_or_default().trim().is_empty() { + continue; + } + if !node.has_tag_name("appshot") || node.children().any(|child| !child.is_text()) { + return Err(invalid()); + } + let image = node.attribute("image").ok_or_else(invalid)?; + let index = paths + .iter() + .position(|path| path == image) + .ok_or_else(invalid)?; + if !used.insert(index) { + return Err(invalid()); + } + let mut screenshot = attachments[index].clone(); + // Older captures may already have backing-surface padding stored in + // their PNG. Normalize their bytes too, without changing attachment IDs. + if png_dimensions(screenshot.bytes()).is_some() { + if let Some(bytes) = trim_appshot_padding(screenshot.bytes()).map_err(|_| invalid())? { + screenshot.image = Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes)); + } + } + let content = node.text().unwrap_or_default(); + // Remove only the serializer's surrounding newlines, preserving content. + let content = content.strip_prefix('\n').unwrap_or(content); + let content = content.strip_suffix('\n').unwrap_or(content); + shots.push(CapturedAppshot { + id: uuid::Uuid::new_v4().to_string(), + app_name: node.attribute("app").ok_or_else(invalid)?.to_string(), + bundle_identifier: node.attribute("bundle-identifier").map(str::to_string), + window_title: node.attribute("window-title").map(str::to_string), + accessibility: AccessibilitySnapshot { + format_version: node + .attribute("accessibility-format") + .ok_or_else(invalid)? + .parse() + .map_err(|_| invalid())?, + content: content.to_string(), + truncated: node + .attribute("truncated") + .ok_or_else(invalid)? + .parse() + .map_err(|_| invalid())?, + }, + screenshot_dimensions: png_dimensions(screenshot.bytes()), + screenshot, + app_icon: None, + captured_at: Utc::now(), + }); + } + if shots.is_empty() + || shots + .iter() + .map(|shot| shot.screenshot.bytes().len() as u64) + .sum::() + > MAX_STAGED_APPSHOT_BYTES + { + return Err(invalid()); + } + let ordinary = attachments + .iter() + .enumerate() + .filter(|(index, _)| !used.contains(index)) + .map(|(_, attachment)| attachment.clone()) + .collect(); + Ok((ordinary, shots)) +} + +/// Safe display metadata carried by the existing prompt format. The observed +/// accessibility payload is never returned to the transcript or queue UI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppshotPresentation { + pub app_name: String, + pub window_title: Option, + pub bundle_identifier: Option, +} + +impl AppshotPresentation { + pub fn title(&self) -> &str { + self.window_title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .unwrap_or(&self.app_name) + } +} + +/// Resolve an installed app's icon locally; remote/mobile viewers retain the +/// app-name badge when that application is not installed. Icons never enter +/// the model prompt or add another attachment to the transport. +pub fn presentation_icon(presentation: &AppshotPresentation) -> Option> { + #[cfg(target_os = "macos")] + { + static ICONS: std::sync::OnceLock< + std::sync::Mutex>>>, + > = std::sync::OnceLock::new(); + let bundle = presentation.bundle_identifier.as_deref()?; + let mut icons = ICONS.get_or_init(Default::default).lock().ok()?; + if let Some(icon) = icons.get(bundle) { + return icon.clone(); + } + if icons.len() >= 64 { + return None; + } + let icon = macos::icon_for_bundle(bundle).and_then(|bytes| { + crate::attachments::queue_thumbnail_image(&gpui::Image::from_bytes( + gpui::ImageFormat::Png, + bytes, + )) + }); + icons.insert(bundle.to_string(), icon.clone()); + icon + } + #[cfg(not(target_os = "macos"))] + { + let _ = presentation; + None + } +} + +pub fn presentations(text: &str) -> HashMap { + let marker = format!("\n\n{CONTEXT_MARKER}"); + let Some((_, context)) = text.split_once(&marker) else { + return HashMap::new(); + }; + if context.len() > 4 * 1024 * 1024 { + return HashMap::new(); + } + let context = context + .split("\n\nAttached images (local files") + .next() + .unwrap_or(""); + let xml = format!("{context}"); + let Ok(doc) = roxmltree::Document::parse_with_options( + &xml, + roxmltree::ParsingOptions { + allow_dtd: false, + nodes_limit: 4096, + ..Default::default() + }, + ) else { + return HashMap::new(); + }; + let mut result = HashMap::new(); + let mut seen = std::collections::HashSet::new(); + for node in doc + .root_element() + .children() + .filter(|node| node.is_element()) + { + if !node.has_tag_name("appshot") || node.children().any(|child| !child.is_text()) { + continue; + } + let (Some(path), Some(app)) = (node.attribute("image"), node.attribute("app")) else { + continue; + }; + if !seen.insert(path) { + result.remove(path); + continue; + } + if path.is_empty() || app.trim().is_empty() { + continue; + } + result.insert( + path.to_string(), + AppshotPresentation { + app_name: app.chars().take(200).collect(), + window_title: node + .attribute("window-title") + .map(|value| value.chars().take(512).collect()), + bundle_identifier: node.attribute("bundle-identifier").map(str::to_string), + }, + ); + } + result +} + +/// Context is persisted for the harness but hidden from the user-message +/// bubble. The screenshot strip remains visible through the ordinary image +/// refs, so this strips only the machine-facing semantic suffix. +pub fn strip_context_for_display(text: &str) -> &str { + let needle = format!("\n\n{CONTEXT_MARKER}"); + text.find(&needle) + .map(|index| text[..index].trim_end()) + .unwrap_or(text) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use gpui::{Image, ImageFormat}; + use std::sync::Arc; + + pub(crate) fn shot() -> CapturedAppshot { + CapturedAppshot { + id: "shot-1".into(), + app_name: "Safari & Notes".into(), + bundle_identifier: Some("com.apple.".into()), + window_title: Some("A \"window\"".into()), + accessibility: AccessibilitySnapshot { + format_version: 1, + content: "AXTextField: ".into(), + truncated: true, + }, + screenshot: StagedAttachment { + id: "image-1".into(), + name: "Safari Appshot.png".into(), + image: Arc::new(Image::from_bytes(ImageFormat::Png, Vec::new())), + }, + screenshot_dimensions: Some((1440, 900)), + app_icon: None, + captured_at: Utc::now(), + } + } + + fn fixture_png(width: u32, height: u32, pixels: &[u8], depth: png::BitDepth) -> Vec { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(depth); + encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); + encoder + .write_header() + .unwrap() + .write_image_data(pixels) + .unwrap(); + bytes + } + + fn decoded_pixels( + bytes: &[u8], + ) -> (png::OutputInfo, Vec, Option) { + let mut reader = png::Decoder::new(std::io::Cursor::new(bytes)) + .read_info() + .unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let output = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(output.buffer_size()); + (output, pixels, reader.info().srgb) + } + + #[test] + fn capture_staging_removes_chrome_backing_surface_padding_before_layout() { + // Same failure as the supplied Chrome capture: a wider PNG canvas than + // visible content, with transparent columns exclusively on the right. + let mut pixels = vec![0; 302 * 165 * 4]; + for row in pixels.chunks_exact_mut(302 * 4) { + for pixel in row[..264 * 4].chunks_exact_mut(4) { + pixel.copy_from_slice(&[22, 33, 44, 255]); + } + } + let png = fixture_png(302, 165, &pixels, png::BitDepth::Eight); + let (staged, dimensions) = stage_appshot_png("Google Chrome", png).unwrap(); + assert_eq!(dimensions, (264, 165)); + assert_eq!(png_dimensions(staged.bytes()), Some(dimensions)); + let (_, decoded, profile) = decoded_pixels(staged.bytes()); + assert_eq!(decoded, [22, 33, 44, 255].repeat(264 * 165)); + assert_eq!(profile, Some(png::SrgbRenderingIntent::Perceptual)); + assert!(trim_appshot_padding(staged.bytes()).unwrap().is_none()); + let (width, height) = crate::composer::appshot_contained_size(Some(dimensions), 320.0); + assert!((width / height - 264.0 / 165.0).abs() < 0.0001); + } + + #[test] + fn padding_trim_preserves_rounded_corners_faint_pixels_and_interior_transparency() { + let mut pixels = vec![0; 8 * 7 * 4]; + for y in 1..6 { + for x in 2..7 { + pixels[(y * 8 + x) * 4..(y * 8 + x + 1) * 4].copy_from_slice(&[12, 34, 56, 255]); + } + } + // A rounded corner, a transparent interior hole, and the faintest + // possible nonzero-alpha edge must survive unchanged. + pixels[(1 * 8 + 2) * 4 + 3] = 0; + pixels[(3 * 8 + 4) * 4 + 3] = 0; + pixels[(3 * 8 + 2) * 4 + 3] = 1; + let png = fixture_png(8, 7, &pixels, png::BitDepth::Eight); + let trimmed = trim_appshot_padding(&png).unwrap().unwrap(); + let (info, actual, _) = decoded_pixels(&trimmed); + assert_eq!((info.width, info.height), (5, 5)); + let expected: Vec = (1..6) + .flat_map(|y| pixels[(y * 8 + 2) * 4..(y * 8 + 7) * 4].to_vec()) + .collect(); + assert_eq!(actual, expected); + } + + #[test] + fn padding_trim_keeps_opaque_margins_and_empty_images_byte_identical() { + for pixel in [[0, 0, 0, 255], [255, 255, 255, 255], [23, 45, 67, 0]] { + let png = fixture_png(8, 6, &pixel.repeat(48), png::BitDepth::Eight); + assert!(trim_appshot_padding(&png).unwrap().is_none()); + assert_eq!( + stage_appshot_png("Discord", png.clone()).unwrap().0.bytes(), + png + ); + } + } + + #[test] + fn padding_trim_preserves_16_bit_color_and_alpha_precision() { + let mut pixels = vec![0; 3 * 2 * 8]; + let sample = [0x12, 0x34, 0xab, 0xcd, 0x56, 0x78, 0x00, 0x01]; + pixels[8..16].copy_from_slice(&sample); + let png = fixture_png(3, 2, &pixels, png::BitDepth::Sixteen); + let trimmed = trim_appshot_padding(&png).unwrap().unwrap(); + let (info, actual, _) = decoded_pixels(&trimmed); + assert_eq!( + (info.width, info.height, info.bit_depth), + (1, 1, png::BitDepth::Sixteen) + ); + assert_eq!(actual, sample); + } + + #[test] + fn restored_queue_appshots_trim_legacy_padding_without_changing_attachment_identity() { + let pixels = [[0, 0, 0, 0], [44, 55, 66, 255], [0, 0, 0, 0]].concat(); + let mut original = shot(); + original.screenshot = crate::attachments::stage_png_bytes( + "Chrome.png".into(), + fixture_png(3, 1, &pixels, png::BitDepth::Eight), + ); + let path = "/host/legacy.png".to_string(); + let text = with_appshots( + "edit", + &[original.clone()], + &HashMap::from([(original.screenshot.id.clone(), path.clone())]), + ); + let (_, restored) = + restore_queued_appshots(&text, &[path], &[original.screenshot.clone()]).unwrap(); + assert_eq!(restored[0].screenshot.id, original.screenshot.id); + assert_eq!(restored[0].screenshot_dimensions, Some((1, 1))); + assert_eq!(restored[0].accessibility, original.accessibility); + assert_eq!( + decoded_pixels(restored[0].screenshot.bytes()).1, + [44, 55, 66, 255] + ); + // The original upload remains immutable. + assert_eq!(png_dimensions(original.screenshot.bytes()), Some((3, 1))); + } + + #[test] + fn png_dimensions_reads_ihdr_and_rejects_invalid_images() { + let mut png = Vec::from(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".as_slice()); + png.extend_from_slice(&1440_u32.to_be_bytes()); + png.extend_from_slice(&900_u32.to_be_bytes()); + assert_eq!(png_dimensions(&png), Some((1440, 900))); + + assert_eq!(png_dimensions(b"not a png"), None); + png[16..20].copy_from_slice(&0_u32.to_be_bytes()); + assert_eq!(png_dimensions(&png), None); + } + + #[test] + fn capture_dimensions_and_names_are_bounded_before_staging() { + assert_eq!(validate_capture_dimensions(4096, 4096), Ok(4096 * 4096 * 4)); + assert!(validate_capture_dimensions(8193, 1).is_err()); + assert!(validate_capture_dimensions(8192, 8192).is_err()); + assert_eq!(safe_app_name("Bad\nApp/../../name"), "Bad-App-..-..-name"); + assert_eq!(safe_app_name("\0\n"), "Application"); + } + + #[test] + fn appshot_context_is_escaped_and_strip_safe() { + let shot = shot(); + let paths = HashMap::from([("image-1".into(), "pending://id/a&b.png".into())]); + let prompt = with_appshots("Fix this", &[shot], &paths); + assert!(prompt.contains("app=\"Safari & Notes\"")); + assert!(prompt.contains("com.apple.<Safari>")); + assert!(prompt.contains("A "window"")); + assert!(prompt.contains("pending://id/a&b.png")); + assert!(prompt.contains("<ignore this>")); + assert_eq!(strip_context_for_display(&prompt), "Fix this"); + } + + #[test] + fn appshot_invalid_xml_characters_round_trip_through_queue_and_presentation() { + let mut original = shot(); + original.app_name = "App\0 & Notes".into(); + original.window_title = Some("Title\u{1b}\u{fffe}\u{ffff}".into()); + original.accessibility.content = format!( + "{}valid\t\r\n<&>é🦀\u{7f}\u{85}\u{10000}", + (0..32).filter_map(char::from_u32).collect::() + ); + let path = "/host/image.png".to_string(); + let encoded = with_appshots( + "inspect", + &[original.clone()], + &HashMap::from([(original.screenshot.id.clone(), path.clone())]), + ); + let sources = presentations(&encoded); + assert_eq!(sources[&path].app_name, "App� & Notes"); + assert_eq!(sources[&path].window_title.as_deref(), Some("Title���")); + let (_, restored) = + restore_queued_appshots(&encoded, &[path], &[original.screenshot.clone()]).unwrap(); + assert_eq!(restored[0].app_name, "App� & Notes"); + assert_eq!(restored[0].window_title.as_deref(), Some("Title���")); + assert_eq!( + restored[0].accessibility.content, + format!( + "{}\t\n{}\r{}valid\t\r\n<&>é🦀\u{7f}\u{85}\u{10000}", + "�".repeat(9), + "�".repeat(2), + "�".repeat(18) + ) + ); + } + + #[test] + fn queued_edit_round_trip_keeps_context_and_rebinds_uploaded_images() { + let mut original = shot(); + original.window_title = Some("Line 1\nLine 2\t&\"".into()); + original.accessibility.content = "\n<&secret>\r\n text\n".into(); + let mut ordinary = original.screenshot.clone(); + ordinary.id = "ordinary".into(); + let paths: Vec = vec!["/host/ordinary.png".into(), "/host/a&b.png".into()]; + let encoded = with_appshots( + "inspect", + &[original.clone()], + &HashMap::from([(original.screenshot.id.clone(), paths[1].clone())]), + ); + for text in [ + encoded.clone(), + crate::attachments::with_attachments(&encoded, &paths), + ] { + let (ordinary_restored, restored) = restore_queued_appshots( + &text, + &paths, + &[ordinary.clone(), original.screenshot.clone()], + ) + .unwrap(); + assert_eq!(ordinary_restored.len(), 1); + assert_eq!(ordinary_restored[0].id, "ordinary"); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].accessibility, original.accessibility); + assert_eq!(restored[0].window_title, original.window_title); + assert_eq!(restored[0].app_name, original.app_name); + let rebound = with_appshots( + "edited", + &restored, + &HashMap::from([(restored[0].screenshot.id.clone(), "/new/renamed.png".into())]), + ); + assert!(rebound.contains("image=\"/new/renamed.png\"")); + assert!(!rebound.contains("/host/")); + assert_eq!(strip_context_for_display(&rebound), "edited"); + assert_eq!(with_appshots("edited", &[], &HashMap::new()), "edited"); + } + } + + #[test] + fn queued_edit_rejects_invalid_or_unmatched_context_without_losing_images() { + let original = shot(); + let paths: Vec = vec!["/host/image.png".into()]; + let valid = with_appshots( + "", + &[original.clone()], + &HashMap::from([(original.screenshot.id.clone(), paths[0].clone())]), + ); + for invalid in [ + valid.replace("/host/image.png", "/missing.png"), + valid.replace("", ""), + valid.replace("( + future: impl std::future::Future>, + budget: Duration, +) -> anyhow::Result { + match futures::future::select(Box::pin(future), Box::pin(async_io::Timer::after(budget))).await + { + futures::future::Either::Left((result, _)) => result, + futures::future::Either::Right(_) => anyhow::bail!("Accessibility enrichment timed out"), + } +} + +pub(super) async fn capture_window(pid: u32, title: &str) -> anyhow::Result { + anyhow::ensure!( + pid != 0 && !title.trim().is_empty(), + "Missing native window identity" + ); + within_deadline( + async { + let started = Instant::now(); + let connection = AccessibilityConnection::new().await?; + let (identity, app_name) = select_window(&connection, pid, title).await?; + let snapshot = traverse(&connection, identity.object.clone(), started).await; + let (after, _) = select_window(&connection, pid, title).await?; + anyhow::ensure!( + identity == after, + "Accessibility window changed during capture" + ); + Ok(SemanticCapture { app_name, snapshot }) + }, + DEADLINE, + ) + .await +} + +/// PID comes from the accessibility bus, never from a display name. Require +/// exactly one matching top-level window, with an exact nonempty title, and +/// retain its bus-name/object-path identity across traversal. Duplicate titles +/// and missing metadata intentionally produce screenshot-only captures. +async fn select_window( + connection: &AccessibilityConnection, + pid: u32, + title: &str, +) -> anyhow::Result<(WindowIdentity, String)> { + let registry = connection.root_accessible_on_registry().await?; + let bus = atspi::zbus::fdo::DBusProxy::new(connection.connection()).await?; + let mut matches = Vec::new(); + for application_ref in registry.get_children().await? { + let Some(name) = application_ref.name() else { + continue; + }; + let process = bus + .get_connection_unix_process_id(name.clone().into()) + .await?; + if process != pid { + continue; + } + let application = connection.object_as_accessible(&application_ref).await?; + let app_name = application.name().await?; + for window_ref in application.get_children().await? { + let window = connection.object_as_accessible(&window_ref).await?; + if window.name().await? != title { + continue; + } + let states = window.get_state().await?; + matches.push(( + WindowIdentity { + object: window_ref, + pid, + title: title.to_owned(), + }, + app_name.clone(), + states.contains(State::Active) || states.contains(State::Focused), + )); + } + } + anyhow::ensure!( + matches.len() == 1, + "Accessibility window identity is ambiguous" + ); + let (identity, app, active) = matches.pop().unwrap(); + anyhow::ensure!(active, "Accessibility window is no longer active"); + Ok((identity, app)) +} + +async fn traverse( + connection: &AccessibilityConnection, + root: ObjectRefOwned, + started: Instant, +) -> AccessibilitySnapshot { + let mut queue = VecDeque::from([(root, 0_usize)]); + let mut output = String::new(); + let mut nodes = 0_usize; + let mut truncated = false; + + while let Some((object_ref, depth)) = queue.pop_front() { + if depth > MAX_DEPTH + || nodes >= MAX_NODES + || output.len() >= MAX_BYTES + || started.elapsed() >= DEADLINE + { + truncated = true; + break; + } + nodes += 1; + let accessible = match connection.object_as_accessible(&object_ref).await { + Ok(proxy) => proxy, + Err(_) => continue, + }; + let role = accessible.get_role().await.unwrap_or(Role::Invalid); + let name = accessible.name().await.unwrap_or_default(); + let description = accessible.description().await.unwrap_or_default(); + let interfaces = accessible.get_interfaces().await.ok(); + let text = if role != Role::PasswordText + && interfaces.is_some_and(|set| set.contains(Interface::Text)) + { + read_text(&accessible).await.unwrap_or_default() + } else { + String::new() + }; + let mut fields = Vec::new(); + if !name.trim().is_empty() { + fields.push(clean(&name)); + } + if !description.trim().is_empty() && description.trim() != name.trim() { + fields.push(clean(&description)); + } + if !text.trim().is_empty() && text.trim() != name.trim() { + fields.push(clean(&text)); + } + if !fields.is_empty() { + let line = format!( + "{}{}: {}\n", + " ".repeat(depth), + role.name(), + fields.join(" | ") + ); + if output.len() + line.len() > MAX_BYTES { + truncated = true; + break; + } + output.push_str(&line); + } + if let Ok(children) = accessible.get_children().await { + queue.extend(children.into_iter().map(|child| (child, depth + 1))); + } + } + + AccessibilitySnapshot { + format_version: 1, + content: output, + truncated, + } +} + +async fn read_text( + accessible: &atspi::proxy::accessible::AccessibleProxy<'_>, +) -> anyhow::Result { + let text = TextProxy::builder(accessible.inner().connection()) + .destination(accessible.inner().destination().clone())? + .path(accessible.inner().path().clone())? + .build() + .await?; + let count = text.character_count().await?.clamp(0, MAX_TEXT_CHARS); + Ok(text.get_text(0, count).await?) +} + +fn clean(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(MAX_TEXT_CHARS as usize) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(bus: &'static str, path: &'static str, pid: u32, title: &str) -> WindowIdentity { + WindowIdentity { + object: ObjectRefOwned::from_static_str_unchecked(bus, path), + pid, + title: title.into(), + } + } + + #[test] + fn window_identity_never_uses_fuzzy_titles_or_application_names() { + let original = identity(":1.2", "/window/1", 10, "a-b"); + assert_ne!(original, identity(":1.3", "/window/1", 20, "a-b")); + assert_ne!(original, identity(":1.2", "/window/2", 10, "a-b")); + assert_ne!(original, identity(":1.2", "/window/1", 10, "ab")); + } + + #[test] + fn hung_enrichment_releases_the_capture_pipeline() { + futures::executor::block_on(async { + assert!( + within_deadline( + futures::future::pending::>(), + Duration::from_millis(10) + ) + .await + .is_err() + ); + assert_eq!( + within_deadline(async { Ok(42) }, Duration::from_millis(100)) + .await + .unwrap(), + 42 + ); + }); + } +} diff --git a/crates/ui/src/appshots/linux/mod.rs b/crates/ui/src/appshots/linux/mod.rs new file mode 100644 index 000000000..3893de4f7 --- /dev/null +++ b/crates/ui/src/appshots/linux/mod.rs @@ -0,0 +1,165 @@ +//! Linux Appshots: portal-first on Wayland, direct X11 capture on X11, and +//! AT-SPI semantic enrichment on both display systems. + +mod atspi; +mod portal; +mod x11; + +use std::fs; +use std::os::unix::net::UnixDatagram; +use std::path::{Path, PathBuf}; +use std::sync::{OnceLock, RwLock}; +use std::thread; + +use futures::channel::mpsc; + +use super::{ + AppshotBackend, AppshotCapabilities, AppshotPlatform, CapabilityState, CaptureError, + CaptureTarget, CapturedAppshot, +}; + +const ACTIVATION_SOCKET: &str = "appshot-activation.sock"; + +#[derive(Debug, Clone, Copy)] +struct WaylandStatus { + shortcut: CapabilityState, + capture: CapabilityState, + target: CaptureTarget, +} + +impl Default for WaylandStatus { + fn default() -> Self { + Self { + shortcut: CapabilityState::Checking, + capture: CapabilityState::Checking, + target: CaptureTarget::PortalWindowPicker, + } + } +} + +fn wayland_status() -> &'static RwLock { + static STATUS: OnceLock> = OnceLock::new(); + STATUS.get_or_init(|| RwLock::new(WaylandStatus::default())) +} + +fn is_wayland() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) +} + +pub struct LinuxBackend; + +#[async_trait::async_trait] +impl AppshotBackend for LinuxBackend { + fn capabilities(&self) -> AppshotCapabilities { + if is_wayland() { + let status = wayland_status() + .read() + .map(|value| *value) + .unwrap_or_default(); + AppshotCapabilities { + platform: AppshotPlatform::LinuxWayland, + global_shortcut: status.shortcut, + window_capture: status.capture, + application_text: CapabilityState::Unavailable, + target: status.target, + } + } else { + AppshotCapabilities { + platform: AppshotPlatform::LinuxX11, + global_shortcut: x11::shortcut_state(), + window_capture: CapabilityState::Ready, + application_text: CapabilityState::Ready, + target: CaptureTarget::ActiveWindow, + } + } + } + + fn start_global_shortcut(&self, activation_dir: &Path) -> mpsc::UnboundedReceiver<()> { + let (tx, rx) = mpsc::unbounded(); + start_activation_socket(activation_dir.to_path_buf(), tx.clone()); + if is_wayland() { + portal::start_shortcut(tx, wayland_status()); + } else { + x11::start_shortcut(tx); + } + rx + } + + async fn capture_active_window(&self) -> Result { + if is_wayland() { + portal::capture(wayland_status()).await + } else { + // A passive X11 hotkey grab can temporarily blur GPUI's window. + // Check the window manager's active PID too, before portal consent. + if x11::viewer_is_active() { + return Err(CaptureError::SelfCapture); + } + if portal::active_window_supported().await { + // Once a portal operation starts, its cancellation, denial or + // failure ends that operation. Native fallback is only for an + // unavailable capability, never a second capture attempt. + return portal::capture_target(CaptureTarget::ActiveWindow).await; + } + x11::capture().await + } + } +} + +pub fn activation_socket_path(data_dir: &Path) -> PathBuf { + data_dir.join(ACTIVATION_SOCKET) +} + +fn start_activation_socket(data_dir: PathBuf, tx: mpsc::UnboundedSender<()>) { + thread::Builder::new() + .name("appshot-linux-activation".into()) + .spawn(move || { + if fs::create_dir_all(&data_dir).is_err() { + return; + } + let path = activation_socket_path(&data_dir); + // The socket belongs exclusively to this feature. Removing a stale + // inode here is safe and lets a restarted Zeron become reachable. + let _ = fs::remove_file(&path); + let Ok(socket) = UnixDatagram::bind(&path) else { + return; + }; + let mut buffer = [0_u8; 32]; + while let Ok(length) = socket.recv(&mut buffer) { + if length > 0 && tx.unbounded_send(()).is_err() { + break; + } + } + let _ = fs::remove_file(path); + }) + .ok(); +} + +pub fn request_running_appshot(data_dir: &Path) -> Result<(), CaptureError> { + let socket = UnixDatagram::unbound().map_err(|error| { + CaptureError::CaptureFailed(format!( + "Could not create Appshot activation socket: {error}" + )) + })?; + socket + .connect(activation_socket_path(data_dir)) + .and_then(|_| socket.send(b"capture")) + .map(|_| ()) + .map_err(|error| { + CaptureError::CaptureFailed(format!( + "Could not reach a running Zeron instance for Appshot capture: {error}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activation_path_stays_inside_data_directory() { + assert_eq!( + activation_socket_path(Path::new("/tmp/zeron-test")), + Path::new("/tmp/zeron-test/appshot-activation.sock") + ); + } +} diff --git a/crates/ui/src/appshots/linux/portal.rs b/crates/ui/src/appshots/linux/portal.rs new file mode 100644 index 000000000..2f3bee5d8 --- /dev/null +++ b/crates/ui/src/appshots/linux/portal.rs @@ -0,0 +1,276 @@ +use std::fs; +use std::io::Read as _; +use std::sync::RwLock; +use std::thread; + +use ashpd::desktop::CreateSessionOptions; +use ashpd::desktop::global_shortcuts::{ + BindShortcutsOptions, ConfigureShortcutsOptions, GlobalShortcuts, NewShortcut, +}; +use ashpd::desktop::screenshot::{AvailableTargets, Screenshot, ScreenshotProxy}; +use futures::channel::mpsc; +use futures::{FutureExt as _, StreamExt as _}; + +use super::WaylandStatus; +use crate::appshots::{CapabilityState, CaptureError, CaptureTarget, CapturedAppshot}; + +const SHORTCUT_ID: &str = "capture-appshot"; + +pub(super) fn start_shortcut( + tx: mpsc::UnboundedSender<()>, + status: &'static RwLock, +) { + thread::Builder::new() + .name("appshot-wayland-portal".into()) + .spawn(move || { + futures::executor::block_on(probe_capture(status)); + futures::executor::block_on(async { + let mut updates = crate::appshots::shortcut::subscribe(); + let mut desired = updates.next().await.flatten(); + let mut last_bound = None; + loop { + let Some(shortcut) = desired.take() else { + let Some(next) = updates.next().await else { + break; + }; + desired = next; + continue; + }; + let configure = last_bound.as_ref().is_some_and(|old| old != &shortcut); + match run_shortcut(tx.clone(), status, &shortcut, configure, &mut updates).await + { + Ok(next) => { + last_bound = Some(shortcut); + desired = next; + } + Err(error) => { + tracing::warn!(?error, "Wayland global-shortcut portal unavailable"); + if let Ok(mut status) = status.write() { + status.shortcut = CapabilityState::SetupRequired; + } + let Some(next) = updates.next().await else { + break; + }; + desired = next; + } + } + } + }); + }) + .ok(); +} + +async fn probe_capture(status: &'static RwLock) { + let detected = match ScreenshotProxy::new().await { + Ok(proxy) if proxy.version() >= 3 => match proxy.available_targets().await { + Ok(targets) if targets.contains(AvailableTargets::ActiveWindow) => WaylandStatus { + capture: CapabilityState::Ready, + target: CaptureTarget::ActiveWindow, + ..status.read().map(|value| *value).unwrap_or_default() + }, + Ok(targets) if targets.contains(AvailableTargets::Window) => WaylandStatus { + capture: CapabilityState::UserSelection, + target: CaptureTarget::PortalWindowPicker, + ..status.read().map(|value| *value).unwrap_or_default() + }, + _ => WaylandStatus { + capture: CapabilityState::Unavailable, + ..status.read().map(|value| *value).unwrap_or_default() + }, + }, + Ok(_) => WaylandStatus { + capture: CapabilityState::Unavailable, + target: CaptureTarget::PortalWindowPicker, + ..status.read().map(|value| *value).unwrap_or_default() + }, + Err(_) => WaylandStatus { + capture: CapabilityState::Unavailable, + ..status.read().map(|value| *value).unwrap_or_default() + }, + }; + if let Ok(mut current) = status.write() { + current.capture = detected.capture; + current.target = detected.target; + } +} + +pub(super) async fn active_window_supported() -> bool { + match ScreenshotProxy::new().await { + Ok(proxy) if proxy.version() >= 3 => proxy + .available_targets() + .await + .is_ok_and(|targets| targets.contains(AvailableTargets::ActiveWindow)), + _ => false, + } +} + +async fn run_shortcut( + tx: mpsc::UnboundedSender<()>, + status: &'static RwLock, + selected: &crate::appshots::shortcut::Shortcut, + configure: bool, + updates: &mut mpsc::UnboundedReceiver>, +) -> Result, ashpd::Error> { + let portal = GlobalShortcuts::new().await?; + let session = portal + .create_session(CreateSessionOptions::default()) + .await?; + // Always close the old session before rebinding, including when consent is + // denied or the feature is switched off. Dropping the proxy does not close it. + let result = async { + let trigger = selected.portal_trigger(); + let shortcut = NewShortcut::new(SHORTCUT_ID, "Capture an Appshot").preferred_trigger(Some(trigger.as_str())); + portal.bind_shortcuts(&session, &[shortcut], None, BindShortcutsOptions::default()).await?.response()?; + if configure && portal.version() >= 2 { + portal.configure_shortcuts(&session, None, ConfigureShortcutsOptions::default()).await?; + } + if let Ok(mut status) = status.write() { status.shortcut = CapabilityState::Ready; } + let mut activations = portal.receive_activated().await?; + loop { + futures::select! { + next = updates.next().fuse() => { + let next = next.flatten(); + if next.as_ref() != Some(selected) { return Ok(next); } + }, + activation = activations.next().fuse() => { + let Some(activation) = activation else { return Ok(None); }; + if activation.shortcut_id() == SHORTCUT_ID && tx.unbounded_send(()).is_err() { return Ok(None); } + }, + } + } + }.await; + let _ = session.close().await; + result +} + +pub(super) async fn capture( + status: &'static RwLock, +) -> Result { + probe_capture(status).await; + let target = status + .read() + .map(|value| value.target) + .unwrap_or(CaptureTarget::PortalWindowPicker); + capture_target(target).await +} + +pub(super) async fn capture_target(target: CaptureTarget) -> Result { + // Do not send an unsupported target: older portals may silently capture + // the entire screen, even when interactive customization is requested. + let proxy = ScreenshotProxy::new().await.map_err(|error| { + CaptureError::CaptureFailed(format!("Screenshot portal unavailable: {error}")) + })?; + let requested = match target { + CaptureTarget::ActiveWindow => AvailableTargets::ActiveWindow, + CaptureTarget::PortalWindowPicker => AvailableTargets::Window, + }; + if proxy.version() < 3 + || !proxy + .available_targets() + .await + .is_ok_and(|targets| targets.contains(requested)) + { + return Err(CaptureError::CaptureFailed( + "This screenshot portal does not support window-only capture. Update your desktop portal to use Appshots.".into(), + )); + } + // The Screenshot portal returns pixels but no verifiable native window + // identity. Never attach text from whichever window happens to have focus. + let portal_target = match target { + CaptureTarget::ActiveWindow => AvailableTargets::ActiveWindow, + CaptureTarget::PortalWindowPicker => AvailableTargets::Window, + }; + let response = Screenshot::request() + .target(portal_target) + .interactive(target == CaptureTarget::PortalWindowPicker) + .modal(false) + .send() + .await + .map_err(portal_capture_error)? + .response() + .map_err(portal_capture_error)?; + let uri = url::Url::parse(response.uri().as_str()).map_err(|error| { + CaptureError::CaptureFailed(format!("Invalid portal image URI: {error}")) + })?; + let path = uri.to_file_path().map_err(|_| { + CaptureError::CaptureFailed("Screenshot portal returned a non-file URI.".into()) + })?; + let file_len = fs::metadata(&path) + .map_err(|error| { + CaptureError::CaptureFailed(format!("Could not inspect portal screenshot: {error}")) + })? + .len(); + if file_len > crate::attachments::MAX_ATTACHMENT_BYTES { + return Err(CaptureError::CaptureFailed( + "The portal screenshot is larger than Zeron's 24 MB image limit.".into(), + )); + } + let file = fs::File::open(path).map_err(|error| { + CaptureError::CaptureFailed(format!("Could not open portal screenshot: {error}")) + })?; + let mut bytes = Vec::with_capacity(file_len as usize); + file.take(crate::attachments::MAX_ATTACHMENT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + CaptureError::CaptureFailed(format!("Could not read portal screenshot: {error}")) + })?; + if bytes.len() as u64 > crate::attachments::MAX_ATTACHMENT_BYTES { + return Err(CaptureError::CaptureFailed( + "The portal screenshot changed size while it was being read.".into(), + )); + } + let app_name = "Selected window".to_string(); + let window_title = None; + let accessibility = crate::appshots::AccessibilitySnapshot::unavailable(); + let (screenshot, dimensions) = super::super::stage_appshot_png(&app_name, bytes)?; + Ok(CapturedAppshot { + id: uuid::Uuid::new_v4().to_string(), + app_name, + bundle_identifier: Some( + match target { + CaptureTarget::ActiveWindow => "linux-portal:active-window", + CaptureTarget::PortalWindowPicker => "linux-portal:selection", + } + .into(), + ), + window_title, + accessibility, + screenshot, + screenshot_dimensions: Some(dimensions), + app_icon: None, + captured_at: chrono::Utc::now(), + }) +} + +fn portal_capture_error(error: ashpd::Error) -> CaptureError { + match error { + ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled) + | ashpd::Error::Portal(ashpd::PortalError::Cancelled(_)) => CaptureError::Cancelled, + error => CaptureError::CaptureFailed(format!("Screenshot portal failed: {error}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_survives_both_portal_error_paths() { + assert_eq!( + portal_capture_error(ashpd::Error::Response( + ashpd::desktop::ResponseError::Cancelled + )), + CaptureError::Cancelled + ); + assert_eq!( + portal_capture_error(ashpd::Error::Portal(ashpd::PortalError::Cancelled( + "cancelled".into() + ))), + CaptureError::Cancelled + ); + assert!(matches!( + portal_capture_error(ashpd::Error::NoResponse), + CaptureError::CaptureFailed(_) + )); + } +} diff --git a/crates/ui/src/appshots/linux/x11.rs b/crates/ui/src/appshots/linux/x11.rs new file mode 100644 index 000000000..d1cf99df4 --- /dev/null +++ b/crates/ui/src/appshots/linux/x11.rs @@ -0,0 +1,575 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; + +use futures::channel::mpsc; +use gpui::{Image, ImageFormat as GpuiImageFormat}; +use x11rb::connection::Connection; +use x11rb::image::{Image as XImage, PixelLayout}; +use x11rb::protocol::Event; +use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, GrabMode, ModMask, Window}; +use x11rb::rust_connection::RustConnection; + +use super::atspi; +use crate::appshots::{ + AccessibilitySnapshot, CapabilityState, CaptureError, CapturedAppshot, + validate_capture_dimensions, +}; + +static SHORTCUT_READY: AtomicBool = AtomicBool::new(true); + +pub(super) fn shortcut_state() -> CapabilityState { + if SHORTCUT_READY.load(Ordering::Relaxed) { + CapabilityState::Ready + } else { + CapabilityState::SetupRequired + } +} + +pub(super) fn start_shortcut(tx: mpsc::UnboundedSender<()>) { + thread::Builder::new() + .name("appshot-x11-hotkey".into()) + .spawn(move || { + if let Err(error) = run_shortcut(tx) { + SHORTCUT_READY.store(false, Ordering::Relaxed); + tracing::warn!(?error, "X11 Appshot shortcut could not be registered"); + } + }) + .ok(); +} + +fn run_shortcut(tx: mpsc::UnboundedSender<()>) -> anyhow::Result<()> { + let (connection, screen_index) = x11rb::connect(None)?; + let root = connection.setup().roots[screen_index].root; + let mut previous = None; + let mut keycode = None; + while !tx.is_closed() { + let shortcut = super::super::shortcut::current(); + if previous.as_ref() != Some(&shortcut) { + if let Some(keycode) = keycode.take() { + connection + .ungrab_key(keycode, root, ModMask::ANY)? + .check()?; + } + if let Some(shortcut) = &shortcut { + match register_shortcut(&connection, root, shortcut) { + Ok(code) => { + keycode = Some(code); + SHORTCUT_READY.store(true, Ordering::Relaxed); + } + Err(error) => { + SHORTCUT_READY.store(false, Ordering::Relaxed); + tracing::warn!(?error, "X11 Appshot shortcut could not be registered"); + } + } + } + connection.flush()?; + while connection.poll_for_event()?.is_some() {} + previous = Some(shortcut.clone()); + } + while let Some(event) = connection.poll_for_event()? { + if let Event::KeyPress(event) = event + && Some(event.detail) == keycode + { + let _ = tx.unbounded_send(()); + } + } + thread::sleep(std::time::Duration::from_millis(20)); + } + Ok(()) +} + +fn register_shortcut( + connection: &RustConnection, + root: Window, + shortcut: &super::super::shortcut::Shortcut, +) -> anyhow::Result { + let setup = connection.setup(); + let minimum = setup.min_keycode; + let count = setup.max_keycode.saturating_sub(minimum).saturating_add(1); + let mapping = connection.get_keyboard_mapping(minimum, count)?.reply()?; + let per_keycode = usize::from(mapping.keysyms_per_keycode); + anyhow::ensure!(per_keycode > 0, "X11 returned an empty keyboard map"); + let keysym = + shortcut_keysym(&shortcut.key).ok_or_else(|| anyhow::anyhow!("unsupported Appshot key"))?; + let keycode = mapping + .keysyms + .chunks(per_keycode) + .position(|symbols| symbols.contains(&keysym)) + .map(|index| minimum.saturating_add(index as u8)) + .ok_or_else(|| anyhow::anyhow!("Appshot key is absent from the X11 keyboard map"))?; + let base = (if shortcut.control { + ModMask::CONTROL + } else { + Default::default() + }) | (if shortcut.alt { + ModMask::M1 + } else { + Default::default() + }) | (if shortcut.shift { + ModMask::SHIFT + } else { + Default::default() + }) | (if shortcut.platform { + ModMask::M4 + } else { + Default::default() + }); + for modifiers in [ + base, + base | ModMask::LOCK, + base | ModMask::M2, + base | ModMask::LOCK | ModMask::M2, + ] { + if let Err(error) = connection + .grab_key( + false, + root, + modifiers, + keycode, + GrabMode::ASYNC, + GrabMode::ASYNC, + )? + .check() + { + // A partial registration must not keep consuming an old/new chord. + connection + .ungrab_key(keycode, root, ModMask::ANY)? + .check()?; + return Err(error.into()); + } + } + Ok(keycode) +} + +fn shortcut_keysym(key: &str) -> Option { + Some(match key { + "space" => 0x20, + "tab" => 0xff09, + "enter" => 0xff0d, + "backspace" => 0xff08, + "delete" => 0xffff, + "insert" => 0xff63, + "up" => 0xff52, + "down" => 0xff54, + "left" => 0xff51, + "right" => 0xff53, + "home" => 0xff50, + "end" => 0xff57, + "pageup" => 0xff55, + "pagedown" => 0xff56, + key if key.len() == 1 => u32::from(key.as_bytes()[0]), + key => 0xffbe + u32::from(key.strip_prefix('f')?.parse::().ok()?.checked_sub(1)?), + }) +} + +pub(super) fn viewer_is_active() -> bool { + let Ok((connection, screen_index)) = x11rb::connect(None) else { + return false; + }; + let root = connection.setup().roots[screen_index].root; + let Ok(active_atom) = atom(&connection, b"_NET_ACTIVE_WINDOW") else { + return false; + }; + let Some(active) = property_u32(&connection, root, active_atom, AtomEnum::WINDOW.into()) + .and_then(|values| values.first().copied()) + else { + return false; + }; + window_belongs_to_viewer(&connection, active) +} + +fn window_belongs_to_viewer(connection: &RustConnection, window: Window) -> bool { + let Ok(pid_atom) = atom(connection, b"_NET_WM_PID") else { + return false; + }; + property_u32(connection, window, pid_atom, AtomEnum::CARDINAL.into()) + .and_then(|values| values.first().copied()) + == Some(std::process::id()) +} + +pub(super) async fn capture() -> Result { + let native = capture_native()?; + let semantic = if let Some(identity) = &native.identity { + let semantic = atspi::capture_window(identity.pid, &identity.title) + .await + .ok(); + // AT-SPI and X11 have no shared window ID. A unique PID/title mapping + // on both sides is required, and the actual XID must remain unchanged. + if current_identity().as_ref() == Some(identity) { + semantic + } else { + None + } + } else { + None + }; + let app_name = native + .desktop + .as_ref() + .and_then(|desktop| desktop.name.clone()) + .or_else(|| semantic.as_ref().map(|value| value.app_name.clone())) + .or_else(|| native.wm_class.clone()) + .unwrap_or_else(|| "X11 application".into()); + let accessibility = semantic + .map(|value| value.snapshot) + .unwrap_or_else(AccessibilitySnapshot::unavailable); + let (screenshot, dimensions) = super::super::stage_appshot_png(&app_name, native.png)?; + Ok(CapturedAppshot { + id: uuid::Uuid::new_v4().to_string(), + app_name, + bundle_identifier: native + .desktop + .as_ref() + .and_then(|desktop| desktop.id.clone()) + .or_else(|| native.wm_class.map(|value| format!("linux-x11:{value}"))), + window_title: native.title, + accessibility, + screenshot, + screenshot_dimensions: Some(dimensions), + app_icon: native + .icon_png + .map(|bytes| Arc::new(Image::from_bytes(GpuiImageFormat::Png, bytes))), + captured_at: chrono::Utc::now(), + }) +} + +struct NativeCapture { + identity: Option, + png: Vec, + title: Option, + wm_class: Option, + desktop: Option, + icon_png: Option>, +} + +fn capture_native() -> Result { + let (connection, screen_index) = x11rb::connect(None).map_err(failed)?; + let root = connection.setup().roots[screen_index].root; + let active_atom = atom(&connection, b"_NET_ACTIVE_WINDOW")?; + let active = property_u32(&connection, root, active_atom, AtomEnum::WINDOW.into()) + .and_then(|values| values.first().copied()) + .ok_or(CaptureError::NoEligibleWindow)?; + if window_belongs_to_viewer(&connection, active) { + return Err(CaptureError::SelfCapture); + } + let identity = read_identity(&connection, root, active); + let geometry = connection + .get_geometry(active) + .map_err(failed)? + .reply() + .map_err(failed)?; + if geometry.width == 0 || geometry.height == 0 { + return Err(CaptureError::NoEligibleWindow); + } + let rgba_len = + validate_capture_dimensions(u32::from(geometry.width), u32::from(geometry.height))?; + let (image, visual_id) = + XImage::get(&connection, active, 0, 0, geometry.width, geometry.height).map_err(failed)?; + let visual = connection + .setup() + .roots + .iter() + .flat_map(|screen| &screen.allowed_depths) + .flat_map(|depth| &depth.visuals) + .find(|visual| visual.visual_id == visual_id) + .copied() + .ok_or_else(|| CaptureError::CaptureFailed("X11 returned an unknown visual.".into()))?; + let layout = PixelLayout::from_visual_type(visual).map_err(failed)?; + let mut rgba = Vec::with_capacity(rgba_len); + for y in 0..geometry.height { + for x in 0..geometry.width { + let (red, green, blue) = layout.decode(image.get_pixel(x, y)); + rgba.extend_from_slice(&[(red >> 8) as u8, (green >> 8) as u8, (blue >> 8) as u8, 255]); + } + } + let png = encode_rgba(u32::from(geometry.width), u32::from(geometry.height), &rgba)?; + let still_active = property_u32(&connection, root, active_atom, AtomEnum::WINDOW.into()) + .and_then(|values| values.first().copied()); + if still_active != Some(active) { + return Err(CaptureError::CaptureFailed( + "The active X11 window changed during capture; try again.".into(), + )); + } + let title = window_title(&connection, active); + let wm_class = window_class(&connection, active); + let desktop = wm_class.as_deref().and_then(find_desktop_entry); + let icon_png = property_icon(&connection, active).or_else(|| { + desktop + .as_ref() + .and_then(|entry| entry.icon.as_deref()) + .and_then(load_theme_icon) + }); + Ok(NativeCapture { + identity, + png, + title, + wm_class, + desktop, + icon_png, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NativeIdentity { + window: Window, + pid: u32, + title: String, +} + +fn current_identity() -> Option { + let (connection, screen_index) = x11rb::connect(None).ok()?; + let root = connection.setup().roots[screen_index].root; + let active_atom = atom(&connection, b"_NET_ACTIVE_WINDOW").ok()?; + let active = *property_u32(&connection, root, active_atom, AtomEnum::WINDOW.into())?.first()?; + read_identity(&connection, root, active) +} + +fn read_identity( + connection: &RustConnection, + root: Window, + active: Window, +) -> Option { + let pid_atom = atom(connection, b"_NET_WM_PID").ok()?; + let pid = *property_u32(connection, active, pid_atom, AtomEnum::CARDINAL.into())?.first()?; + let title = window_title(connection, active).filter(|title| !title.trim().is_empty())?; + let clients_atom = atom(connection, b"_NET_CLIENT_LIST").ok()?; + let clients = property_u32(connection, root, clients_atom, AtomEnum::WINDOW.into())?; + let mut matching = Vec::new(); + for window in clients { + let process = property_u32(connection, window, pid_atom, AtomEnum::CARDINAL.into()) + .and_then(|values| values.first().copied()); + if process == Some(pid) + && window_title(connection, window).as_deref() == Some(title.as_str()) + { + matching.push(window); + } + } + (pid != 0 && matching == [active]).then_some(NativeIdentity { + window: active, + pid, + title, + }) +} + +fn atom(connection: &RustConnection, name: &[u8]) -> Result { + connection + .intern_atom(false, name) + .map_err(failed)? + .reply() + .map(|reply| reply.atom) + .map_err(failed) +} + +fn property_u32( + connection: &RustConnection, + window: Window, + property: Atom, + property_type: Atom, +) -> Option> { + connection + .get_property(false, window, property, property_type, 0, u32::MAX) + .ok()? + .reply() + .ok()? + .value32() + .map(Iterator::collect) +} + +fn property_bytes( + connection: &RustConnection, + window: Window, + property: Atom, + property_type: Atom, +) -> Option> { + connection + .get_property(false, window, property, property_type, 0, u32::MAX) + .ok()? + .reply() + .ok() + .map(|reply| reply.value) +} + +fn window_title(connection: &RustConnection, window: Window) -> Option { + let utf8 = atom(connection, b"UTF8_STRING").ok()?; + let net_name = atom(connection, b"_NET_WM_NAME").ok()?; + property_bytes(connection, window, net_name, utf8) + .or_else(|| { + property_bytes( + connection, + window, + AtomEnum::WM_NAME.into(), + AtomEnum::STRING.into(), + ) + }) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|value| !value.trim().is_empty()) +} + +fn window_class(connection: &RustConnection, window: Window) -> Option { + let bytes = property_bytes( + connection, + window, + AtomEnum::WM_CLASS.into(), + AtomEnum::STRING.into(), + )?; + bytes + .split(|byte| *byte == 0) + .filter_map(|part| std::str::from_utf8(part).ok()) + .filter(|value| !value.trim().is_empty()) + .next_back() + .map(str::to_string) +} + +fn property_icon(connection: &RustConnection, window: Window) -> Option> { + let icon_atom = atom(connection, b"_NET_WM_ICON").ok()?; + let values = property_u32(connection, window, icon_atom, AtomEnum::CARDINAL.into())?; + let mut candidates = Vec::new(); + let mut offset = 0_usize; + while offset + 2 <= values.len() { + let width = values[offset] as usize; + let height = values[offset + 1] as usize; + offset += 2; + let length = width.checked_mul(height)?; + if width > 512 || height > 512 || length > 512 * 512 { + offset = offset.checked_add(length)?; + continue; + } + if width > 0 && height > 0 && offset + length <= values.len() { + candidates.push(( + width.abs_diff(64) + height.abs_diff(64), + width, + height, + &values[offset..offset + length], + )); + } + offset = offset.saturating_add(length); + } + let (_, width, height, pixels) = candidates.into_iter().min_by_key(|value| value.0)?; + let mut rgba = Vec::with_capacity(width * height * 4); + for argb in pixels { + rgba.extend_from_slice(&[ + (argb >> 16) as u8, + (argb >> 8) as u8, + *argb as u8, + (argb >> 24) as u8, + ]); + } + encode_rgba(width as u32, height as u32, &rgba).ok() +} + +fn encode_rgba(width: u32, height: u32, rgba: &[u8]) -> Result, CaptureError> { + super::super::encode_rgba_png(width, height, rgba, "X11") +} + +#[derive(Clone)] +struct DesktopEntry { + id: Option, + name: Option, + icon: Option, +} + +fn find_desktop_entry(wm_class: &str) -> Option { + let needle = wm_class.to_ascii_lowercase(); + desktop_directories() + .into_iter() + .find_map(|directory| { + fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .find_map(|item| { + let path = item.path(); + if path.extension().and_then(|value| value.to_str()) != Some("desktop") { + return None; + } + let contents = fs::read_to_string(&path).ok()?; + let fields = desktop_fields(&contents); + let file_matches = path + .file_stem() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(wm_class)); + let class_matches = fields + .iter() + .find(|(key, _)| key == "StartupWMClass") + .is_some_and(|(_, value)| value.eq_ignore_ascii_case(wm_class)); + if !file_matches && !class_matches { + return None; + } + Some(DesktopEntry { + id: path + .file_name() + .and_then(|value| value.to_str()) + .map(str::to_string), + name: fields + .iter() + .find(|(key, _)| key == "Name") + .map(|(_, value)| value.clone()), + icon: fields + .iter() + .find(|(key, _)| key == "Icon") + .map(|(_, value)| value.clone()), + }) + }) + }) + .or_else(|| { + Some(DesktopEntry { + id: Some(format!("linux-x11:{needle}")), + name: None, + icon: None, + }) + }) +} + +fn desktop_fields(contents: &str) -> Vec<(String, String)> { + let mut in_entry = false; + let mut fields = Vec::new(); + for line in contents.lines() { + if line.starts_with('[') { + in_entry = line.trim() == "[Desktop Entry]"; + continue; + } + if in_entry + && let Some((key, value)) = line.split_once('=') + && matches!(key, "Name" | "Icon" | "StartupWMClass") + { + fields.push((key.into(), value.trim().into())); + } + } + fields +} + +fn desktop_directories() -> Vec { + let mut directories = vec![ + PathBuf::from("/usr/share/applications"), + PathBuf::from("/usr/local/share/applications"), + ]; + if let Some(home) = std::env::var_os("HOME") { + directories.insert(0, PathBuf::from(home).join(".local/share/applications")); + } + directories +} + +fn load_theme_icon(name: &str) -> Option> { + let path = Path::new(name); + if path.is_absolute() { + return fs::read(path).ok(); + } + let mut candidates = vec![ + PathBuf::from("/usr/share/pixmaps").join(format!("{name}.png")), + PathBuf::from("/usr/share/icons/hicolor/64x64/apps").join(format!("{name}.png")), + PathBuf::from("/usr/share/icons/hicolor/128x128/apps").join(format!("{name}.png")), + ]; + if let Some(home) = std::env::var_os("HOME") { + candidates.insert( + 0, + PathBuf::from(home).join(format!(".local/share/icons/hicolor/64x64/apps/{name}.png")), + ); + } + candidates.into_iter().find_map(|path| fs::read(path).ok()) +} + +fn failed(error: impl std::fmt::Display) -> CaptureError { + CaptureError::CaptureFailed(format!("X11 Appshot capture failed: {error}")) +} diff --git a/crates/ui/src/appshots/macos.rs b/crates/ui/src/appshots/macos.rs new file mode 100644 index 000000000..69e550608 --- /dev/null +++ b/crates/ui/src/appshots/macos.rs @@ -0,0 +1,1259 @@ +use std::cell::RefCell; +use std::ffi::{CStr, c_void}; +use std::ptr; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use block::ConcreteBlock; +use chrono::Utc; +use core_foundation::array::CFArray; +use core_foundation::base::{CFRelease, CFType, CFTypeRef, TCFType}; +use core_foundation::boolean::CFBoolean; +use core_foundation::dictionary::CFDictionary; +use core_foundation::number::CFNumber; +use core_foundation::string::{CFString, CFStringRef}; +use core_graphics::access::ScreenCaptureAccess; +use core_graphics::geometry::{CGPoint, CGRect, CGSize}; +use core_graphics::window::{ + CGWindowID, copy_window_info, create_image, kCGNullWindowID, kCGWindowAlpha, kCGWindowBounds, + kCGWindowImageBoundsIgnoreFraming, kCGWindowImageNominalResolution, kCGWindowIsOnscreen, + kCGWindowLayer, kCGWindowListExcludeDesktopElements, kCGWindowListOptionAll, + kCGWindowListOptionIncludingWindow, kCGWindowName, kCGWindowNumber, kCGWindowOwnerPID, +}; +use futures::channel::mpsc; +use objc::rc::autoreleasepool; +use objc::runtime::{Class, Object}; +use objc::{class, msg_send, sel, sel_impl}; + +use super::{ + ACCESSIBILITY_SETTINGS_URL, AccessibilitySnapshot, AppshotBackend, AppshotCapabilities, + AppshotPlatform, CapabilityState, CaptureError, CaptureTarget, CapturedAppshot, + SCREEN_RECORDING_SETTINGS_URL, +}; + +const COMMAND_KEY: u32 = 1 << 8; +const SHIFT_KEY: u32 = 1 << 9; +const OPTION_KEY: u32 = 1 << 11; +const CONTROL_KEY: u32 = 1 << 12; +const EVENT_CLASS_KEYBOARD: u32 = u32::from_be_bytes(*b"keyb"); +const EVENT_HOT_KEY_PRESSED: u32 = 5; +const APPSHOT_HOT_KEY_SIGNATURE: u32 = u32::from_be_bytes(*b"ZAPS"); +const MAX_AX_DEPTH: usize = 24; +const MAX_AX_NODES: usize = 1_500; +const MAX_AX_BYTES: usize = 96 * 1024; +const MAX_AX_VALUE_CHARS: usize = 4_096; +const AX_DEADLINE: Duration = Duration::from_millis(900); +const SCREEN_CAPTURE_KIT_TIMEOUT: Duration = Duration::from_secs(4); +const MAX_CAPTURE_DIMENSION: f64 = 4_096.0; +static SHORTCUT_READY: AtomicBool = AtomicBool::new(true); + +type AXUIElementRef = *const c_void; +type AXError = i32; +const AX_SUCCESS: AXError = 0; + +type EventTargetRef = *mut c_void; +type EventHandlerCallRef = *mut c_void; +type EventRef = *mut c_void; +type EventHotKeyRef = *mut c_void; + +#[repr(C)] +struct EventTypeSpec { + event_class: u32, + event_kind: u32, +} + +#[repr(C)] +struct EventHotKeyId { + signature: u32, + id: u32, +} + +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + fn AXIsProcessTrusted() -> bool; + fn AXIsProcessTrustedWithOptions(options: *const c_void) -> bool; + fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef; + fn AXUIElementCopyAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: *mut CFTypeRef, + ) -> AXError; + fn AXUIElementSetMessagingTimeout(element: AXUIElementRef, seconds: f32) -> AXError; + fn AXValueGetTypeID() -> core_foundation::base::CFTypeID; + fn AXValueGetValue(value: CFTypeRef, kind: u32, result: *mut c_void) -> bool; + static kAXTrustedCheckOptionPrompt: CFStringRef; +} + +#[link(name = "Carbon", kind = "framework")] +unsafe extern "C" { + fn TISCopyCurrentASCIICapableKeyboardLayoutInputSource() -> CFTypeRef; + fn TISGetInputSourceProperty(source: CFTypeRef, property: CFStringRef) -> *const c_void; + static kTISPropertyUnicodeKeyLayoutData: CFStringRef; + fn UCKeyTranslate( + layout: *const c_void, + key_code: u16, + action: u16, + modifiers: u32, + keyboard_type: u32, + options: u32, + dead_key_state: *mut u32, + max_length: usize, + actual_length: *mut usize, + output: *mut u16, + ) -> i32; + fn LMGetKbdType() -> u16; + fn UnregisterEventHotKey(hot_key: EventHotKeyRef) -> i32; + fn GetApplicationEventTarget() -> EventTargetRef; + fn InstallEventHandler( + target: EventTargetRef, + handler: Option i32>, + event_type_count: u32, + event_types: *const EventTypeSpec, + user_data: *mut c_void, + out_handler: *mut *mut c_void, + ) -> i32; + fn RegisterEventHotKey( + key_code: u32, + modifiers: u32, + hot_key_id: EventHotKeyId, + target: EventTargetRef, + options: u32, + out_ref: *mut EventHotKeyRef, + ) -> i32; +} + +unsafe extern "C" { + fn dlopen(path: *const std::ffi::c_char, mode: i32) -> *mut c_void; +} + +struct FrontmostApplication { + pid: i32, + name: String, + bundle_identifier: Option, + icon_png: Option>, +} + +struct FrontmostWindow { + id: CGWindowID, + title: Option, +} + +pub struct MacOsBackend; + +#[async_trait::async_trait] +impl AppshotBackend for MacOsBackend { + fn capabilities(&self) -> AppshotCapabilities { + AppshotCapabilities { + platform: AppshotPlatform::MacOs, + global_shortcut: if SHORTCUT_READY.load(Ordering::Relaxed) { + CapabilityState::Ready + } else { + CapabilityState::SetupRequired + }, + window_capture: if ScreenCaptureAccess.preflight() { + CapabilityState::Ready + } else { + CapabilityState::PermissionRequired + }, + application_text: if unsafe { AXIsProcessTrusted() } { + CapabilityState::Ready + } else { + CapabilityState::PermissionRequired + }, + target: CaptureTarget::ActiveWindow, + } + } + + fn start_global_shortcut( + &self, + _activation_dir: &std::path::Path, + ) -> mpsc::UnboundedReceiver<()> { + start_global_shortcut() + } + + async fn capture_active_window(&self) -> Result { + capture_frontmost_window() + } + + fn request_capture_access(&self) { + request_screen_recording_permission(); + } + + fn request_semantic_access(&self) { + request_accessibility_permission(); + } + + fn capture_settings_url(&self) -> Option<&'static str> { + Some(SCREEN_RECORDING_SETTINGS_URL) + } + + fn semantic_settings_url(&self) -> Option<&'static str> { + Some(ACCESSIBILITY_SETTINGS_URL) + } +} + +fn request_accessibility_permission() { + autoreleasepool(|| unsafe { + let prompt_value = CFBoolean::true_value(); + let prompt: *mut Object = msg_send![class!(NSDictionary), + dictionaryWithObject: prompt_value.as_CFTypeRef() as *mut Object + forKey: kAXTrustedCheckOptionPrompt as *mut Object + ]; + let _ = AXIsProcessTrustedWithOptions(prompt.cast()); + }); +} + +fn request_screen_recording_permission() { + let _ = ScreenCaptureAccess.request(); +} + +struct HotKeyRegistration { + target: EventTargetRef, + hot_key: EventHotKeyRef, + shortcut: Option, +} + +thread_local! { + // Carbon registration and replacement always run on GPUI's main thread. + static HOT_KEY: RefCell> = const { RefCell::new(None) }; +} + +fn start_global_shortcut() -> mpsc::UnboundedReceiver<()> { + let (tx, rx) = mpsc::unbounded(); + let sender = Box::into_raw(Box::new(tx)).cast::(); + let event_type = EventTypeSpec { + event_class: EVENT_CLASS_KEYBOARD, + event_kind: EVENT_HOT_KEY_PRESSED, + }; + let target = unsafe { GetApplicationEventTarget() }; + let status = unsafe { + InstallEventHandler( + target, + Some(appshot_hot_key_handler), + 1, + &event_type, + sender, + ptr::null_mut(), + ) + }; + if status != 0 { + unsafe { drop(Box::from_raw(sender.cast::>())) }; + SHORTCUT_READY.store(false, Ordering::Relaxed); + tracing::warn!(status, "Appshot global shortcut handler unavailable"); + return rx; + } + HOT_KEY.with(|registration| { + *registration.borrow_mut() = Some(HotKeyRegistration { + target, + hot_key: ptr::null_mut(), + shortcut: None, + }) + }); + refresh_global_shortcut(super::shortcut::current()); + rx +} + +pub(super) fn refresh_global_shortcut(shortcut: Option) { + HOT_KEY.with(|registration| { + let mut registration = registration.borrow_mut(); + let Some(registration) = registration.as_mut() else { + return; + }; + if registration.shortcut == shortcut { + return; + } + if !registration.hot_key.is_null() { + unsafe { + UnregisterEventHotKey(registration.hot_key); + } + registration.hot_key = ptr::null_mut(); + } + registration.shortcut = shortcut.clone(); + let Some(shortcut) = shortcut else { + return; + }; + let Some(key_code) = shortcut_keycode(&shortcut.key) else { + SHORTCUT_READY.store(false, Ordering::Relaxed); + return; + }; + let modifiers = (if shortcut.control { CONTROL_KEY } else { 0 }) + | (if shortcut.alt { OPTION_KEY } else { 0 }) + | (if shortcut.shift { SHIFT_KEY } else { 0 }) + | (if shortcut.platform { COMMAND_KEY } else { 0 }); + let status = unsafe { + RegisterEventHotKey( + key_code, + modifiers, + EventHotKeyId { + signature: APPSHOT_HOT_KEY_SIGNATURE, + id: 1, + }, + registration.target, + 0, + &mut registration.hot_key, + ) + }; + SHORTCUT_READY.store(status == 0, Ordering::Relaxed); + if status != 0 { + tracing::warn!(status, "Appshot global shortcut unavailable"); + } + }); +} + +fn shortcut_keycode(key: &str) -> Option { + let named = match key { + "space" => 49, + "tab" => 48, + "enter" => 36, + "backspace" => 51, + "delete" => 117, + "insert" => 114, + "up" => 126, + "down" => 125, + "left" => 123, + "right" => 124, + "home" => 115, + "end" => 119, + "pageup" => 116, + "pagedown" => 121, + _ => { + if let Some(index) = key.strip_prefix('f').and_then(|n| n.parse::().ok()) { + return index.checked_sub(1).and_then(|index| { + [ + 122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, 105, 107, 113, 106, + 64, 79, 80, 90, + ] + .get(index) + .copied() + }); + } + return layout_keycode(key); + } + }; + Some(named) +} + +fn layout_keycode(key: &str) -> Option { + // Resolve the printed key against the active ASCII-capable layout. A US + // positional table would capture Q when an AZERTY user records A. + unsafe { + let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource(); + if source.is_null() { + return None; + } + let data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); + let result = if data.is_null() { + None + } else { + let layout = core_foundation::data::CFDataGetBytePtr(data.cast()).cast(); + let mut found = None; + 'levels: for modifiers in [0, 2] { + for code in 0..128_u16 { + let mut output = [0_u16; 4]; + let mut length = 0; + let mut dead = 0; + if UCKeyTranslate( + layout, + code, + 0, + modifiers, + u32::from(LMGetKbdType()), + 1, + &mut dead, + output.len(), + &mut length, + output.as_mut_ptr(), + ) == 0 + && length <= output.len() + && String::from_utf16_lossy(&output[..length]).to_lowercase() == key + { + found = Some(u32::from(code)); + break 'levels; + } + } + } + found + }; + CFRelease(source); + result + } +} + +unsafe extern "C" fn appshot_hot_key_handler( + _call: EventHandlerCallRef, + _event: EventRef, + user_data: *mut c_void, +) -> i32 { + if !user_data.is_null() { + let sender = unsafe { &*user_data.cast::>() }; + let _ = sender.unbounded_send(()); + } + 0 +} + +fn capture_frontmost_window() -> Result { + autoreleasepool(|| { + let started = Instant::now(); + let app = frontmost_application()?; + if app.pid == std::process::id() as i32 { + return Err(CaptureError::SelfCapture); + } + if !ScreenCaptureAccess.preflight() { + request_screen_recording_permission(); + return Err(CaptureError::PermissionRequired); + } + // Resolve the actual front-to-back window once, before asynchronous + // ScreenCaptureKit work can allow focus to change. Every capture path + // remains bound to this exact CGWindowID. + let preserved_window = frontmost_window(app.pid)?; + // Retain the AX window before asynchronous capture. Never resolve focus + // again afterward: another document may have become active by then. + let accessibility_window = preserved_accessibility_window(app.pid, preserved_window.id); + let (window, png) = match capture_with_screen_capture_kit(app.pid, preserved_window.id) { + Ok(Some(capture)) => capture, + Ok(None) => { + let png = capture_png(preserved_window.id)?; + (preserved_window, png) + } + Err(error) => { + // ScreenCaptureKit is the reliable path for fullscreen Spaces, + // but keep macOS 12/13 and transient framework failures useful. + tracing::warn!(%error, "ScreenCaptureKit Appshot failed; using CoreGraphics fallback"); + let png = capture_png(preserved_window.id)?; + (preserved_window, png) + } + }; + let pixels_ready = Instant::now(); + let (screenshot, screenshot_dimensions) = super::stage_appshot_png(&app.name, png)?; + super::capture_ready(); + tracing::debug!( + capture_ms = pixels_ready.duration_since(started).as_millis(), + staging_ms = pixels_ready.elapsed().as_millis(), + total_ms = started.elapsed().as_millis(), + "Appshot capture feedback requested" + ); + let accessibility = accessibility_window + .as_ref() + .map(accessibility_snapshot) + .unwrap_or_else(AccessibilitySnapshot::unavailable); + Ok(CapturedAppshot { + id: uuid::Uuid::new_v4().to_string(), + app_name: app.name, + bundle_identifier: app.bundle_identifier, + window_title: window.title, + accessibility, + screenshot, + screenshot_dimensions: Some(screenshot_dimensions), + app_icon: app.icon_png.map(|bytes| { + std::sync::Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes)) + }), + captured_at: Utc::now(), + }) + }) +} + +fn frontmost_application() -> Result { + unsafe { + let workspace: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace]; + let app: *mut Object = msg_send![workspace, frontmostApplication]; + if app.is_null() { + return Err(CaptureError::NoEligibleWindow); + } + let pid: i32 = msg_send![app, processIdentifier]; + let name_obj: *mut Object = msg_send![app, localizedName]; + let bundle_obj: *mut Object = msg_send![app, bundleIdentifier]; + let icon: *mut Object = msg_send![app, icon]; + let name = nsstring(name_obj).unwrap_or_else(|| "Application".into()); + Ok(FrontmostApplication { + pid, + name, + bundle_identifier: nsstring(bundle_obj), + icon_png: nsimage_png(icon), + }) + } +} + +pub(super) fn icon_for_bundle(bundle: &str) -> Option> { + autoreleasepool(|| unsafe { + let workspace: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace]; + let bundle = CFString::new(bundle); + let url: *mut Object = msg_send![workspace, URLForApplicationWithBundleIdentifier: bundle.as_concrete_TypeRef()]; + if url.is_null() { + return None; + } + let path: *mut Object = msg_send![url, path]; + let icon: *mut Object = msg_send![workspace, iconForFile: path]; + nsimage_png(icon) + }) +} + +unsafe fn nsimage_png(image: *mut Object) -> Option> { + if image.is_null() { + return None; + } + let tiff: *mut Object = unsafe { msg_send![image, TIFFRepresentation] }; + if tiff.is_null() { + return None; + } + let rep: *mut Object = unsafe { msg_send![class!(NSBitmapImageRep), imageRepWithData: tiff] }; + if rep.is_null() { + return None; + } + let properties: *mut Object = unsafe { msg_send![class!(NSDictionary), dictionary] }; + let data: *mut Object = + unsafe { msg_send![rep, representationUsingType: 4usize properties: properties] }; + unsafe { nsdata_bytes(data) } +} + +unsafe fn nsdata_bytes(data: *mut Object) -> Option> { + if data.is_null() { + return None; + } + let len: usize = unsafe { msg_send![data, length] }; + let bytes: *const u8 = unsafe { msg_send![data, bytes] }; + if bytes.is_null() || len == 0 { + return None; + } + Some(unsafe { std::slice::from_raw_parts(bytes, len) }.to_vec()) +} + +fn frontmost_window(pid: i32) -> Result { + let list = copy_window_info( + kCGWindowListOptionAll | kCGWindowListExcludeDesktopElements, + kCGNullWindowID, + ) + .ok_or(CaptureError::NoEligibleWindow)?; + let focused_bounds = focused_window_bounds(pid); + // CGWindowList is front-to-back. The first eligible layer-zero window is + // the frontmost visible one. Chromium and GPUI apps also own hidden, + // layer-zero helper windows; those are not capture targets. Retain the + // all-Spaces enumeration for ScreenCaptureKit, but exclude offscreen and + // transparent windows before binding the capture to a stable window ID. + // Choosing by area could select an unrelated background document. + for item in list.iter() { + let cf = unsafe { CFType::wrap_under_get_rule(*item as CFTypeRef) }; + let Some(dict) = cf.downcast::() else { + continue; + }; + let owner = dictionary_number(&dict, unsafe { kCGWindowOwnerPID }).and_then(|n| n.to_i32()); + let layer = dictionary_number(&dict, unsafe { kCGWindowLayer }).and_then(|n| n.to_i32()); + if owner != Some(pid) || layer != Some(0) { + continue; + } + let id = dictionary_number(&dict, unsafe { kCGWindowNumber }) + .and_then(|n| n.to_i64()) + .and_then(|n| u32::try_from(n).ok()) + .ok_or(CaptureError::NoEligibleWindow)?; + let bounds = dictionary_rect(&dict, unsafe { kCGWindowBounds }); + let onscreen = dictionary_value(&dict, unsafe { kCGWindowIsOnscreen }) + .and_then(|value| value.downcast::()) + .is_some_and(|value| value == CFBoolean::true_value()); + let alpha = dictionary_number(&dict, unsafe { kCGWindowAlpha }) + .and_then(|value| value.to_f64()) + .unwrap_or(0.0); + if !visible_capture_window(onscreen, alpha, bounds) { + continue; + } + let Some(bounds) = bounds else { continue }; + if focused_bounds.is_some_and(|focused| !same_window_bounds(bounds, focused)) { + continue; + } + tracing::debug!( + window_id = id, + width = bounds.size.width, + height = bounds.size.height, + matched_focus = focused_bounds.is_some(), + "Appshot capture target selected" + ); + let candidate = FrontmostWindow { + id, + title: dictionary_string(&dict, unsafe { kCGWindowName }), + }; + return Ok(candidate); + } + Err(CaptureError::NoEligibleWindow) +} + +fn visible_capture_window(onscreen: bool, alpha: f64, bounds: Option) -> bool { + onscreen + && alpha.is_finite() + && alpha > 0.0 + && bounds.is_some_and(|bounds| { + bounds.size.width.is_finite() + && bounds.size.height.is_finite() + && bounds.size.width >= 32.0 + && bounds.size.height >= 32.0 + }) +} + +/// Match the actual focused document instead of a Chromium helper that can be +/// layer zero, on screen, and fully opaque while still containing no UI. +/// Both AX and WindowServer rectangles use global top-left coordinates. +fn focused_window_bounds(pid: i32) -> Option { + if !unsafe { AXIsProcessTrusted() } { + return None; + } + unsafe { + let app = AXUIElementCreateApplication(pid); + if app.is_null() { + return None; + } + let deadline = Instant::now() + Duration::from_millis(250); + let focused = copy_ax_value(app, "AXFocusedWindow", deadline); + CFRelease(app.cast()); + let focused = focused?; + let element = focused.as_CFTypeRef() as AXUIElementRef; + let position = copy_ax_value(element, "AXPosition", deadline)?; + let size = copy_ax_value(element, "AXSize", deadline)?; + if position.type_of() != AXValueGetTypeID() || size.type_of() != AXValueGetTypeID() { + return None; + } + let mut origin = CGPoint::new(0.0, 0.0); + let mut dimensions = CGSize::new(0.0, 0.0); + if !AXValueGetValue( + position.as_CFTypeRef(), + 1, + (&mut origin as *mut CGPoint).cast(), + ) || !AXValueGetValue( + size.as_CFTypeRef(), + 2, + (&mut dimensions as *mut CGSize).cast(), + ) { + return None; + } + let bounds = CGRect::new(&origin, &dimensions); + visible_capture_window(true, 1.0, Some(bounds)).then_some(bounds) + } +} + +fn same_window_bounds(window: CGRect, focused: CGRect) -> bool { + // Allow WindowServer/AX rounding at fractional display scales. + [ + (window.origin.x, focused.origin.x), + (window.origin.y, focused.origin.y), + (window.size.width, focused.size.width), + (window.size.height, focused.size.height), + ] + .iter() + .all(|(a, b)| (a - b).abs() <= 2.0) +} + +/// Use LaunchServices to bring this app back after the explicit capture +/// shortcut. GPUI's deprecated activateIgnoringOtherApps call is only a +/// cooperative request on modern macOS and can leave the source app in front. +/// Run on the main thread, after capture has finished and the tile is staged. +pub(super) fn foreground_after_capture() { + unsafe { + let workspace: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace]; + let bundle: *mut Object = msg_send![class!(NSBundle), mainBundle]; + let url: *mut Object = msg_send![bundle, bundleURL]; + let extension: *mut Object = msg_send![url, pathExtension]; + if nsstring(extension).as_deref() != Some("app") { + // A bare `cargo run` has no launchable app bundle. GPUI's normal + // window activation above remains the fallback for that case. + return; + } + let configuration: *mut Object = + msg_send![class!(NSWorkspaceOpenConfiguration), configuration]; + let _: () = msg_send![configuration, setActivates: 1i8]; + let _: () = msg_send![configuration, setCreatesNewApplicationInstance: 0i8]; + let completion = ConcreteBlock::new(|_app: *mut Object, error: *mut Object| { + if !error.is_null() { + tracing::warn!("Could not bring Zeron forward after Appshot capture"); + } + }) + .copy(); + let _: () = msg_send![workspace, openApplicationAtURL: url configuration: configuration completionHandler: &*completion]; + } +} + +fn dictionary_value(dict: &CFDictionary, key: CFStringRef) -> Option { + let value = dict.find(key.cast::())?; + Some(unsafe { CFType::wrap_under_get_rule(*value as CFTypeRef) }) +} + +fn dictionary_number(dict: &CFDictionary, key: CFStringRef) -> Option { + dictionary_value(dict, key)?.downcast::() +} + +fn dictionary_string(dict: &CFDictionary, key: CFStringRef) -> Option { + dictionary_value(dict, key)? + .downcast::() + .map(|value| value.to_string()) + .filter(|value| !value.trim().is_empty()) +} + +fn dictionary_rect(dict: &CFDictionary, key: CFStringRef) -> Option { + dictionary_value(dict, key)? + .downcast::() + .and_then(|bounds| CGRect::from_dict_representation(&bounds)) +} + +/// ScreenCaptureKit is Space-independent and is Apple's supported replacement +/// for the deprecated CGWindowListCreateImage path. It is essential for +/// fullscreen windows, which live in their own Space. +fn capture_with_screen_capture_kit( + pid: i32, + preserved_window_id: CGWindowID, +) -> Result)>, String> { + if !load_screen_capture_kit() { + return Ok(None); + } + let (Some(shareable_class), Some(filter_class), Some(configuration_class), Some(manager_class)) = ( + Class::get("SCShareableContent"), + Class::get("SCContentFilter"), + Class::get("SCStreamConfiguration"), + Class::get("SCScreenshotManager"), + ) else { + return Ok(None); + }; + + let (content_tx, content_rx) = std::sync::mpsc::sync_channel(1); + let content_block = ConcreteBlock::new(move |content: *mut Object, error: *mut Object| { + let result = unsafe { + if !content.is_null() { + let retained: *mut Object = msg_send![content, retain]; + Ok(retained as usize) + } else { + Err(ns_error_message( + error, + "ScreenCaptureKit could not enumerate windows", + )) + } + }; + if let Err(unsent) = content_tx.send(result) + && let Ok(content) = unsent.0 + { + unsafe { CFRelease((content as *const c_void).cast()) }; + } + }) + .copy(); + unsafe { + let _: () = msg_send![ + shareable_class, + getShareableContentExcludingDesktopWindows: 1i8 + onScreenWindowsOnly: 0i8 + completionHandler: &*content_block + ]; + } + let content = content_rx + .recv_timeout(SCREEN_CAPTURE_KIT_TIMEOUT) + .map_err(|_| "Timed out while enumerating capturable windows".to_string())?? + as *mut Object; + + let selected = unsafe { select_screen_capture_kit_window(content, pid, preserved_window_id) }; + unsafe { + let _: () = msg_send![content, release]; + } + let Some((window, metadata, points_wide, points_high)) = selected else { + return Err("The frontmost application has no ScreenCaptureKit window".into()); + }; + + let (pixel_width, pixel_height) = capture_dimensions(points_wide, points_high); + let filter: *mut Object = unsafe { msg_send![filter_class, alloc] }; + let filter: *mut Object = + unsafe { msg_send![filter, initWithDesktopIndependentWindow: window] }; + let configuration: *mut Object = unsafe { msg_send![configuration_class, new] }; + unsafe { + let _: () = msg_send![configuration, setWidth: pixel_width]; + let _: () = msg_send![configuration, setHeight: pixel_height]; + let _: () = msg_send![configuration, setScalesToFit: 1i8]; + let _: () = msg_send![configuration, setPreservesAspectRatio: 1i8]; + let _: () = msg_send![configuration, setShowsCursor: 0i8]; + let _: () = msg_send![configuration, setIgnoreShadowsSingleWindow: 1i8]; + } + + let (image_tx, image_rx) = std::sync::mpsc::sync_channel(1); + let image_block = ConcreteBlock::new(move |image: *mut c_void, error: *mut Object| { + let result = unsafe { + if !image.is_null() { + // The completion owns the image only for this call. A CGImage + // is a CF object, so retain it before crossing the channel. + core_foundation::base::CFRetain(image.cast()) as usize + } else { + let _ = image_tx.send(Err(ns_error_message( + error, + "ScreenCaptureKit returned no screenshot", + ))); + return; + } + }; + if image_tx.send(Ok(result)).is_err() { + unsafe { CFRelease((result as *const c_void).cast()) }; + } + }) + .copy(); + unsafe { + let _: () = msg_send![ + manager_class, + captureImageWithFilter: filter + configuration: configuration + completionHandler: &*image_block + ]; + } + let image_result = image_rx + .recv_timeout(SCREEN_CAPTURE_KIT_TIMEOUT) + .map_err(|_| "Timed out while capturing the frontmost window".to_string()); + unsafe { + let _: () = msg_send![configuration, release]; + let _: () = msg_send![filter, release]; + let _: () = msg_send![window, release]; + } + let image = image_result?? as *mut c_void; + let png = encode_png(image); + unsafe { + CFRelease(image.cast()); + } + png.map(|png| Some((metadata, png))) +} + +fn load_screen_capture_kit() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| unsafe { + // Load dynamically so Zeron's macOS 12.0 minimum remains valid. The + // screenshot manager arrived in macOS 14; older systems use fallback. + let path = b"/System/Library/Frameworks/ScreenCaptureKit.framework/ScreenCaptureKit\0"; + !dlopen(path.as_ptr().cast(), 1).is_null() // RTLD_LAZY + }) +} + +/// Returns a retained SCWindow plus metadata and its size in screen points. +unsafe fn select_screen_capture_kit_window( + content: *mut Object, + pid: i32, + preserved_window_id: CGWindowID, +) -> Option<(*mut Object, FrontmostWindow, f64, f64)> { + let windows: *mut Object = unsafe { msg_send![content, windows] }; + let count: usize = unsafe { msg_send![windows, count] }; + let mut best: Option<(u8, f64, *mut Object, FrontmostWindow, f64, f64)> = None; + for index in 0..count { + let window: *mut Object = unsafe { msg_send![windows, objectAtIndex: index] }; + let owner: *mut Object = unsafe { msg_send![window, owningApplication] }; + if owner.is_null() { + continue; + } + let owner_pid: i32 = unsafe { msg_send![owner, processID] }; + let layer: isize = unsafe { msg_send![window, windowLayer] }; + if owner_pid != pid || layer != 0 { + continue; + } + let id: CGWindowID = unsafe { msg_send![window, windowID] }; + if id != preserved_window_id { + continue; + } + let bounds = window_bounds(id); + let (width, height, area) = bounds + .map(|rect| { + let width = rect.size.width.max(1.0); + let height = rect.size.height.max(1.0); + (width, height, width * height) + }) + .unwrap_or((1_920.0, 1_080.0, 1.0)); + let active: i8 = unsafe { msg_send![window, isActive] }; + let on_screen: i8 = unsafe { msg_send![window, isOnScreen] }; + let rank = (active != 0) as u8 * 2 + (on_screen != 0) as u8; + let title_obj: *mut Object = unsafe { msg_send![window, title] }; + let metadata = FrontmostWindow { + id, + title: unsafe { nsstring(title_obj) }, + }; + if best.as_ref().is_none_or(|(best_rank, best_area, ..)| { + rank > *best_rank || (rank == *best_rank && area > *best_area) + }) { + best = Some((rank, area, window, metadata, width, height)); + } + } + best.map(|(_, _, window, metadata, width, height)| { + let retained: *mut Object = unsafe { msg_send![window, retain] }; + (retained, metadata, width, height) + }) +} + +fn window_bounds(window_id: CGWindowID) -> Option { + let list = copy_window_info(kCGWindowListOptionIncludingWindow, window_id)?; + let item = *list.iter().next()?; + let cf = unsafe { CFType::wrap_under_get_rule(item as CFTypeRef) }; + let dict = cf.downcast::()?; + dictionary_rect(&dict, unsafe { kCGWindowBounds }) +} + +fn capture_dimensions(width: f64, height: f64) -> (usize, usize) { + let longest = width.max(height).max(1.0); + let scale = 2.0f64.min(MAX_CAPTURE_DIMENSION / longest); + ( + (width * scale).round().max(1.0) as usize, + (height * scale).round().max(1.0) as usize, + ) +} + +unsafe fn ns_error_message(error: *mut Object, fallback: &str) -> String { + if error.is_null() { + return fallback.into(); + } + let description: *mut Object = unsafe { msg_send![error, localizedDescription] }; + unsafe { nsstring(description) }.unwrap_or_else(|| fallback.into()) +} + +fn fallback_capture_dimensions(bounds: CGRect) -> Result<(u32, u32), CaptureError> { + if !bounds.origin.x.is_finite() + || !bounds.origin.y.is_finite() + || !bounds.size.width.is_finite() + || !bounds.size.height.is_finite() + || bounds.size.width <= 0.0 + || bounds.size.height <= 0.0 + || bounds.size.width > u32::MAX as f64 + || bounds.size.height > u32::MAX as f64 + { + return Err(CaptureError::NoEligibleWindow); + } + // Nominal resolution requests one pixel per point. Reject oversized + // rectangles before asking WindowServer to allocate the capture. + let dimensions = ( + bounds.size.width.ceil() as u32, + bounds.size.height.ceil() as u32, + ); + super::validate_capture_dimensions(dimensions.0, dimensions.1)?; + Ok(dimensions) +} + +fn capture_png(window_id: CGWindowID) -> Result, CaptureError> { + let bounds = window_bounds(window_id).ok_or(CaptureError::NoEligibleWindow)?; + fallback_capture_dimensions(bounds)?; + let image = create_image( + bounds, + kCGWindowListOptionIncludingWindow, + window_id, + kCGWindowImageNominalResolution | kCGWindowImageBoundsIgnoreFraming, + ) + .ok_or_else(|| { + CaptureError::CaptureFailed("The application window could not be captured.".into()) + })?; + use foreign_types::ForeignType; + encode_png(image.as_ptr().cast()).map_err(CaptureError::CaptureFailed) +} + +fn encode_png(image: *mut c_void) -> Result, String> { + unsafe extern "C" { + fn CGImageGetWidth(image: *mut c_void) -> usize; + fn CGImageGetHeight(image: *mut c_void) -> usize; + } + if image.is_null() { + return Err("The captured window was empty.".into()); + } + let width = u32::try_from(unsafe { CGImageGetWidth(image) }) + .map_err(|_| "Screenshot width exceeds the capture budget")?; + let height = u32::try_from(unsafe { CGImageGetHeight(image) }) + .map_err(|_| "Screenshot height exceeds the capture budget")?; + super::validate_capture_dimensions(width, height).map_err(|error| error.to_string())?; + unsafe { + let rep: *mut Object = msg_send![class!(NSBitmapImageRep), alloc]; + let rep: *mut Object = msg_send![rep, initWithCGImage: image]; + if rep.is_null() { + return Err("The screenshot could not be encoded.".into()); + } + let properties: *mut Object = msg_send![class!(NSDictionary), dictionary]; + // NSBitmapImageFileTypePNG = 4. + let data: *mut Object = + msg_send![rep, representationUsingType: 4usize properties: properties]; + let _: () = msg_send![rep, release]; + if data.is_null() { + return Err("The screenshot could not be encoded.".into()); + } + let len: usize = msg_send![data, length]; + if len as u64 > crate::attachments::MAX_ATTACHMENT_BYTES { + return Err("The captured window is larger than Zeron's 24 MB image limit.".into()); + } + let bytes: *const u8 = msg_send![data, bytes]; + if bytes.is_null() || len == 0 { + return Err("The captured window was empty.".into()); + } + Ok(std::slice::from_raw_parts(bytes, len).to_vec()) + } +} + +fn preserved_accessibility_window(pid: i32, window_id: CGWindowID) -> Option { + if !unsafe { AXIsProcessTrusted() } { + return None; + } + unsafe { + let app = AXUIElementCreateApplication(pid); + if app.is_null() { + return None; + } + let deadline = Instant::now() + Duration::from_millis(250); + let focused = copy_ax_value(app, "AXFocusedWindow", deadline); + CFRelease(app.cast()); + let focused = focused?; + // Resolve the CG identity while this AX element is still focused. If + // focus moved during acquisition, omit semantics instead of guessing. + if frontmost_window(pid).ok()?.id != window_id { + return None; + } + Some(focused) + } +} + +fn accessibility_snapshot(window: &CFType) -> AccessibilitySnapshot { + let mut state = AxTraversal::new(); + unsafe { state.visit(window.as_CFTypeRef() as AXUIElementRef, 0) }; + AccessibilitySnapshot { + format_version: 1, + content: state.output, + truncated: state.truncated, + } +} + +struct AxTraversal { + output: String, + nodes: usize, + truncated: bool, + deadline: Instant, +} + +impl AxTraversal { + fn new() -> Self { + Self { + output: String::new(), + nodes: 0, + truncated: false, + deadline: Instant::now() + AX_DEADLINE, + } + } + + unsafe fn visit(&mut self, element: AXUIElementRef, depth: usize) { + if depth > MAX_AX_DEPTH + || self.nodes >= MAX_AX_NODES + || self.output.len() >= MAX_AX_BYTES + || Instant::now() >= self.deadline + { + self.truncated = true; + return; + } + self.nodes += 1; + let role = unsafe { ax_string(element, "AXRole", self.deadline) } + .unwrap_or_else(|| "AXElement".into()); + let subrole = unsafe { ax_string(element, "AXSubrole", self.deadline) }; + let secure = is_secure_ax_element(&role, subrole.as_deref()); + let title = unsafe { ax_string(element, "AXTitle", self.deadline) }; + let description = unsafe { ax_string(element, "AXDescription", self.deadline) }; + let value = if secure { + None + } else { + unsafe { ax_scalar_string(element, "AXValue", self.deadline) } + }; + if Instant::now() >= self.deadline { + self.truncated = true; + return; + } + let mut fields = Vec::new(); + if let Some(title) = title.filter(|s| !s.trim().is_empty()) { + fields.push(format!("title={}", compact(&title))); + } + if let Some(description) = description.filter(|s| !s.trim().is_empty()) { + fields.push(format!("description={}", compact(&description))); + } + if let Some(value) = value.filter(|s| !s.trim().is_empty()) { + fields.push(format!("value={}", compact(&value))); + } + let line = if fields.is_empty() { + format!("{}{}\n", " ".repeat(depth), role) + } else { + format!("{}{} {}\n", " ".repeat(depth), role, fields.join(" ")) + }; + let remaining = MAX_AX_BYTES.saturating_sub(self.output.len()); + if line.len() > remaining { + let end = line + .char_indices() + .take_while(|(index, _)| *index <= remaining) + .map(|(index, _)| index) + .last() + .unwrap_or(0); + self.output.push_str(&line[..end]); + self.truncated = true; + return; + } + self.output.push_str(&line); + if secure { + return; + } + let Some(children_value) = (unsafe { copy_ax_value(element, "AXChildren", self.deadline) }) + else { + self.truncated |= Instant::now() >= self.deadline; + return; + }; + if let Some(children) = children_value.downcast::() { + for child in children.iter() { + unsafe { self.visit(*child as AXUIElementRef, depth + 1) }; + if self.truncated { + break; + } + } + } + } +} + +fn is_secure_ax_element(role: &str, subrole: Option<&str>) -> bool { + role == "AXSecureTextField" || subrole == Some("AXSecureTextField") +} + +unsafe fn copy_ax_value( + element: AXUIElementRef, + attribute: &str, + deadline: Instant, +) -> Option { + let remaining = deadline.checked_duration_since(Instant::now())?; + if remaining.is_zero() + || unsafe { AXUIElementSetMessagingTimeout(element, remaining.as_secs_f32().min(0.25)) } + != AX_SUCCESS + { + return None; + } + let attribute = CFString::new(attribute); + let mut value: CFTypeRef = ptr::null(); + if unsafe { + AXUIElementCopyAttributeValue(element, attribute.as_concrete_TypeRef(), &mut value) + } != AX_SUCCESS + || value.is_null() + { + return None; + } + Some(unsafe { CFType::wrap_under_create_rule(value) }) +} + +unsafe fn ax_string(element: AXUIElementRef, attribute: &str, deadline: Instant) -> Option { + unsafe { copy_ax_value(element, attribute, deadline) }? + .downcast::() + .map(|value| value.to_string()) +} + +unsafe fn ax_scalar_string( + element: AXUIElementRef, + attribute: &str, + deadline: Instant, +) -> Option { + let value = unsafe { copy_ax_value(element, attribute, deadline) }?; + if let Some(string) = value.downcast::() { + return Some(string.to_string()); + } + if let Some(number) = value.downcast::() { + return number + .to_i64() + .map(|number| number.to_string()) + .or_else(|| number.to_f64().map(|number| number.to_string())); + } + None +} + +fn compact(value: &str) -> String { + let compact = value.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= MAX_AX_VALUE_CHARS { + compact + } else { + let end = compact + .char_indices() + .nth(MAX_AX_VALUE_CHARS) + .map(|(index, _)| index) + .unwrap_or(compact.len()); + format!("{}…", &compact[..end]) + } +} + +unsafe fn nsstring(value: *mut Object) -> Option { + if value.is_null() { + return None; + } + let bytes: *const std::ffi::c_char = msg_send![value, UTF8String]; + (!bytes.is_null()).then(|| { + unsafe { CStr::from_ptr(bytes) } + .to_string_lossy() + .into_owned() + }) +} + +#[cfg(test)] +mod tests { + use super::{ + capture_dimensions, is_secure_ax_element, same_window_bounds, visible_capture_window, + }; + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + + #[test] + fn fallback_rejects_oversized_or_invalid_native_acquisition() { + let bounds = |w, h| CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(w, h)); + assert_eq!( + super::fallback_capture_dimensions(bounds(1440.2, 900.0)).unwrap(), + (1441, 900) + ); + for (w, h) in [ + (9000.0, 500.0), + (8192.0, 8192.0), + (f64::NAN, 500.0), + (100.0, 0.0), + ] { + assert!(super::fallback_capture_dimensions(bounds(w, h)).is_err()); + } + } + + #[test] + fn expired_ax_budget_never_calls_the_native_element() { + // A null element is safe here only because the elapsed budget must + // return before any native AX call. It guards the deadline boundary. + assert!( + unsafe { super::copy_ax_value(std::ptr::null(), "AXTitle", std::time::Instant::now()) } + .is_none() + ); + } + + #[test] + fn focused_document_wins_over_onscreen_helpers_and_other_documents() { + let focused = CGRect::new(&CGPoint::new(80.0, 40.0), &CGSize::new(1000.0, 700.0)); + let helper = CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(128.0, 128.0)); + let background = CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(1400.0, 900.0)); + let candidates = [helper, background, focused]; + assert_eq!( + candidates + .iter() + .position(|bounds| same_window_bounds(*bounds, focused)), + Some(2) + ); + assert!(!visible_capture_window( + true, + 1.0, + Some(CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(1.0, 1.0))) + )); + } + + #[test] + fn capture_skips_hidden_helpers_without_rejecting_small_visible_windows() { + let bounds = Some(CGRect::new( + &CGPoint::new(0.0, 0.0), + &CGSize::new(200.0, 120.0), + )); + assert!(!visible_capture_window(false, 1.0, bounds)); + assert!(!visible_capture_window(true, 0.0, bounds)); + assert!(!visible_capture_window(true, f64::NAN, bounds)); + assert!(!visible_capture_window(true, 1.0, None)); + assert!(visible_capture_window(true, 1.0, bounds)); + } + + #[test] + fn capture_dimensions_use_retina_scale_for_ordinary_windows() { + assert_eq!(capture_dimensions(1_440.0, 900.0), (2_880, 1_800)); + } + + #[test] + fn capture_dimensions_cap_large_fullscreen_windows() { + assert_eq!(capture_dimensions(5_120.0, 2_880.0), (4_096, 2_304)); + } + + #[test] + fn secure_text_subroles_are_redacted() { + assert!(is_secure_ax_element( + "AXTextField", + Some("AXSecureTextField") + )); + assert!(is_secure_ax_element("AXSecureTextField", None)); + assert!(!is_secure_ax_element("AXTextField", Some("AXSearchField"))); + } +} diff --git a/crates/ui/src/appshots/shortcut.rs b/crates/ui/src/appshots/shortcut.rs new file mode 100644 index 000000000..eb62922e7 --- /dev/null +++ b/crates/ui/src/appshots/shortcut.rs @@ -0,0 +1,199 @@ +//! Shared user preference and live registration updates for desktop hotkeys. +use std::sync::{Mutex, OnceLock}; + +use futures::channel::mpsc; +use gpui::Keystroke; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Shortcut { + pub key: String, + pub control: bool, + pub alt: bool, + pub shift: bool, + pub platform: bool, +} + +impl Shortcut { + pub fn parse(combo: &str) -> Result { + let stroke = Keystroke::parse(&crate::settings::platform_combo(combo)).map_err(|_| ())?; + let modifiers = stroke.modifiers; + let key = stroke.key; + let supported = (key.len() == 1 && key.as_bytes()[0].is_ascii_alphanumeric()) + || matches!( + key.as_str(), + "space" + | "tab" + | "enter" + | "backspace" + | "delete" + | "insert" + | "up" + | "down" + | "left" + | "right" + | "home" + | "end" + | "pageup" + | "pagedown" + ) + || key + .strip_prefix('f') + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=20).contains(&n)); + if !supported + || modifiers.function + || !(modifiers.control || modifiers.alt || modifiers.platform) + { + return Err(()); + } + Ok(Self { + key, + control: modifiers.control, + alt: modifiers.alt, + shift: modifiers.shift, + platform: modifiers.platform, + }) + } + + #[cfg(any(target_os = "linux", test))] + pub fn portal_trigger(&self) -> String { + let mut parts = Vec::new(); + if self.control { + parts.push("CTRL".to_owned()); + } + if self.alt { + parts.push("ALT".to_owned()); + } + if self.shift { + parts.push("SHIFT".to_owned()); + } + if self.platform { + parts.push("LOGO".to_owned()); + } + parts.push(match self.key.as_str() { + "enter" => "Return".into(), + "backspace" => "BackSpace".into(), + "pageup" => "Page_Up".into(), + "pagedown" => "Page_Down".into(), + "space" => "space".into(), + key if key.len() == 1 => key.into(), + key => { + let mut chars = key.chars(); + chars.next().unwrap().to_uppercase().collect::() + chars.as_str() + } + }); + parts.join("+") + } +} + +struct Preferences { + shortcut: Shortcut, + recording: bool, + subscribers: Vec>>, +} + +fn preferences() -> &'static Mutex { + static PREFERENCES: OnceLock> = OnceLock::new(); + PREFERENCES.get_or_init(|| { + Mutex::new(Preferences { + shortcut: Shortcut::parse(crate::settings::ShortcutId::CaptureAppshot.default_combo()) + .unwrap(), + recording: false, + subscribers: Vec::new(), + }) + }) +} + +impl Preferences { + fn active(&self) -> Option { + (super::enabled() && !self.recording).then(|| self.shortcut.clone()) + } +} + +pub(crate) fn current() -> Option { + preferences().lock().unwrap().active() +} + +#[cfg(target_os = "linux")] +pub(crate) fn subscribe() -> mpsc::UnboundedReceiver> { + let (tx, rx) = mpsc::unbounded(); + let mut preferences = preferences().lock().unwrap(); + let _ = tx.unbounded_send(preferences.active()); + preferences.subscribers.push(tx); + rx +} + +fn update(mutate: impl FnOnce(&mut Preferences)) { + let active = { + let mut preferences = preferences().lock().unwrap(); + mutate(&mut preferences); + let active = preferences.active(); + preferences + .subscribers + .retain(|tx| tx.unbounded_send(active.clone()).is_ok()); + active + }; + #[cfg(target_os = "macos")] + super::macos::refresh_global_shortcut(active); + #[cfg(not(target_os = "macos"))] + let _ = active; +} + +pub(super) fn enabled_changed() { + update(|_| {}); +} + +pub fn set_shortcut(combo: &str) { + if !super::is_desktop() { + return; + } + let shortcut = Shortcut::parse(combo).unwrap_or_else(|_| { + Shortcut::parse(crate::settings::ShortcutId::CaptureAppshot.default_combo()).unwrap() + }); + if preferences().lock().unwrap().shortcut == shortcut { + return; + } + update(|preferences| preferences.shortcut = shortcut); +} + +pub fn set_recording(recording: bool) { + if !super::is_desktop() || preferences().lock().unwrap().recording == recording { + return; + } + update(|preferences| preferences.recording = recording); +} + +pub fn validate_shortcut(combo: &str) -> Result<(), ()> { + Shortcut::parse(combo).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn global_shortcuts_require_a_supported_modified_key() { + for combo in ["a", "shift-a", "fn-a", "ctrl-mystery", "ctrl-f25"] { + assert!(Shortcut::parse(combo).is_err(), "{combo}"); + } + for combo in ["ctrl-alt-space", "mod-shift-k", "alt-f12", "ctrl-pageup"] { + assert!(Shortcut::parse(combo).is_ok(), "{combo}"); + } + } + #[test] + fn portal_uses_xkb_key_names_and_selected_modifiers() { + assert_eq!( + Shortcut::parse("ctrl-alt-space").unwrap().portal_trigger(), + "CTRL+ALT+space" + ); + assert_eq!( + Shortcut::parse("ctrl-shift-pageup") + .unwrap() + .portal_trigger(), + "CTRL+SHIFT+Page_Up" + ); + } +} + +pub(crate) fn capture_allowed() -> bool { + current().is_some() +} diff --git a/crates/ui/src/attachments.rs b/crates/ui/src/attachments.rs index e5d664631..a9fafb720 100644 --- a/crates/ui/src/attachments.rs +++ b/crates/ui/src/attachments.rs @@ -70,6 +70,7 @@ pub fn with_attachments(text: &str, paths: &[String]) -> String { /// An attachment ref parsed back out of a user message's text. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UserImageAttachment { + pub appshot: Option, pub id: String, pub path: String, pub name: String, @@ -129,7 +130,8 @@ pub fn parse_user_message_images(content: &str) -> ParsedUserMessage { attachments: Vec::new(), }; }; - let body = content[..body_end].trim_end(); + let body = crate::appshots::strip_context_for_display(content[..body_end].trim_end()); + let presentations = crate::appshots::presentations(content); let attachments: Vec = content[refs_start..] .lines() .filter_map(|line| { @@ -138,6 +140,7 @@ pub fn parse_user_message_images(content: &str) -> ParsedUserMessage { }) .enumerate() .map(|(index, path)| UserImageAttachment { + appshot: presentations.get(&path).cloned(), id: format!("{index}:{path}"), name: name_from_path(&path), path, @@ -260,6 +263,16 @@ pub fn stage_clipboard_image(image: Image) -> StagedAttachment { } } +/// Stage native macOS capture bytes without a temporary file. The capture +/// service already encoded PNG and enforces the shared size limit. +pub fn stage_png_bytes(name: String, bytes: Vec) -> StagedAttachment { + StagedAttachment { + id: uuid::Uuid::new_v4().to_string(), + name: ensure_extension(&name, ImageFormat::Png), + image: Arc::new(Image::from_bytes(ImageFormat::Png, bytes)), + } +} + // --------------------------------------------------------------------------- // Upload (state.ts uploadAttachment) + read-back (state.ts readAttachmentImage) // --------------------------------------------------------------------------- @@ -504,6 +517,26 @@ pub async fn read_attachment_image( }) } +/// Decode with a fixed allocation budget and retain only a small queue image. +/// Full resolution is fetched on explicit preview, never retained by queue rows. +pub(crate) fn queue_thumbnail_image(source: &Image) -> Option> { + let mut reader = image::ImageReader::new(std::io::Cursor::new(source.bytes.as_slice())) + .with_guessed_format() + .ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + let thumb = reader.decode().ok()?.thumbnail(160, 112); + let mut bytes = std::io::Cursor::new(Vec::new()); + thumb.write_to(&mut bytes, image::ImageFormat::Png).ok()?; + Some(Arc::new(Image::from_bytes( + ImageFormat::Png, + bytes.into_inner(), + ))) +} + // --------------------------------------------------------------------------- // Transcript image cache (transcript-attachment-cache.ts) // --------------------------------------------------------------------------- @@ -877,6 +910,48 @@ pub(crate) fn lightbox_with_size( mod tests { use super::*; + #[test] + fn appshot_cards_follow_their_exact_image_reference() { + let shot = crate::appshots::tests::shot(); + let paths = HashMap::from([(shot.screenshot.id.clone(), "/remote/a & b.png".to_string())]); + let body = crate::appshots::with_appshots("Look here", &[shot], &paths); + let message = with_attachments( + &body, + &["/remote/ordinary.png".into(), "/remote/a & b.png".into()], + ); + let parsed = parse_user_message_images(&message); + assert_eq!(parsed.text, "Look here"); + assert!(parsed.attachments[0].appshot.is_none()); + let appshot = parsed.attachments[1].appshot.as_ref().unwrap(); + assert_eq!(appshot.app_name, "Safari & Notes"); + assert_eq!(appshot.title(), "A \"window\""); + } + + #[test] + fn duplicate_or_invalid_appshot_metadata_stays_an_ordinary_attachment() { + let body = format!( + "Question\n\n{}\nprivate textother text", + crate::appshots::CONTEXT_MARKER + ); + let parsed = parse_user_message_images(&with_attachments(&body, &["/a.png".into()])); + assert!(parsed.attachments[0].appshot.is_none()); + assert_eq!(parsed.text, "Question"); + } + + #[test] + fn queue_images_have_a_small_retained_pixel_budget() { + let source = image::DynamicImage::new_rgba8(2400, 1600); + let mut bytes = std::io::Cursor::new(Vec::new()); + source + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + let source = Image::from_bytes(ImageFormat::Png, bytes.into_inner()); + let thumbnail = queue_thumbnail_image(&source).unwrap(); + let (width, height) = crate::appshots::png_dimensions(&thumbnail.bytes).unwrap(); + assert!(width <= 160 && height <= 112); + assert!(thumbnail.bytes.len() < 160 * 112 * 4); + } + #[test] fn with_attachments_round_trips_through_parse() { let paths = vec!["/data/uploads/ab-cat.png".to_string(), "/x/dog.jpg".into()]; @@ -899,6 +974,19 @@ mod tests { assert_eq!(parsed.attachments.len(), 1); } + #[test] + fn appshot_context_is_hidden_but_image_remains() { + let body = format!( + "Fix the layout\n\n{}\nsecret AX text", + crate::appshots::CONTEXT_MARKER + ); + let content = with_attachments(&body, &["/a/appshot.png".to_string()]); + let parsed = parse_user_message_images(&content); + assert_eq!(parsed.text, "Fix the layout"); + assert_eq!(parsed.attachments.len(), 1); + assert_eq!(parsed.attachments[0].path, "/a/appshot.png"); + } + #[test] fn plain_text_passes_through_unchanged() { assert_eq!(with_attachments("hello", &[]), "hello"); diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 1ae4c8ed8..4b27db34d 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -31,6 +31,7 @@ use zeron_proto::{ }; use zeron_rpc::{RpcError, methods}; +use crate::appshots::{self, CapturedAppshot}; use crate::attachments::{self, StagedAttachment}; use crate::motion; use crate::pickers::Pickers; @@ -487,6 +488,56 @@ fn modified_submit_target(has_content: bool) -> ModifiedSubmitTarget { } } +pub const APPSHOT_TILE_MIN_WIDTH: f32 = 96.0; +pub const APPSHOT_IMAGE_INSET: f32 = 12.0; +pub const APPSHOT_PREVIEW_HEIGHT: f32 = 148.0; +pub const APPSHOT_IMAGE_MAX_WIDTH: f32 = 320.0; +pub const APPSHOT_IMAGE_MAX_HEIGHT: f32 = 132.0; +pub const APPSHOT_TILE_HEIGHT: f32 = 192.0; + +struct AppshotActionTooltip(SharedString); + +impl Render for AppshotActionTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx); + div() + .px(px(8.0)) + .py(px(6.0)) + .rounded(px(6.0)) + .border_1() + .border_color(theme.border_strong) + .bg(theme.surface_raised) + .shadow_md() + .text_size(px(11.0)) + .text_color(theme.text) + .child(self.0.clone()) + } +} + +/// Give ordinary captures a shared height while their width follows the +/// source window. Extreme panoramas and narrow composers cap width without +/// cropping. Explicit dimensions also bound the native image while decoding. +pub fn appshot_contained_size(dimensions: Option<(u32, u32)>, max_width: f32) -> (f32, f32) { + let max_width = if max_width.is_finite() { + max_width.clamp(1.0, APPSHOT_IMAGE_MAX_WIDTH) + } else { + APPSHOT_IMAGE_MAX_WIDTH + }; + let (width, height) = dimensions + .filter(|(width, height)| *width > 0 && *height > 0) + .unwrap_or((16, 10)); + let scale = (max_width / width as f32).min(APPSHOT_IMAGE_MAX_HEIGHT / height as f32); + (width as f32 * scale, height as f32 * scale) +} + +pub fn appshot_strip_height(count: usize) -> f32 { + if count == 0 { + 0.0 + } else { + STRIP_PAD_TOP + APPSHOT_TILE_HEIGHT + } +} + pub fn send_button_mode(run_live: bool, has_text: bool) -> SendButtonMode { match (run_live, has_text) { (false, _) => SendButtonMode::Send, @@ -3906,7 +3957,7 @@ pub struct Composer { pub(crate) state: Entity, pub(crate) input: Entity, /// Draft displaced while a queued message occupies the composer. - pub(crate) queue_edit_draft: Option<(String, Vec)>, + pub(crate) queue_edit_draft: Option<(String, Vec, Vec)>, /// Composer actions row: repo/branch/harness-model/traits (§1.7). /// Shared with the shell's new-session canvas, which renders the /// device/project target selectors ([`Pickers::render_target_selectors`]). @@ -3916,6 +3967,11 @@ pub struct Composer { /// Staged-but-unsent attachments per chat key (use-attachments.ts `stash`): /// navigating away and back restores them; memory-only, like the original. pub(crate) attachments: HashMap>, + /// Rich window captures keyed exactly like drafts and ordinary staged + /// attachments. Each owns one screenshot that joins the existing upload + /// path only at send time. + pub(crate) appshots: HashMap>, + appshot_entrances: HashMap, /// The staged attachment being viewed full-size (click a thumbnail). preview: Option, /// Focused while the lightbox is open so Escape reaches it; the input @@ -3975,6 +4031,8 @@ pub struct Composer { /// Live drag over the queue panel: which row, and where it would land. pub(crate) queue_drag: Option, pub(crate) queue_scroll: gpui::ScrollHandle, + pub(crate) queue_full_preview: Option>, + pub(crate) queue_previews: HashMap<(String, String), crate::queue::QueuePreview>, /// Rows awaiting a host-authoritative removal acknowledgement. They stay /// visible but inert until the host wins the race against queue delivery. pub(crate) queue_removing: HashSet, @@ -4062,6 +4120,8 @@ impl Composer { } pub fn new(state: Entity, cx: &mut Context) -> Self { + cx.on_release(|this, cx| this.release_queue_previews(cx)) + .detach(); let input = cx.new(|cx| { let mut input = ComposerInput::with_context("Do anything…", MESSAGE_COMPOSER_CONTEXT, cx); @@ -4129,6 +4189,8 @@ impl Composer { pickers, drafts: HashMap::new(), attachments: HashMap::new(), + appshots: HashMap::new(), + appshot_entrances: HashMap::new(), preview: None, preview_focus: cx.focus_handle(), preview_focus_pending: false, @@ -4166,6 +4228,8 @@ impl Composer { focus_pending: true, queue_drag: None, queue_scroll: gpui::ScrollHandle::new(), + queue_full_preview: None, + queue_previews: HashMap::new(), queue_removing: HashSet::new(), queue_shortcut_revealed: false, expanded_mode: false, @@ -4249,6 +4313,100 @@ impl Composer { .unwrap_or(&[]) } + pub(crate) fn queue_preview_limit(&self) -> usize { + if self.last_available_width.unwrap_or(COMPOSER_MAX_WIDTH) < 520.0 { + 1 + } else { + 2 + } + } + + pub(crate) fn show_queue_image( + &mut self, + preview: attachments::PreviewImage, + cx: &mut Context, + ) { + self.preview = Some(preview); + self.preview_focus_pending = true; + cx.notify(); + } + + pub(crate) fn staged_appshots(&self) -> &[CapturedAppshot] { + self.appshots + .get(&self.current_key) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub fn stage_appshot(&mut self, appshot: CapturedAppshot, cx: &mut Context) { + self.stage_appshot_for(self.current_key.clone(), appshot, cx); + } + + pub fn stage_appshot_for( + &mut self, + key: String, + appshot: CapturedAppshot, + cx: &mut Context, + ) -> bool { + let staged_bytes = self + .appshots + .get(&key) + .into_iter() + .flatten() + .map(|shot| shot.screenshot.bytes().len() as u64) + .sum::(); + let saved_bytes = self + .queue_edit_draft + .as_ref() + .filter(|_| key == self.current_key) + .map(|(_, _, shots)| { + shots + .iter() + .map(|shot| shot.screenshot.bytes().len() as u64) + .sum::() + }) + .unwrap_or_default(); + let staged_bytes = staged_bytes.saturating_add(saved_bytes); + let incoming = appshot.screenshot.bytes().len() as u64; + if incoming > attachments::MAX_ATTACHMENT_BYTES + || staged_bytes.saturating_add(incoming) > appshots::MAX_STAGED_APPSHOT_BYTES + { + self.failure = Some( + "Remove an Appshot before adding another (96 MB staged Appshot limit).".into(), + ); + self.failure_key = Some(key); + cx.notify(); + return false; + } + self.appshot_entrances + .retain(|_, start| start.elapsed().as_secs_f32() < motion::speed_scale()); + if !motion::reduced_motion(cx) { + self.appshot_entrances + .insert(appshot.id.clone(), Instant::now()); + } + // A capture completing while a queue edit is being saved belongs to + // the displaced draft; it must not be lost or change the in-flight edit. + if self.queue_edit_finishing && key == self.current_key { + if let Some((_, _, saved_appshots)) = &mut self.queue_edit_draft { + saved_appshots.push(appshot); + } else { + self.appshots.entry(key).or_default().push(appshot); + } + } else { + self.appshots.entry(key).or_default().push(appshot); + } + self.failure = None; + self.failure_key = None; + cx.notify(); + true + } + + pub fn show_appshot_error(&mut self, message: String, cx: &mut Context) { + self.failure = Some(message.into()); + self.failure_key = Some(self.current_key.clone()); + cx.notify(); + } + fn add_staged(&mut self, staged: Vec, cx: &mut Context) { if self.queue_edit_finishing { return; @@ -4320,10 +4478,44 @@ impl Composer { cx.notify(); } + fn restore_failed_appshots( + &mut self, + sent: &[CapturedAppshot], + failed_key: &str, + restore_key: &str, + ) { + if sent.is_empty() { + return; + } + let mut merged = sent.to_vec(); + for key in [failed_key, restore_key] { + for shot in self.appshots.remove(key).unwrap_or_default() { + if !merged.iter().any(|existing| existing.id == shot.id) { + merged.push(shot); + } + } + } + self.appshots.insert(restore_key.to_string(), merged); + } + + fn remove_appshot(&mut self, id: &str, cx: &mut Context) { + if self.queue_edit_finishing { + return; + } + if let Some(list) = self.appshots.get_mut(&self.current_key) { + list.retain(|appshot| appshot.id != id); + if list.is_empty() { + self.appshots.remove(&self.current_key); + } + } + cx.notify(); + } + /// Drop a deleted chat's per-chat composer state — staged attachments hold /// raw image bytes, and a deleted chat's stage could never be sent again. pub fn purge_chat(&mut self, chat_id: &str, cx: &mut Context) { self.attachments.remove(chat_id); + self.appshots.remove(chat_id); self.state.update(cx, |state, _| { state.purge_review_comments(chat_id); }); @@ -4458,6 +4650,235 @@ impl Composer { Some(strip) } + fn render_appshot_strip( + &self, + theme: &Theme, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let appshots = self.staged_appshots(); + if appshots.is_empty() { + return None; + } + let mut strip = div() + .id("composer-appshots-strip") + .flex() + .flex_row() + .gap(px(STRIP_GAP)) + .px(px(STRIP_PAD_X)) + .pt(px(STRIP_PAD_TOP)) + .overflow_x_scroll(); + let max_image_width = self.last_available_width.unwrap_or(COMPOSER_MAX_WIDTH) + - 2.0 * Theme::SPACE_LG + - 2.0 + - 2.0 * STRIP_PAD_X + - 2.0 * APPSHOT_IMAGE_INSET; + for (ix, appshot) in appshots.iter().enumerate() { + let group: SharedString = format!("composer-appshot-{}", appshot.id).into(); + let preview = crate::attachments::PreviewImage::new( + appshot.screenshot.name.clone(), + appshot.screenshot.image.clone(), + ); + let preview_on_key = preview.clone(); + let preview_on_a11y = preview.clone(); + let composer_for_preview = cx.entity().downgrade(); + let remove_id = appshot.id.clone(); + let remove_on_key_id = remove_id.clone(); + let remove_on_a11y_id = remove_id.clone(); + let composer_for_remove = cx.entity().downgrade(); + let source: SharedString = appshot + .window_title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| appshot.app_name.clone()) + .into(); + let preview_label: SharedString = format!("Preview {source}").into(); + let remove_label: SharedString = format!("Remove {source}").into(); + let preview_aria = preview_label.clone(); + let remove_aria = remove_label.clone(); + let (image_width, image_height) = + appshot_contained_size(appshot.screenshot_dimensions, max_image_width); + let tile_width = (image_width + 2.0 * APPSHOT_IMAGE_INSET).max(APPSHOT_TILE_MIN_WIDTH); + let mut card = div() + .id(("composer-appshot", ix)) + .group(group.clone()) + .relative() + .w(px(tile_width)) + .h(px(APPSHOT_TILE_HEIGHT)) + .flex_none() + .flex() + .flex_col() + .items_center() + .rounded(px(14.0)) + .overflow_hidden() + .cursor_pointer() + .hover(|style| style.bg(crate::theme::ink(0.045))) + .tooltip(move |_, cx| { + cx.new(|_| AppshotActionTooltip(preview_label.clone())) + .into() + }) + .role(gpui::Role::Button) + .aria_label(preview_aria) + .tab_index(0) + .focus_visible(|style| { + style + .bg(crate::theme::ink(0.06)) + .border_1() + .border_color(theme.accent) + }) + .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + preview_on_key.viewer.reset(); + this.preview = Some(preview_on_key.clone()); + this.preview_focus_pending = true; + cx.notify(); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + composer_for_preview + .update(cx, |this, cx| { + preview_on_a11y.viewer.reset(); + this.preview = Some(preview_on_a11y.clone()); + this.preview_focus_pending = true; + cx.notify(); + }) + .ok(); + }) + .on_click(cx.listener(move |this, _, _, cx| { + preview.viewer.reset(); + this.preview = Some(preview.clone()); + this.preview_focus_pending = true; + cx.notify(); + })) + .child( + div() + .id(("composer-appshot-preview", ix)) + .w(px(tile_width)) + .h(px(APPSHOT_PREVIEW_HEIGHT)) + .relative() + .flex_none() + .flex() + .items_end() + .justify_center() + .overflow_hidden() + .rounded(px(12.0)) + .child( + div() + .w(px(image_width)) + .h(px(image_height)) + .flex_none() + .overflow_hidden() + .rounded(px(4.0)) + .shadow_sm() + .child(crate::edge_fade::edge_faded( + 44.0, + false, + true, + img(appshot.screenshot.image.clone()) + .w(px(image_width)) + .h(px(image_height)) + .object_fit(ObjectFit::Contain), + )), + ), + ) + .child( + div() + .mt(px(20.0)) + .max_w(px(tile_width - 20.0)) + .truncate() + .text_center() + .text_size(px(12.5)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text) + .child(source), + ); + if let Some(icon) = &appshot.app_icon { + card = card.child(crate::frost::layered( + div() + .absolute() + .top(px(APPSHOT_PREVIEW_HEIGHT - 22.0)) + .left(px((tile_width - 28.0) / 2.0)) + .size(px(28.0)) + .rounded(px(7.0)) + .bg(theme.bg) + .border_1() + .border_color(theme.border) + .flex() + .items_center() + .justify_center() + .child( + img(icon.clone()) + .size(px(24.0)) + .rounded(px(5.0)) + .object_fit(ObjectFit::Contain), + ), + )); + } + card = card.child(crate::frost::layered( + div() + .id(("composer-appshot-remove", ix)) + .absolute() + .top(px(6.0)) + .right(px(6.0)) + .size(px(22.0)) + .rounded_full() + .bg(theme.bg.opacity(0.92)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .shadow_sm() + .opacity(0.0) + .group_hover(group, |style| style.opacity(1.0)) + .tooltip(move |_, cx| { + cx.new(|_| AppshotActionTooltip(remove_label.clone())) + .into() + }) + .role(gpui::Role::Button) + .aria_label(remove_aria) + .tab_index(0) + .focus_visible(|style| style.opacity(1.0).border_1().border_color(theme.accent)) + .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.remove_appshot(&remove_on_key_id, cx); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + composer_for_remove + .update(cx, |this, cx| { + this.remove_appshot(&remove_on_a11y_id, cx); + }) + .ok(); + }) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.remove_appshot(&remove_id, cx); + })) + .child( + crate::icons::icon(crate::icons::CLOSE_CIRCLE) + .size(px(15.0)) + .text_color(theme.text_muted), + ), + )); + // Entity-owned timestamps prevent the entrance replaying on route remount. + if let Some(start) = self.appshot_entrances.get(&appshot.id) { + let raw = (start.elapsed().as_secs_f32() / (0.24 * motion::speed_scale())) + .clamp(0.0, 1.0); + if raw < 1.0 && !motion::reduced_motion(cx) { + let progress = + motion::MotionSpec::new(240, motion::EASE_OUT_EXPO).progress(raw); + card = card.opacity(progress).top(px(8.0 * (1.0 - progress))); + window.request_animation_frame(); + } + } + strip = strip.child(card); + } + Some(strip.into_any_element()) + } + /// Paperclip: the native image picker (the original's hidden /// ``). fn open_file_picker(&mut self, cx: &mut Context) { @@ -5301,7 +5722,9 @@ impl Composer { self.failure = Some("The queued message was removed; your edit remains in the composer".into()); // Recover both drafts when another device removes the reserved row. - if let Some((draft, mut attachments)) = self.queue_edit_draft.take() { + if let Some((draft, mut attachments, mut appshots)) = self.queue_edit_draft.take() { + appshots.extend(self.appshots.remove(&self.current_key).unwrap_or_default()); + self.appshots.insert(self.current_key.clone(), appshots); let edited = self.input.read(cx).text().to_string(); let text = [draft, edited] .into_iter() @@ -5442,7 +5865,7 @@ impl Composer { } let has_text = composer_has_content( self.input.read(cx).text(), - self.staged().len(), + self.staged().len() + self.staged_appshots().len(), self.staged_comments(cx).len(), ); send_button_mode(self.run_live(cx), has_text) @@ -5462,8 +5885,11 @@ impl Composer { return; } let text = self.input.read(cx).text().trim().to_string(); - let no_content = - !composer_has_content(&text, self.staged().len(), self.staged_comments(cx).len()); + let no_content = !composer_has_content( + &text, + self.staged().len() + self.staged_appshots().len(), + self.staged_comments(cx).len(), + ); match self.button_mode(cx) { SendButtonMode::Stop => self.interrupt_selected(cx), _ if no_content => {} @@ -5483,7 +5909,7 @@ impl Composer { } let has_content = composer_has_content( self.input.read(cx).text(), - self.staged().len(), + self.staged().len() + self.staged_appshots().len(), self.staged_comments(cx).len(), ); match modified_submit_target(has_content) { @@ -5556,7 +5982,7 @@ impl Composer { let space_id = space.as_ref().map(|s| s.id.clone()); let space_path = space.as_ref().map(|s| s.path.clone()); if queue && !is_new { - let capability = if self.staged().is_empty() { + let capability = if self.staged().is_empty() && self.staged_appshots().is_empty() { capabilities::MESSAGE_QUEUE_V1 } else { capabilities::MESSAGE_QUEUE_ATTACHMENTS_V1 @@ -5573,10 +5999,17 @@ impl Composer { // Snapshot-and-clear NOW (use-attachments.ts takeAttachments): the // strip empties the instant you hit send; a failure hands the files // back into the chat's stash. - let staged = self + let ordinary_staged = self .attachments .remove(&self.current_key) .unwrap_or_default(); + let staged_appshots = self.appshots.remove(&self.current_key).unwrap_or_default(); + let mut staged = ordinary_staged.clone(); + staged.extend( + staged_appshots + .iter() + .map(|appshot| appshot.screenshot.clone()), + ); // `typed` keeps the user's own words for the failure hand-back below: // restoring the folded prompt would paste the comment block into the // input as literal text. @@ -5619,7 +6052,10 @@ impl Composer { .is_some_and(|id| local_device_id.as_deref() != Some(id)); // Queue rows do not carry upstream's attachment-transfer escort, so // they retain the proven host-upload path and store absolute refs. - let queued_flow = !queue && !staged.is_empty() && { + // Appshot XML attributes require escaped final paths. The engine's plain + // string replacement of pending refs cannot safely rewrite those, so + // rich captures use the existing upload-before-send path. + let queued_flow = !queue && staged_appshots.is_empty() && !staged.is_empty() && { let state = self.state.read(cx); let local_ok = local_device_id .as_deref() @@ -5656,7 +6092,15 @@ impl Composer { .map(|att| format!("pending/{}/{}", att.id, att.name)) .collect() }; - let echo_text = attachments::with_attachments(&text, &echo_paths); + let echo_appshot_paths: HashMap = staged + .iter() + .zip(&echo_paths) + .map(|(attachment, path)| (attachment.id.clone(), path.clone())) + .collect(); + let echo_text = attachments::with_attachments( + &appshots::with_appshots(&text, &staged_appshots, &echo_appshot_paths), + &echo_paths, + ); // Queued flow also seeds the UPLOAD ALIAS: the host rewrites the // persisted ref to `{its uploads dir}/{id8}-{name}` — an absolute // path the sender can't predict, but whose id8 it minted. The alias @@ -5840,7 +6284,15 @@ impl Composer { attachments::seed_attachment(&device_id, path, &att.name, att.image.clone()); } } - content = attachments::with_attachments(&text, &attachment_paths); + let appshot_paths: HashMap = staged + .iter() + .zip(&attachment_paths) + .map(|(attachment, path)| (attachment.id.clone(), path.clone())) + .collect(); + content = attachments::with_attachments( + &appshots::with_appshots(&text, &staged_appshots, &appshot_paths), + &attachment_paths, + ); // A normal send already has an optimistic echo: refresh it // in place with the uploaded refs so its thumbnails never // flicker. A queued message has no transcript echo at all; @@ -5995,12 +6447,15 @@ impl Composer { // A queue row is editable UI state, so its text must stay // free of the internal attachment-path trailer. The host // rebuilds that transport when it promotes the row. + let appshot_paths = staged.iter().zip(&attachment_paths) + .map(|(attachment, path)| (attachment.id.clone(), path.clone())).collect(); + let queue_body = appshots::with_appshots(&text, &staged_appshots, &appshot_paths); let queue_text = if !clean_queue_attachment_text { content.as_str() - } else if text.trim().is_empty() && !attachment_paths.is_empty() { + } else if queue_body.trim().is_empty() && !attachment_paths.is_empty() { attachments::ATTACHMENT_ONLY_TEXT } else { - text.as_str() + queue_body.as_str() }; let params = serde_json::json!({ "chatId": chat_id, @@ -6124,12 +6579,12 @@ impl Composer { // no further swap will fire). Set the input directly. composer.input.update(cx, |input, cx| input.set_text(restore_text, cx)); } - if !staged.is_empty() { + if !ordinary_staged.is_empty() { // Merge by id (stashAttachments): files the user staged // while the send was in flight survive the hand-back — // draining the minted chat's slot too when the restore // target is the canvas. - let mut merged = staged.clone(); + let mut merged = ordinary_staged.clone(); for key in [err_chat_id.clone(), restore_key.clone()] { if let Some(slot) = composer.attachments.get_mut(&key) { let fresh: Vec<_> = slot @@ -6139,8 +6594,9 @@ impl Composer { merged.extend(fresh); } } - composer.attachments.insert(restore_key, merged); + composer.attachments.insert(restore_key.clone(), merged); } + composer.restore_failed_appshots(&staged_appshots, &err_chat_id, &restore_key); } cx.notify(); }) @@ -6888,7 +7344,7 @@ impl Render for Composer { && !self.pickers.read(cx).is_open() && !composer_has_content( self.input.read(cx).text(), - self.staged().len(), + self.staged().len() + self.staged_appshots().len(), self.staged_comments(cx).len(), ); let container = container.when_some( @@ -6936,6 +7392,7 @@ impl Render for Composer { // for the outer container padding and the pill's 1px borders. let strip_width_hint = self.last_available_width.unwrap_or(COMPOSER_MAX_WIDTH) - 2.0 * Theme::SPACE_LG - 2.0; + let appshot_count = self.staged_appshots().len(); let strip_h = attachment_strip_height(staged_count, strip_width_hint); let comment_strip_h = comment_strip_height(self.staged_comments(cx).len()); let base_height = if expanded { @@ -6943,7 +7400,8 @@ impl Render for Composer { } else { COMPACT_TOTAL_HEIGHT }; - let target_height = base_height + strip_h + comment_strip_h; + let target_height = + base_height + strip_h + appshot_strip_height(appshot_count) + comment_strip_h; self.height_morph = flip_morph_step( self.height_morph, (target_height - self.last_target_height).abs() > 0.5, @@ -6973,8 +7431,13 @@ impl Render for Composer { } self.last_rendered_height = pill_height; let text_pt = morph_text_pad(morph_t); - let textarea_height = - (pill_height - strip_h - comment_strip_h - PILL_BORDER_V - ACTIONS_ROW_HEIGHT).max(0.0); + let textarea_height = (pill_height + - strip_h + - appshot_strip_height(appshot_count) + - comment_strip_h + - PILL_BORDER_V + - ACTIONS_ROW_HEIGHT) + .max(0.0); self.input.update(cx, |input, cx| { let height = if expanded { (textarea_height - text_pt - 4.0).max(0.0) @@ -7037,6 +7500,7 @@ impl Render for Composer { // Staged-thumbnail strip (attachment-ui.tsx AttachmentStrip), above // the input inside the pill in both modes. let strip = self.render_attachment_strip(&theme, cx); + let appshot_strip = self.render_appshot_strip(&theme, window, cx); let comments_chip = self.render_comments_chip(&theme, cx); // The pill chrome (zeron composer.tsx): `rounded-[26px] border @@ -7086,6 +7550,7 @@ impl Render for Composer { .flex() .flex_col() .children(comments_chip) + .children(appshot_strip) .children(strip) .child( div() @@ -7148,6 +7613,7 @@ impl Render for Composer { .flex_col() .justify_end() .children(comments_chip) + .children(appshot_strip) .children(strip) .child( div() @@ -8019,6 +8485,37 @@ mod tests { assert_eq!(input_content_height(0), INPUT_LINE_HEIGHT); } + #[test] + fn appshot_strip_height_tracks_cards() { + assert_eq!(appshot_strip_height(0), 0.0); + assert_eq!(appshot_strip_height(1), STRIP_PAD_TOP + APPSHOT_TILE_HEIGHT); + assert_eq!(appshot_strip_height(2), appshot_strip_height(1)); + } + + #[test] + fn appshot_images_share_height_and_adapt_width_without_losing_aspect_ratio() { + let landscape = appshot_contained_size(Some((1600, 900)), 320.0); + assert!((landscape.0 - 234.66667).abs() < 0.01); + assert_eq!(landscape.1, APPSHOT_IMAGE_MAX_HEIGHT); + let portrait = appshot_contained_size(Some((900, 1600)), 320.0); + assert!((portrait.0 - 74.25).abs() < 0.01); + assert_eq!(portrait.1, landscape.1); + assert_eq!( + appshot_contained_size(Some((1000, 1000)), 320.0), + (132.0, 132.0) + ); + // Narrow side-by-side layouts and panoramas fit without distortion. + let narrow = appshot_contained_size(Some((1600, 900)), 160.0); + assert_eq!(narrow, (160.0, 90.0)); + assert_eq!( + appshot_contained_size(Some((4000, 1000)), 900.0), + (320.0, 80.0) + ); + let fallback = appshot_contained_size(None, 320.0); + assert!((fallback.0 - 211.2).abs() < 0.01); + assert_eq!(fallback.1, 132.0); + } + #[test] fn input_wheel_scroll_uses_gpui_direction_and_clamps() { // Positive wheel delta moves toward the start; negative moves down. @@ -8769,3 +9266,85 @@ mod tests { assert!(!input_request_resolved(&t, "other")); } } + +#[cfg(test)] +mod appshot_rebase_tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + + #[gpui::test] + fn appshot_only_draft_counts_as_content_and_removal_clears_it(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, cx| { + let shot = appshots::tests::shot(); + composer.stage_appshot(shot.clone(), cx); + assert!(composer_has_content( + "", + composer.staged().len() + composer.staged_appshots().len(), + 0 + )); + composer.remove_appshot(&shot.id, cx); + assert!(composer.staged_appshots().is_empty()); + assert!(!composer_has_content( + "", + composer.staged().len() + composer.staged_appshots().len(), + 0 + )); + }); + } + + #[gpui::test] + fn failed_send_restores_complete_appshots_without_duplicates(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, cx| { + let original = appshots::tests::shot(); + let mut fresh = original.clone(); + fresh.id = "fresh".into(); + composer.stage_appshot_for("minted".into(), original.clone(), cx); + composer.stage_appshot(fresh, cx); + composer.restore_failed_appshots(&[original.clone()], "minted", ""); + assert_eq!(composer.staged_appshots().len(), 2); + assert_eq!( + composer.staged_appshots()[0].accessibility, + original.accessibility + ); + assert_eq!( + composer.staged_appshots()[0].screenshot.id, + original.screenshot.id + ); + assert_eq!(composer.staged_appshots()[1].id, "fresh"); + assert!(!composer.appshots.contains_key("minted")); + }); + } + + #[gpui::test] + fn remote_queue_removal_recovers_both_appshot_drafts(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, cx| { + let original = appshots::tests::shot(); + let mut edited = original.clone(); + edited.id = "edited".into(); + composer.stage_appshot(edited, cx); + composer.queue_edit_draft = Some(("original".into(), vec![], vec![original])); + composer.editing_queued = Some("removed-row".into()); + composer + .input + .update(cx, |input, cx| input.set_text("edited", cx)); + composer.on_state_changed(cx); + assert!(composer.editing_queued.is_none()); + assert_eq!(composer.staged_appshots().len(), 2); + assert_eq!(composer.input.read(cx).text(), "original\n\nedited"); + }); + } +} + +#[cfg(feature = "appshots-fixture")] +impl Composer { + pub fn fixture_clear_appshots(&mut self, cx: &mut Context) { + self.appshots.clear(); + cx.notify(); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 78f74e13f..c9befb849 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -14,6 +14,7 @@ pub mod app_menus; pub mod appearance; +pub mod appshots; pub mod attachments; pub mod badges; pub mod browser; @@ -54,7 +55,7 @@ mod workspace_links; use std::path::PathBuf; -use futures::StreamExt as _; +use futures::{FutureExt as _, StreamExt as _}; use gpui::{App, AppContext as _, Bounds, TitlebarOptions, WindowBounds, WindowOptions, px, size}; pub use state::EngineBootConfig; @@ -164,6 +165,7 @@ pub fn run_app(config: UiConfig) { cx, ); composer::init(cx, ui_settings.composer_send_behavior); + appshots::set_enabled(ui_settings.appshots_enabled); terminal::panel::init(cx); app_menus::init(cx); cx.register_url_scheme("zeron").detach(); @@ -200,6 +202,8 @@ pub fn run_app(config: UiConfig) { boot: config.boot(), }); open_main_window(state, config.boot(), cx); + #[cfg(any(target_os = "macos", target_os = "linux"))] + start_appshot_service(config.boot().data_dir, cx); // Native menu bar — macOS gets the standard app menu (About/Services/ // Hide/Quit ⌘Q), Edit clipboard verbs routed to the focused input, and // a Window menu (⌘M/⌘W). Without this, `NSApp.mainMenu` stays nil: no @@ -215,80 +219,229 @@ pub fn run_app(config: UiConfig) { /// Open the 1320×880 main window (min 900×600) with [`shell::Shell`] as the /// root view. Called at boot and again from `on_reopen` if the dock icon is /// clicked after ⌘W closed the window. -fn open_main_window(state: gpui::Entity, boot: EngineBootConfig, cx: &mut App) { +fn open_main_window( + state: gpui::Entity, + boot: EngineBootConfig, + cx: &mut App, +) -> gpui::WindowHandle { // zeron window geometry: 1320×880, min 900×600 (feature-inventory §1.1). let bounds = Bounds::centered(None, size(px(1320.), px(880.)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size(px(900.), px(600.))), - // `kind` is deliberately left at its default `WindowKind::Normal` - // (gpui platform.rs WindowOptions::default), which on macOS maps - // to `NSNormalWindowLevel` (gpui_macos window.rs) — same as zed's - // main window. Nothing here raises the window level or touches - // presentation options; the "menu bar never appears" symptom came - // from the missing `set_menus` call (nil `NSApp.mainMenu`), not - // from window kind/level, and `appears_transparent` only affects - // the titlebar, not the menu bar. - // macOS: frameless-inset chrome like the original Electron app - // (`titleBarStyle: "hiddenInset"`, traffic lights at 14,15 — - // feature-inventory §1.1). No title text — the strip is - // custom-drawn (zed sets `title: None` the same way). On - // Linux/Windows `appears_transparent` hides the system titlebar - // for our custom-drawn chrome; harmless where unsupported. - titlebar: Some(TitlebarOptions { - title: None, - appears_transparent: true, - // Centered on the titlebar's content line (40px bar, content - // shifted 4px down, lights ~12px tall → center 22). - traffic_light_position: Some(gpui::point(px(14.), px(14.))), - }), - // Our own titlebar strip drags the window (WindowControlArea:: - // Drag + start_window_move) — mark the content view app-owned - // so AppKit neither dead-zones the strip nor delays clicks. - app_owns_titlebar_drag: true, - // Linux: request client-side decorations — zeron draws its own - // unified titlebar and (under CSD) its own caption buttons - // (shell.rs `render_linux_caption_controls`). Leaving this unset - // requests SERVER decorations, which stacked a compositor - // titlebar on top of the app's chrome under sway/KDE, while - // compositors without SSD support (GNOME) went client-side - // anyway — frameless, and before the shell drew caption buttons, - // with no window controls at all. The compositor can still - // override via xdg-decoration negotiation; the shell re-resolves - // what to draw every frame. - window_decorations: cfg!(target_os = "linux") - .then_some(gpui::WindowDecorations::Client), - // Frosted shell (macOS): blur the desktop behind the window; the - // shell paints its frost surface translucent so the sidebar reads - // as glass (shell.rs root). Elsewhere blur support is compositor - // roulette — stay opaque. - // One source of truth with the re-apply loop in `appearance::apply` - // — if these two ever disagree, vibrancy dies on the first theme - // change and never comes back. - window_background: theme::Theme::of(cx).window_background_appearance(), - app_id: Some("zeron".into()), - ..Default::default() - }, - move |window, cx| { - window.set_rem_size(px(typography::font_size(cx).pixels())); - // React to the user flipping macOS between light and dark. Detached: - // the subscription lives as long as the window does, and the window - // owns nothing that would drop it early. - appearance::observe_window(window, cx).detach(); - let shell = cx.new(|cx| shell::Shell::new(state, boot, cx)); - let weak_shell = shell.downgrade(); - window.on_window_should_close(cx, move |_, cx| { - weak_shell - .update(cx, |shell, cx| shell.prepare_window_close(cx)) - .unwrap_or(true) - }); - shell - }, - ) - .expect("failed to open window"); + let handle = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(900.), px(600.))), + // `kind` is deliberately left at its default `WindowKind::Normal` + // (gpui platform.rs WindowOptions::default), which on macOS maps + // to `NSNormalWindowLevel` (gpui_macos window.rs) — same as zed's + // main window. Nothing here raises the window level or touches + // presentation options; the "menu bar never appears" symptom came + // from the missing `set_menus` call (nil `NSApp.mainMenu`), not + // from window kind/level, and `appears_transparent` only affects + // the titlebar, not the menu bar. + // macOS: frameless-inset chrome like the original Electron app + // (`titleBarStyle: "hiddenInset"`, traffic lights at 14,15 — + // feature-inventory §1.1). No title text — the strip is + // custom-drawn (zed sets `title: None` the same way). On + // Linux/Windows `appears_transparent` hides the system titlebar + // for our custom-drawn chrome; harmless where unsupported. + titlebar: Some(TitlebarOptions { + title: None, + appears_transparent: true, + // Centered on the titlebar's content line (40px bar, content + // shifted 4px down, lights ~12px tall → center 22). + traffic_light_position: Some(gpui::point(px(14.), px(14.))), + }), + // Our own titlebar strip drags the window (WindowControlArea:: + // Drag + start_window_move) — mark the content view app-owned + // so AppKit neither dead-zones the strip nor delays clicks. + app_owns_titlebar_drag: true, + // Linux: request client-side decorations — zeron draws its own + // unified titlebar and (under CSD) its own caption buttons + // (shell.rs `render_linux_caption_controls`). Leaving this unset + // requests SERVER decorations, which stacked a compositor + // titlebar on top of the app's chrome under sway/KDE, while + // compositors without SSD support (GNOME) went client-side + // anyway — frameless, and before the shell drew caption buttons, + // with no window controls at all. The compositor can still + // override via xdg-decoration negotiation; the shell re-resolves + // what to draw every frame. + window_decorations: cfg!(target_os = "linux") + .then_some(gpui::WindowDecorations::Client), + // Frosted shell (macOS): blur the desktop behind the window; the + // shell paints its frost surface translucent so the sidebar reads + // as glass (shell.rs root). Elsewhere blur support is compositor + // roulette — stay opaque. + // One source of truth with the re-apply loop in `appearance::apply` + // — if these two ever disagree, vibrancy dies on the first theme + // change and never comes back. + window_background: theme::Theme::of(cx).window_background_appearance(), + app_id: Some("zeron".into()), + ..Default::default() + }, + move |window, cx| { + window.set_rem_size(px(typography::font_size(cx).pixels())); + // React to the user flipping macOS between light and dark. Detached: + // the subscription lives as long as the window does, and the window + // owns nothing that would drop it early. + appearance::observe_window(window, cx).detach(); + let shell = cx.new(|cx| shell::Shell::new(state, boot, cx)); + let weak_shell = shell.downgrade(); + window.on_window_should_close(cx, move |_, cx| { + weak_shell + .update(cx, |shell, cx| shell.prepare_window_close(cx)) + .unwrap_or(true) + }); + shell + }, + ) + .expect("failed to open window"); // Belt and braces: assert the blur once the window actually exists. The // `WindowOptions` value is applied during creation, before the view is // attached; re-pushing it here means a window is never left opaque. appearance::reapply_window_background(cx); + handle +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn start_appshot_service(activation_dir: std::path::PathBuf, cx: &mut App) { + let mut shortcuts = appshots::start_global_shortcut(activation_dir); + cx.spawn(async move |cx| { + while shortcuts.next().await.is_some() { + if !appshots::capture_allowed() { + continue; + } + let Some(capture) = cx.update(start_appshot_capture) else { + continue; + }; + let capture = capture.await; + // Coalesce presses made while capture was in flight. Delivery + // focuses Zeron; replaying old activations would capture the wrong + // app or show a misleading self-capture error after success. + while matches!(shortcuts.next().now_or_never(), Some(Some(()))) {} + cx.update(|cx| deliver_appshot(capture, cx)); + } + }) + .detach(); +} + +/// Check viewer focus on the UI thread before any native capture or portal +/// request. Portals do not identify the source window, so their backends cannot +/// reject Zeron after the picker or capture has already started. +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn start_appshot_capture( + cx: &mut App, +) -> Option>> { + if cx.active_window().is_some() { + return None; + } + Some( + cx.background_executor() + .spawn(async { appshots::capture_active_window().await }), + ) +} + +#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] +mod appshot_activation_tests { + use super::*; + + struct ViewerWindow; + + impl gpui::Render for ViewerWindow { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + gpui::div() + } + } + + #[gpui::test] + fn appshot_capture_skips_any_focused_viewer_window(cx: &mut gpui::TestAppContext) { + // The guard must cover every Zeron window, not only a Shell/chat root. + for _ in 0..2 { + let window = cx.add_window(|_, _| ViewerWindow); + window + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert!(cx.active_window().is_some()); + assert!(start_appshot_capture(cx).is_none()); + }); + } + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn deliver_appshot( + result: Result, + cx: &mut App, +) { + use std::collections::VecDeque; + use std::sync::{Mutex, OnceLock}; + + fn pending() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(VecDeque::new())) + } + + if matches!( + result, + Err(appshots::CaptureError::Cancelled | appshots::CaptureError::SelfCapture) + ) { + return; + } + let mut captures = pending() + .lock() + .map(|mut queue| queue.drain(..).collect::>()) + .unwrap_or_default(); + let error = match result { + Ok(appshot) => { + captures.push_back(appshot); + None + } + Err(error) => Some(error), + }; + let handle = cx + .window_stack() + .unwrap_or_else(|| cx.windows()) + .into_iter() + .find_map(|handle| handle.downcast::()) + .or_else(|| { + let reopen = cx.try_global::()?; + Some(open_main_window( + reopen.state.clone(), + reopen.boot.clone(), + cx, + )) + }); + let Some(handle) = handle else { + if !captures.is_empty() { + let count = captures.len(); + if let Ok(mut queue) = pending().lock() { + queue.extend(captures); + } + tracing::warn!( + count, + "Appshot captured with no Zeron window; preserving it for the next delivery" + ); + } + return; + }; + let captured = !captures.is_empty(); + cx.activate(true); + let _ = handle.update(cx, |shell, window, cx| { + window.activate_window(); + for appshot in captures { + shell.receive_appshot(appshot, window, cx); + } + if let Some(error) = error { + shell.show_appshot_error(error.to_string(), window, cx); + } + }); + if captured { + appshots::foreground_after_capture(); + } } diff --git a/crates/ui/src/queue.rs b/crates/ui/src/queue.rs index e1a90072b..54077d425 100644 --- a/crates/ui/src/queue.rs +++ b/crates/ui/src/queue.rs @@ -74,13 +74,15 @@ impl Render for QueueActionTooltip { /// Compact, borderless rows inside the queue's single glass surface. const ROW_HEIGHT: f32 = 36.0; const QUEUE_TEXT_SIZE: f32 = 12.5; -const ROW_GAP: f32 = 2.0; +const ROW_GAP: f32 = 0.0; const ROW_SLOT: f32 = ROW_HEIGHT + ROW_GAP; const ROW_PAD_X: f32 = 8.0; -const ROW_RADIUS: f32 = 8.0; -const PANEL_PAD_X: f32 = 8.0; -const PANEL_RADIUS: f32 = ROW_RADIUS + PANEL_PAD_X; -const PANEL_PAD_TOP: f32 = PANEL_PAD_X; +const PANEL_RADIUS: f32 = 16.0; +const PANEL_BORDER: f32 = 1.0; +const PANEL_INSET: f32 = 4.0; +// Concentric with the tray's outer edge, including its layout border. +const ROW_RADIUS: f32 = PANEL_RADIUS - PANEL_BORDER - PANEL_INSET; +const PANEL_PAD_TOP: f32 = PANEL_INSET; /// The custom 24px queue glyphs have quieter geometry than the legacy set, so /// render them slightly larger to preserve the previous optical weight. const QUEUE_ICON_SIZE: f32 = 13.0; @@ -176,6 +178,10 @@ fn one_line(text: &str) -> SharedString { /// an older client may still have stored the attachment trailer in `text`. /// Hide it only when the parsed paths exactly match the row's attachment field. fn queue_visible_text(text: &str, attachments: &[String]) -> String { + let text = crate::appshots::strip_context_for_display(text); + if text.trim().is_empty() && !attachments.is_empty() { + return crate::attachments::ATTACHMENT_ONLY_TEXT.to_string(); + } if attachments.is_empty() { return text.to_string(); } @@ -196,6 +202,22 @@ fn queue_visible_text(text: &str, attachments: &[String]) -> String { } } +/// Presentation-only metadata. Never expose the observed accessibility payload. +fn queue_attachment_labels(text: &str, paths: &[String]) -> Vec { + let presentations = crate::appshots::presentations(text); + paths + .iter() + .map(|path| match presentations.get(path) { + Some(appshot) => format!("{} Appshot", appshot.app_name), + None => std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Image") + .to_owned(), + }) + .collect() +} + fn queue_panel_surface(theme: &Theme) -> gpui::Div { div() .occlude() @@ -204,9 +226,12 @@ fn queue_panel_surface(theme: &Theme) -> gpui::Div { .border_1() .border_color(theme.border) .when(!theme.is_frost(), |el| el.shadow_lg()) - .px(px(PANEL_PAD_X)) + // Inset hover surfaces so they stay inside the rounded tray. + .px(px(PANEL_INSET)) .pt(px(PANEL_PAD_TOP)) - .pb(px(QUEUE_COMPOSER_OVERLAP)) + // The overlap is hidden behind the composer; retain a visible inset + // below the final row, matching the top and sides. + .pb(px(QUEUE_COMPOSER_OVERLAP + PANEL_INSET)) .flex() .flex_col() } @@ -235,6 +260,24 @@ fn queue_rows( .outset_bottom(QUEUE_TEXT_SIZE) } +fn preview_load_gate() -> &'static futures::lock::Mutex<()> { + static GATE: std::sync::OnceLock> = std::sync::OnceLock::new(); + GATE.get_or_init(|| futures::lock::Mutex::new(())) +} + +pub(crate) struct QueuePreview { + image: Option, + finished: bool, + // Keeping the task here cancels offscreen transfers on eviction. + _task: gpui::Task<()>, +} + +fn visible_queue_rows(offset: f32, height: f32, count: usize) -> std::ops::Range { + let first = ((-offset).max(0.0) / ROW_SLOT).floor() as usize; + let last = first.saturating_add((height.max(0.0) / ROW_SLOT).ceil() as usize + 1); + first.min(count)..last.min(count) +} + impl Composer { /// The queue panel, or `None` when nothing is waiting. Like the composer, /// it is one frosted surface; rows use spacing and hover wash rather than @@ -242,7 +285,7 @@ impl Composer { pub(crate) fn render_queue_panel( &mut self, show_head_shortcut: bool, - window: &Window, + window: &mut Window, cx: &mut Context, ) -> Option { // A drop outside the panel ends GPUI's active drag without invoking our @@ -259,6 +302,7 @@ impl Composer { ); (state.queue.clone(), chat_id, host_supports_actions) }; + self.prepare_queue_previews(&items, window, cx); if items.is_empty() { return None; } @@ -291,6 +335,7 @@ impl Composer { ); let panel = queue_panel_surface(&theme) + .on_scroll_wheel(cx.listener(|_, _, _, cx| cx.notify())) // The complete glass surface is a drop target, including its // padding. .on_drag_move::(cx.listener( @@ -397,6 +442,11 @@ impl Composer { &key, primary_action, resolved_primary.is_some(), + queue_head_shortcut_visible( + ix, + show_head_shortcut, + resolved_primary.is_some() && !being_removed, + ), theme, cx.listener(move |this, _, _, cx| { this.activate_queued_primary(primary_id.clone(), primary_action, cx); @@ -453,7 +503,11 @@ impl Composer { .flex() .flex_row() .items_center() - .gap(px(8.0)) + .gap(px(if self.queue_preview_limit() == 1 { + 4.0 + } else { + 8.0 + })) .rounded(px(ROW_RADIUS)) .when(being_edited, |el| el.bg(crate::theme::ink(0.06))) .when(!being_edited && !being_removed, |el| { @@ -481,15 +535,71 @@ impl Composer { // from the editing state. .when(being_edited, |el| el.child(div().w(px(14.0)).flex_none())) .when(!being_edited, |el| { - el.child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(px(QUEUE_TEXT_SIZE)) - .text_color(theme.text.opacity(0.9)) - .child(text), + let labels = queue_attachment_labels(&item.text, &item.attachments); + let summary = if labels.len() > 1 { + format!("{} attachments · {}", labels.len(), labels.join(" · ")) + } else { + labels.join(" · ") + }; + let only_images = text.as_ref() == crate::attachments::ATTACHMENT_ONLY_TEXT; + let title = if only_images { + summary.clone().into() + } else { + text + }; + let mut content = div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(1.0)) + .child( + div() + .truncate() + .text_size(px(QUEUE_TEXT_SIZE)) + .line_height(px(16.0)) + .text_color(theme.text.opacity(0.9)) + .child(title), + ); + if !labels.is_empty() && !only_images { + content = content.child( + div() + .truncate() + .text_size(px(11.0)) + .line_height(px(13.0)) + .text_color(theme.text_muted) + .child(summary), + ); + } + el.children( + item.attachments + .iter() + .take(self.queue_preview_limit()) + .enumerate() + .map(|(index, path)| self.queue_thumbnail(&key, index, path, cx)), ) + .when(item.attachments.len() > self.queue_preview_limit(), |el| { + let remaining = item.attachments.len() - self.queue_preview_limit(); + el.child( + div() + .id(SharedString::from(format!("{key}-more-attachments"))) + .w(px(28.0)) + .h(px(28.0)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(5.0)) + .bg(crate::theme::ink(0.06)) + .text_size(px(11.0)) + .text_color(theme.text_muted) + .aria_label(format!( + "{remaining} more attachments; edit message to view all" + )) + .child(format!("+{remaining}")), + ) + }) + .child(content) }) .when(being_edited, |el| { el.child( @@ -505,14 +615,6 @@ impl Composer { }), ) }) - // Keep queued attachments visible alongside the message. - .when(!item.attachments.is_empty(), |el| { - el.child( - crate::icons::icon(crate::icons::QUEUE_PAPERCLIP) - .size(px(QUEUE_ICON_SIZE)) - .text_color(theme.text_muted.opacity(0.7)), - ) - }) .when(being_edited, |el| { el.child( div() @@ -535,19 +637,6 @@ impl Composer { .gap(px(3.0)) .child(discard) .child(edit) - .when( - queue_head_shortcut_visible( - ix, - show_head_shortcut, - resolved_primary.is_some() && !being_removed, - ), - |el| { - el.child(crate::popover::kbd_hint( - theme, - modifier_send_label(cfg!(target_os = "macos")), - )) - }, - ) .child(primary), ) }); @@ -573,6 +662,245 @@ impl Composer { .into_any_element() } + pub(crate) fn release_queue_previews(&mut self, cx: &mut gpui::App) { + for (_, preview) in self.queue_previews.drain() { + if let Some(image) = preview.image { + gpui::ImageSource::Image(image.image).evict(None, cx); + } + } + } + + fn prepare_queue_previews( + &mut self, + items: &[QueuedMessage], + window: &mut Window, + cx: &mut Context, + ) { + use crate::attachments; + let state = self.state.read(cx); + let device = state + .selected_chat_row() + .map(|chat| chat.device_id.clone()) + .unwrap_or_default(); + let engine = state.engine().cloned(); + let target = + (state.local_device_id.as_deref() != Some(device.as_str())).then(|| device.clone()); + let visible = visible_queue_rows( + f32::from(self.queue_scroll.offset().y), + f32::from(window.viewport_size().height) * 0.3, + items.len(), + ); + let keys: std::collections::HashSet<_> = items[visible] + .iter() + .flat_map(|item| item.attachments.iter().take(self.queue_preview_limit())) + .map(|path| (device.clone(), path.clone())) + .take(64) + .collect(); + self.queue_previews.retain(|key, preview| { + if keys.contains(key) { + return true; + } + if let Some(image) = &preview.image { + gpui::ImageSource::Image(image.image.clone()).evict(Some(window), cx); + } + false + }); + let Some(engine) = engine else { return }; + for key in keys { + if self.queue_previews.contains_key(&key) { + continue; + } + let engine = engine.clone(); + let target = target.clone(); + let task_key = key.clone(); + let task = cx.spawn(async move |this, cx| { + let _permit = preview_load_gate().lock().await; + let source = match attachments::attachment_snapshot(&task_key.0, &task_key.1) { + attachments::AttachmentSnapshot::Loaded(image) => { + Some(attachments::LoadedAttachmentImage { + name: image.name.to_string(), + image: image.image, + }) + } + _ => { + attachments::read_attachment_image( + &engine, + cx.background_executor(), + target.as_deref(), + &task_key.1, + ) + .await + } + }; + let image = if let Some(source) = source { + cx.background_executor() + .spawn(async move { + attachments::queue_thumbnail_image(&source.image).map(|image| { + attachments::CachedAttachmentImage { + name: source.name.into(), + image, + } + }) + }) + .await + } else { + None + }; + this.update(cx, |this, cx| { + if let Some(preview) = this.queue_previews.get_mut(&task_key) { + preview.image = image; + preview.finished = true; + } + cx.notify(); + }) + .ok(); + }); + self.queue_previews.insert( + key, + QueuePreview { + image: None, + finished: false, + _task: task, + }, + ); + } + } + + fn load_queue_full_preview(&mut self, device: String, path: String, cx: &mut Context) { + use crate::attachments; + if let attachments::AttachmentSnapshot::Loaded(image) = + attachments::attachment_snapshot(&device, &path) + { + self.queue_full_preview = None; + self.show_queue_image( + crate::attachments::PreviewImage::new(image.name, image.image), + cx, + ); + return; + } + let state = self.state.read(cx); + let Some(engine) = state.engine().cloned() else { + return; + }; + let target = (state.local_device_id.as_deref() != Some(device.as_str())).then_some(device); + let chat = state.selected_chat.clone(); + self.queue_full_preview = Some(cx.spawn(async move |this, cx| { + let image = attachments::read_attachment_image( + &engine, + cx.background_executor(), + target.as_deref(), + &path, + ) + .await; + this.update(cx, |this, cx| { + this.queue_full_preview = None; + if this.state.read(cx).selected_chat != chat { + return; + } + if let Some(image) = image { + this.show_queue_image( + crate::attachments::PreviewImage::new(image.name, image.image), + cx, + ); + } else { + this.show_appshot_error( + "Could not load the image. Try opening it again.".into(), + cx, + ); + } + }) + .ok(); + })); + } + + fn queue_thumbnail( + &self, + key: &SharedString, + index: usize, + path: &str, + cx: &mut Context, + ) -> AnyElement { + use crate::attachments; + let device = self + .state + .read(cx) + .selected_chat_row() + .map(|chat| chat.device_id.clone()) + .unwrap_or_default(); + let cache_key = (device.clone(), path.to_string()); + let failed = self + .queue_previews + .get(&cache_key) + .is_some_and(|preview| preview.finished && preview.image.is_none()); + let snapshot = self + .queue_previews + .get(&cache_key) + .and_then(|preview| preview.image.clone()); + let frame = div() + .id(SharedString::from(format!("{key}-image-{index}"))) + .w(px(40.0)) + .h(px(28.0)) + .flex_none() + .rounded(px(5.0)) + .border_1() + .border_color(crate::theme::hairline(0.1)) + .bg(crate::theme::ink(0.035)) + .overflow_hidden(); + match snapshot { + Some(image) => { + let label = image.name.clone(); + let path = path.to_owned(); + let accent = Theme::of(cx).accent; + frame + .role(gpui::Role::Button) + .aria_label(format!("Preview {}", label)) + .tab_index(0) + .focus_visible(move |style| style.border_color(accent)) + .hover(move |style| style.border_color(accent)) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.load_queue_full_preview(device.clone(), path.clone(), cx); + })) + .child( + gpui::img(image.image) + .w(px(38.0)) + .h(px(26.0)) + .rounded(px(4.0)) + .object_fit(gpui::ObjectFit::Cover), + ) + .into_any_element() + } + _ => frame + .when(failed, |frame| { + let accent = Theme::of(cx).accent; + frame + .role(gpui::Role::Button) + .aria_label("Open attachment preview") + .tab_index(0) + .focus_visible(move |style| style.border_color(accent)) + .cursor_pointer() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.queue_previews.remove(&cache_key); + this.load_queue_full_preview( + cache_key.0.clone(), + cache_key.1.clone(), + cx, + ); + cx.notify(); + })) + }) + .flex() + .items_center() + .justify_center() + .child(icon(icons::QUEUE_PAPERCLIP).size(px(14.0))) + .into_any_element(), + } + } + /// A permanently-visible trailing glyph button. The queue reference keeps /// edit and remove present instead of revealing them only on hover. fn queue_action( @@ -586,10 +914,13 @@ impl Composer { on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, ) -> AnyElement { let own = SharedString::from(format!("{key}-{slot}-grp")); + let accent = theme.accent; div() .id(SharedString::from(format!("{key}-{slot}"))) .group(own.clone()) - .size(px(18.0)) + .role(gpui::Role::Button) + .aria_label(label) + .size(px(28.0)) .flex_none() .flex() .items_center() @@ -599,7 +930,13 @@ impl Composer { .when(enabled, |el| { el.cursor_pointer() .hover(|s| s.opacity(1.0).bg(crate::theme::ink(0.07))) - .on_click(on_click) + .tab_index(0) + .focus_visible(move |s| s.bg(accent.opacity(0.18)).text_color(accent)) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |event, window, cx| { + cx.stop_propagation(); + on_click(event, window, cx); + }) }) .when(!enabled, |el| { el.cursor(gpui::CursorStyle::Arrow).opacity(0.45) @@ -626,6 +963,7 @@ impl Composer { key: &SharedString, action: QueuePrimaryAction, enabled: bool, + show_shortcut: bool, theme: &Theme, on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, ) -> AnyElement { @@ -634,27 +972,35 @@ impl Composer { } else { "Waiting for provider capabilities" }; + let accent = theme.accent; + let compact = self.queue_preview_limit() == 1; div() .id(SharedString::from(format!("{key}-primary"))) - .h(px(22.0)) + .role(gpui::Role::Button) + .aria_label(tooltip) + // Both labels occupy the same slot; modifier previews never move + // the message text, thumbnails, or adjacent actions. + .w(px(if compact { 28.0 } else { 72.0 })) + .h(px(28.0)) .flex_none() - .px(px(6.0)) .flex() - .flex_row() .items_center() - .gap(px(4.0)) - .rounded(px(6.0)) + .justify_center() + .rounded(px(5.0)) .text_size(px(11.5)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(theme.text_muted.opacity(0.82)) + .text_color(theme.text_muted) .when(enabled, |el| { el.cursor_pointer() + .tab_index(0) .hover(|s| s.bg(crate::theme::ink(0.07)).text_color(theme.text)) - .on_click(on_click) - }) - .when(!enabled, |el| { - el.cursor(gpui::CursorStyle::Arrow).opacity(0.5) + .focus_visible(move |s| s.bg(accent.opacity(0.18)).text_color(accent)) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |event, window, cx| { + cx.stop_propagation(); + on_click(event, window, cx); + }) }) + .when(!enabled, |el| el.opacity(0.45)) .tooltip(move |_, cx| { cx.new(|_| QueueActionTooltip { label: tooltip.into(), @@ -662,7 +1008,26 @@ impl Composer { .into() }) .tooltip_show_delay(std::time::Duration::from_millis(350)) - .child("Send now") + .child(if show_shortcut { + div() + .child(if compact { + if cfg!(target_os = "macos") { + "⌘↵" + } else { + "⌃↵" + } + } else { + modifier_send_label(cfg!(target_os = "macos")) + }) + .into_any_element() + } else if compact { + icon(icons::QUEUE_SEND) + .size(px(QUEUE_ICON_SIZE)) + .text_color(theme.text_muted) + .into_any_element() + } else { + div().child("Send now").into_any_element() + }) .into_any_element() } @@ -928,6 +1293,7 @@ impl Composer { .call(methods::BEGIN_QUEUED_MESSAGE_EDIT, params) .await; let mut loaded_attachments = Vec::new(); + let mut loaded_appshots = Vec::new(); if let Ok(reply) = &result && reply.get("outcome").and_then(|v| v.as_str()) == Some("acquired") { @@ -945,6 +1311,16 @@ impl Composer { None => { load_failed = true; break; } } } + if !load_failed { + let paths: Vec = serde_json::from_value(reply["attachments"].clone()).unwrap_or_default(); + match crate::appshots::restore_queued_appshots( + reply.get("text").and_then(|v| v.as_str()).unwrap_or_default(), + &paths, &loaded_attachments, + ) { + Ok((ordinary, shots)) => { loaded_attachments = ordinary; loaded_appshots = shots; } + Err(_) => { load_failed = true; } + } + } if load_failed { let _ = engine.client().call(methods::FINISH_QUEUED_MESSAGE_EDIT, serde_json::json!({ "chatId": chat_id, "id": id, "leaseId": reply.get("leaseId"), @@ -952,7 +1328,7 @@ impl Composer { })).await; this.update(cx, |composer, cx| { composer.queue_edit_pending_id = None; - composer.failure = Some("Couldn't load the queued attachments. Check the connection and update the chat host.".into()); + composer.failure = Some("Couldn't load the queued attachments or Appshot context. Check the connection and update the chat host.".into()); cx.notify(); }).ok(); return; @@ -1018,8 +1394,10 @@ impl Composer { composer.queue_edit_draft = Some(( composer.input.read(cx).text().to_string(), composer.attachments.remove(&composer.current_key).unwrap_or_default(), + composer.appshots.remove(&composer.current_key).unwrap_or_default(), )); composer.attachments.insert(composer.current_key.clone(), loaded_attachments); + composer.appshots.insert(composer.current_key.clone(), loaded_appshots); composer.focus_pending = true; composer.input.update(cx, |input, cx| input.set_text(text, cx)); composer.start_queue_edit_renewal(engine.clone(), cx); @@ -1054,7 +1432,7 @@ impl Composer { return false; } let text = self.input.read(cx).text().trim().to_string(); - if text.is_empty() && self.staged().is_empty() { + if text.is_empty() && self.staged().is_empty() && self.staged_appshots().is_empty() { self.finish_queue_edit("discard", None, cx); } else { self.finish_queue_edit("commit", Some(text), cx); @@ -1090,7 +1468,8 @@ impl Composer { }); self.queue_edit_task = None; self.queue_edit_renew_task = None; - if let Some((text, attachments)) = self.queue_edit_draft.take() { + if let Some((text, attachments, appshots)) = self.queue_edit_draft.take() { + self.appshots.insert(self.current_key.clone(), appshots); self.input.update(cx, |input, cx| input.set_text(text, cx)); self.attachments .insert(self.current_key.clone(), attachments); @@ -1120,7 +1499,9 @@ impl Composer { return; }; let expected = self.queue_edit_base_text_hash.clone(); - let staged = self.staged().to_vec(); + let mut staged = self.staged().to_vec(); + let staged_appshots = self.staged_appshots().to_vec(); + staged.extend(staged_appshots.iter().map(|shot| shot.screenshot.clone())); let mut params = serde_json::json!({ "chatId": chat_id, "id": id, @@ -1147,6 +1528,11 @@ impl Composer { ).await.map_err(|err| err.to_string())?; paths.push(path); } + let appshot_paths = staged.iter().zip(&paths) + .map(|(attachment, path)| (attachment.id.clone(), path.clone())).collect(); + params["text"] = crate::appshots::with_appshots( + params["text"].as_str().unwrap_or_default(), &staged_appshots, &appshot_paths, + ).into(); if params["text"].as_str().is_some_and(|text| text.trim().is_empty()) && !paths.is_empty() { params["text"] = crate::attachments::ATTACHMENT_ONLY_TEXT.into(); } @@ -1396,9 +1782,22 @@ mod tests { use super::{ PANEL_PAD_TOP, QueuePrimaryAction, ROW_SLOT, available_queue_primary_action, one_line, queue_action_needs_host, queue_drag_offsets, queue_drop_index, queue_head_shortcut_visible, - queue_mutation_acknowledged, queue_visible_text, + queue_mutation_acknowledged, queue_visible_text, visible_queue_rows, }; + #[test] + fn queue_preview_work_follows_the_visible_rows() { + assert_eq!(visible_queue_rows(0.0, ROW_SLOT * 3.0, 1000), 0..4); + assert_eq!( + visible_queue_rows(-ROW_SLOT * 20.0, ROW_SLOT * 3.0, 1000), + 20..24 + ); + assert_eq!( + visible_queue_rows(-ROW_SLOT * 20.0, ROW_SLOT * 3.0, 0), + 0..0 + ); + } + #[test] fn available_primary_action_obeys_row_and_host_gates() { assert_eq!( @@ -1482,6 +1881,60 @@ mod tests { assert_eq!(one_line(" spaced out ").as_ref(), "spaced out"); } + #[test] + fn appshot_context_is_hidden_in_clean_and_legacy_queue_rows() { + let shot = crate::appshots::tests::shot(); + let paths: Vec = vec!["/host/image.png".into()]; + for user_text in ["inspect this", ""] { + let body = crate::appshots::with_appshots( + user_text, + &[shot.clone()], + &std::collections::HashMap::from([(shot.screenshot.id.clone(), paths[0].clone())]), + ); + let expected = if user_text.is_empty() { + crate::attachments::ATTACHMENT_ONLY_TEXT + } else { + user_text + }; + assert_eq!(queue_visible_text(&body, &paths), expected); + assert_eq!( + queue_visible_text(&crate::attachments::with_attachments(&body, &paths), &paths), + expected + ); + } + } + + #[test] + fn attachment_labels_decode_app_names_and_preserve_ordinary_images() { + let shot = crate::appshots::tests::shot(); + let paths = vec![ + "/tmp/shot & detail.png".to_owned(), + "/tmp/reference.png".to_owned(), + ]; + let mut shot = shot; + shot.app_name = "Notes & Ideas".into(); + let body = crate::appshots::with_appshots( + "look", + &[shot.clone()], + &[(shot.screenshot.id.clone(), paths[0].clone())] + .into_iter() + .collect(), + ); + assert_eq!( + super::queue_attachment_labels(&body, &paths), + vec!["Notes & Ideas Appshot", "reference.png"] + ); + assert_eq!( + super::queue_attachment_labels(&body, &["/tmp/other.png".into()]), + vec!["other.png"] + ); + let malformed = format!("\n\n{}\n bool { + self != Self::CaptureAppshot || crate::appshots::is_desktop() + } + /// Row label (zeron lib/shortcuts.ts `SHORTCUT_DEFINITIONS`, verbatim). pub fn label(self) -> &'static str { match self { + ShortcutId::CaptureAppshot => "Capture Appshot", ShortcutId::SaveFile => "Save file", ShortcutId::BrowserReload => "Reload browser page", ShortcutId::ToggleSidebar => "Toggle left sidebar", @@ -583,6 +601,8 @@ impl ShortcutId { /// this guards against only exists off macOS). pub fn default_combo_on(self, mac: bool) -> &'static str { match self { + ShortcutId::CaptureAppshot if mac => "ctrl-alt-space", + ShortcutId::CaptureAppshot => "mod-alt-space", ShortcutId::SaveFile => "mod-s", ShortcutId::BrowserReload => "mod-shift-r", ShortcutId::ToggleSidebar => "mod-b", @@ -625,6 +645,8 @@ impl ShortcutId { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct KeymapConfig { + #[cfg_attr(not(any(target_os = "macos", target_os = "linux")), serde(skip))] + pub capture_appshot: String, pub save_file: String, pub browser_reload: String, pub toggle_sidebar: String, @@ -644,6 +666,7 @@ pub struct KeymapConfig { impl Default for KeymapConfig { fn default() -> Self { Self { + capture_appshot: ShortcutId::CaptureAppshot.default_combo().into(), save_file: ShortcutId::SaveFile.default_combo().into(), browser_reload: ShortcutId::BrowserReload.default_combo().into(), toggle_sidebar: ShortcutId::ToggleSidebar.default_combo().into(), @@ -661,6 +684,7 @@ impl Default for KeymapConfig { impl KeymapConfig { pub fn get(&self, id: ShortcutId) -> &str { match id { + ShortcutId::CaptureAppshot => &self.capture_appshot, ShortcutId::SaveFile => &self.save_file, ShortcutId::BrowserReload => &self.browser_reload, ShortcutId::ToggleSidebar => &self.toggle_sidebar, @@ -680,6 +704,7 @@ impl KeymapConfig { pub fn set(&mut self, id: ShortcutId, combo: String) { match id { + ShortcutId::CaptureAppshot => self.capture_appshot = combo, ShortcutId::SaveFile => self.save_file = combo, ShortcutId::BrowserReload => self.browser_reload = combo, ShortcutId::ToggleSidebar => self.toggle_sidebar = combo, @@ -787,10 +812,11 @@ pub fn conflicted_shortcuts(keymap: &KeymapConfig) -> Vec { .into_iter() .filter(|&id| { let combo = keymap.get(id); - !combo.is_empty() + id.available() + && !combo.is_empty() && ShortcutId::ALL .into_iter() - .any(|other| other != id && keymap.get(other) == combo) + .any(|other| other.available() && other != id && keymap.get(other) == combo) }) .collect() } @@ -945,6 +971,15 @@ impl UiSettings { match std::fs::read_to_string(Self::path(data_dir)) { Ok(text) => { match serde_json::from_str::(&text).and_then(|mut value| { + if let Some(settings) = value.as_object_mut() { + let previous_sound = settings + .get("soundEnabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + settings + .entry("appshotSoundEnabled") + .or_insert(serde_json::Value::Bool(previous_sound)); + } if let Some(keymap) = value .get_mut("keymap") .and_then(serde_json::Value::as_object_mut) @@ -1073,6 +1108,84 @@ mod tests { assert!(!loaded.sound_enabled); } + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn appshot_shortcut_defaults_round_trip_and_reset() { + assert_eq!( + ShortcutId::CaptureAppshot.default_combo_on(true), + "ctrl-alt-space" + ); + assert_eq!( + ShortcutId::CaptureAppshot.default_combo_on(false), + "mod-alt-space" + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + UiSettings::path(dir.path()), + r#"{"keymap":{"newSession":"mod-shift-n"}}"#, + ) + .unwrap(); + let mut settings = UiSettings::load(dir.path()); + assert_eq!( + settings.keymap.capture_appshot, + ShortcutId::CaptureAppshot.default_combo() + ); + assert_eq!(settings.keymap.new_session, "mod-shift-n"); + settings + .keymap + .set(ShortcutId::CaptureAppshot, "mod-alt-k".into()); + std::fs::write( + UiSettings::path(dir.path()), + serde_json::to_vec(&settings).unwrap(), + ) + .unwrap(); + let mut restored = UiSettings::load(dir.path()); + assert_eq!(restored.keymap.capture_appshot, "mod-alt-k"); + restored.keymap.reset(ShortcutId::CaptureAppshot); + assert_eq!( + restored.keymap.capture_appshot, + ShortcutId::CaptureAppshot.default_combo() + ); + } + + #[test] + fn appshot_settings_are_serialized_only_on_desktop() { + let value = serde_json::to_value(UiSettings::default()).unwrap(); + for key in [ + "appshotsEnabled", + "appshotSoundEnabled", + "appshotDestination", + ] { + assert_eq!( + value.get(key).is_some(), + crate::appshots::is_desktop(), + "{key}" + ); + } + assert_eq!( + value["keymap"].get("captureAppshot").is_some(), + crate::appshots::is_desktop() + ); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn appshot_sound_migrates_mute_and_persists_independently() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(UiSettings::path(dir.path()), r#"{"soundEnabled":false}"#).unwrap(); + let mut loaded = UiSettings::load(dir.path()); + assert!(!loaded.appshot_sound_enabled); + loaded.appshot_sound_enabled = true; + std::fs::write( + UiSettings::path(dir.path()), + serde_json::to_vec(&loaded).unwrap(), + ) + .unwrap(); + let restored = UiSettings::load(dir.path()); + assert!(restored.appshot_sound_enabled); + assert!(!restored.sound_enabled); + } + #[test] fn obsolete_steering_preference_does_not_reset_other_settings() { let loaded: UiSettings = serde_json::from_str( @@ -1122,6 +1235,9 @@ mod tests { }, escape_stops_active_agent: true, composer_send_behavior: ComposerSendBehavior::ModEnter, + appshots_enabled: false, + appshot_sound_enabled: true, + appshot_destination: crate::appshots::AppshotDestination::NewSession, appearance: crate::appearance::AppearanceMode::Light, git_history_columns: GitHistoryColumns { author: false, diff --git a/crates/ui/src/settings/appshots.rs b/crates/ui/src/settings/appshots.rs new file mode 100644 index 000000000..19d8e1c7a --- /dev/null +++ b/crates/ui/src/settings/appshots.rs @@ -0,0 +1,293 @@ +//! Appshots settings share the shortcut recorder and persistence events with +//! keyboard settings, while keeping capture setup on its own route. +use super::*; +use crate::appshots::{AppshotPlatform, CapabilityState}; +use crate::settings::widgets; + +fn row( + theme: &Theme, + first: bool, + title: &str, + description: &str, + control: gpui::AnyElement, +) -> gpui::Div { + widgets::card_row(theme, first) + .flex_wrap() + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .gap(px(4.0)) + .child(widgets::row_title(theme, title.to_string())) + .child( + div() + .text_size(crate::typography::ui_rems(12.0)) + .line_height(px(18.0)) + .text_color(theme.text_muted) + .child(SharedString::from(description.to_string())), + ), + ) + .child(control) +} + +impl ShortcutsPage { + pub(super) fn render_appshots(&self, cx: &mut Context) -> gpui::AnyElement { + let theme = Theme::of(cx).clone(); + let accent = theme.accent; + let capabilities = self.appshot_capabilities; + let toggle = |id: &'static str, label: &'static str, enabled: bool| { + widgets::toggle_switch(&theme, enabled) + .id(id) + .role(gpui::Role::Switch) + .aria_label(label) + .aria_toggled(if enabled { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .tab_index(0) + .focus_visible(move |style| style.border_2().border_color(accent)) + }; + let capture = toggle( + "appshots-enabled", + "Capture Appshots", + self.appshots_enabled, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.appshots_enabled = !this.appshots_enabled; + this.commit_appshots(cx); + cx.notify(); + })); + let sound = toggle( + "appshot-sound-enabled", + "Capture sound", + self.appshot_sound_enabled, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.appshot_sound_enabled = !this.appshot_sound_enabled; + this.commit_appshots(cx); + cx.notify(); + })); + let destinations = AppshotDestination::ALL + .into_iter() + .enumerate() + .map(|(ix, destination)| { + let selected = self.appshot_destination == destination; + div() + .id(("appshot-destination", ix)) + .role(gpui::Role::Button) + .aria_label(destination.label()) + .aria_toggled(if selected { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .tab_index(0) + .focus_visible(move |style| style.border_2().border_color(accent)) + .px(px(10.0)) + .py(px(7.0)) + .rounded(px(7.0)) + .border_1() + .border_color(if selected { + theme.text.opacity(0.24) + } else { + theme.border + }) + .bg(if selected { + crate::theme::ink(0.09) + } else { + gpui::transparent_black() + }) + .text_size(px(11.0)) + .text_color(if selected { + theme.text + } else { + theme.text_muted + }) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + this.appshot_destination = destination; + this.commit_appshots(cx); + cx.notify(); + })) + .child(destination.label()) + }) + .collect::>(); + let destination_description = match self.appshot_destination { + AppshotDestination::Automatic => { + "Use the open session, or the new-session composer when no session is open." + } + AppshotDestination::LastSession => { + "Use the open session, or return to the last session used for an Appshot." + } + AppshotDestination::NewSession => { + "Stage captures in a new-session composer, keeping existing drafts intact." + } + }; + let shortcut = div() + .flex() + .items_center() + .gap(px(10.0)) + .child(widgets::badge(&theme, capabilities.global_shortcut.badge())) + .child(self.render_binding_control( + ShortcutId::CaptureAppshot, + 0, + self.recording, + &theme, + cx, + )); + let action = |id: &'static str, label: &'static str| { + widgets::ghost_action(&theme) + .id(id) + .role(gpui::Role::Button) + .aria_label(label) + .tab_index(0) + .focus_visible(move |style| style.border_2().border_color(accent)) + .cursor_pointer() + .child(label) + }; + let mut window_access = div() + .flex() + .items_center() + .gap(px(10.0)) + .child(widgets::badge(&theme, capabilities.window_capture.badge())); + if self.appshots_enabled + && capabilities.window_capture == CapabilityState::PermissionRequired + { + let label = if self.capture_access_prompted { + "Open System Settings" + } else { + "Allow window capture" + }; + window_access = window_access.child(action("appshots-capture-access", label).on_click( + cx.listener(|this, _, _, cx| { + if this.capture_access_prompted { + if let Some(url) = crate::appshots::capture_settings_url() { + cx.open_url(url); + } + } else { + this.capture_access_prompted = true; + crate::appshots::request_capture_access(); + } + this.appshot_capabilities = crate::appshots::capabilities(); + cx.notify(); + }), + )); + } + let mut text_access = div() + .flex() + .items_center() + .gap(px(10.0)) + .child(widgets::badge( + &theme, + capabilities.application_text.badge(), + )); + if self.appshots_enabled + && capabilities.application_text == CapabilityState::PermissionRequired + && crate::appshots::semantic_settings_url().is_some() + { + let label = if self.semantic_access_prompted { + "Open System Settings" + } else { + "Enable text capture" + }; + text_access = text_access.child(action("appshots-semantic-access", label).on_click( + cx.listener(|this, _, _, cx| { + if this.semantic_access_prompted { + if let Some(url) = crate::appshots::semantic_settings_url() { + cx.open_url(url); + } + } else { + this.semantic_access_prompted = true; + crate::appshots::request_semantic_access(); + } + this.appshot_capabilities = crate::appshots::capabilities(); + cx.notify(); + }), + )); + } + let card = widgets::section_card(&theme) + .child(row( + &theme, + true, + "Capture Appshots", + "Captures are staged for review and never sent automatically.", + capture.into_any_element(), + )) + .child(row( + &theme, + false, + "Capture sound", + "Play a sound when an Appshot is ready.", + sound.into_any_element(), + )) + .child(row( + &theme, + false, + if capabilities.platform == AppshotPlatform::LinuxWayland { + "Preferred shortcut" + } else { + "Global shortcut" + }, + capabilities.shortcut_description(), + shortcut.into_any_element(), + )) + .child(row( + &theme, + false, + "Destination", + destination_description, + div() + .flex() + .flex_wrap() + .gap(px(6.0)) + .children(destinations) + .into_any_element(), + )) + .child(row( + &theme, + false, + "Window capture", + capabilities.capture_description(), + window_access.into_any_element(), + )) + .child(row( + &theme, + false, + "Application text", + capabilities.semantic_description(), + text_access.into_any_element(), + )); + let helper: SharedString = if self.recording.is_some() { + "Press Escape to cancel.".into() + } else { + self.conflict_notice.clone().unwrap_or_default() + }; + let refresh = action("appshots-refresh-permissions", "Check again") + .aria_label("Check permissions again") + .on_click(cx.listener(|this, _, _, cx| { + this.appshot_capabilities = crate::appshots::capabilities(); + cx.notify(); + })); + div().id("appshots-settings-page").size_full().overflow_y_scroll().track_focus(&self.focus) + .tab_group() + .tab_index(0) + .tab_stop(false) + .on_key_down(|event, window, cx| { + let key = &event.keystroke; + if key.key == "tab" && !key.modifiers.control && !key.modifiers.alt && !key.modifiers.platform { + if key.modifiers.shift { window.focus_prev(cx); } else { window.focus_next(cx); } + cx.stop_propagation(); + } + }) + .child(widgets::page_column().child(widgets::page_header(&theme, "Appshots", None)) + .child(widgets::page_subtitle(&theme, capabilities.setup_description()).line_height(px(20.0))) + .child(card).child(div().min_h(px(20.0)).mt(px(8.0)).text_size(px(12.0)).text_color(theme.text_muted).child(helper)) + .child(div().mt(px(12.0)).flex().flex_wrap().items_center().gap(px(12.0)) + .child(div().flex_1().text_size(px(12.0)).text_color(theme.text_muted).child("Changed a permission? Check again after returning to Zeron.")) + .child(refresh))) + .into_any_element() + } +} diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index 87cf978dc..9a576a970 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -4,10 +4,14 @@ //! shell persists them and re-applies the app keymap. use gpui::{ - Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, SharedString, Window, div, - prelude::*, px, + Context, Entity, EventEmitter, FocusHandle, Keystroke, SharedString, Window, div, prelude::*, + px, }; +use crate::appshots::{AppshotCapabilities, AppshotDestination}; + +#[path = "appshots.rs"] +mod appshots_page; use crate::settings::{ ComposerSendBehavior, KeymapConfig, ShortcutId, combo_from_keystroke, display_combo, }; @@ -43,18 +47,33 @@ pub enum ShortcutsEvent { EscapeStopsActiveAgentChanged(bool), /// The composer send behavior changed — persist + re-apply. ComposerSendBehaviorChanged(ComposerSendBehavior), + AppshotsChanged { + enabled: bool, + sound_enabled: bool, + destination: AppshotDestination, + }, } pub struct ShortcutsPage { + appshots_page: bool, + appshots_focus_pending: bool, /// Working copy (kept in sync with the shell via change events). keymap: KeymapConfig, escape_stops_active_agent: bool, composer_send_behavior: ComposerSendBehavior, recording: Option, + recording_blur: Option, + recording_interceptor: Option, /// A rejected record attempt ("{Combo} is already assigned to {label}.") — /// conflicts never persist; they're refused at record time, as in zeron. conflict_notice: Option, focus: FocusHandle, + appshots_enabled: bool, + appshot_sound_enabled: bool, + appshot_destination: AppshotDestination, + appshot_capabilities: AppshotCapabilities, + capture_access_prompted: bool, + semantic_access_prompted: bool, // The page never talks RPC; state is kept for parity with sibling pages // (and future per-device keymaps). _state: Entity, @@ -68,19 +87,72 @@ impl ShortcutsPage { keymap: KeymapConfig, escape_stops_active_agent: bool, composer_send_behavior: ComposerSendBehavior, + appshots_enabled: bool, + appshot_sound_enabled: bool, + appshot_destination: AppshotDestination, cx: &mut Context, ) -> Self { + cx.on_release(|_, _| crate::appshots::set_recording(false)) + .detach(); Self { + appshots_page: false, + appshots_focus_pending: false, keymap, escape_stops_active_agent, composer_send_behavior, recording: None, + recording_blur: None, + recording_interceptor: None, conflict_notice: None, focus: cx.focus_handle(), + appshots_enabled, + appshot_sound_enabled, + appshot_destination, + appshot_capabilities: crate::appshots::capabilities(), + capture_access_prompted: false, + semantic_access_prompted: false, _state: state, } } + pub fn show_appshots(&mut self, appshots: bool) { + if self.appshots_page != appshots { + self.stop_recording(); + self.conflict_notice = None; + self.appshots_page = appshots; + self.appshots_focus_pending = appshots; + } + } + + fn start_recording(&mut self, id: ShortcutId, window: &mut Window, cx: &mut Context) { + self.recording = Some(id); + crate::appshots::set_recording(true); + let page = cx.entity().downgrade(); + // Bound actions run before Div key listeners. Intercept first so a + // conflicting chord is recorded/refused instead of running its action. + self.recording_interceptor = Some(cx.intercept_keystrokes(move |event, window, cx| { + let _ = page.update(cx, |page, cx| { + if page.focus.is_focused(window) { + page.record_keystroke(&event.keystroke, cx); + } + }); + })); + self.recording_blur = Some(cx.on_blur(&self.focus, window, |this, _, cx| { + this.stop_recording(); + cx.notify(); + })); + self.conflict_notice = None; + window.focus(&self.focus, cx); + cx.notify(); + } + + fn stop_recording(&mut self) { + self.recording = None; + self.recording_blur = None; + self.recording_interceptor = None; + crate::appshots::set_recording(false); + } + fn commit(&mut self, cx: &mut Context) { cx.emit(ShortcutsEvent::KeymapChanged(self.keymap.clone())); cx.notify(); @@ -107,29 +179,56 @@ impl ShortcutsPage { } } - fn on_key_down(&mut self, event: &KeyDownEvent, cx: &mut Context) { + fn commit_appshots(&self, cx: &mut Context) { + cx.emit(ShortcutsEvent::AppshotsChanged { + enabled: self.appshots_enabled, + sound_enabled: self.appshot_sound_enabled, + destination: self.appshot_destination, + }); + } + + fn record_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) { let Some(recording) = self.recording else { return; }; - let mods = &event.keystroke.modifiers; + let mods = &keystroke.modifiers; match record_key( - &event.keystroke.key, + &keystroke.key, mods.control, mods.alt, mods.shift, mods.platform, ) { RecordOutcome::Cancelled => { - self.recording = None; + self.stop_recording(); cx.notify(); } RecordOutcome::Ignored => {} RecordOutcome::Set(combo) => { + if recording == ShortcutId::CaptureAppshot + && crate::appshots::validate_shortcut(&combo).is_err() + { + self.conflict_notice = Some( + format!( + "Use {} with a letter, number, function key or navigation key.", + if cfg!(target_os = "macos") { + "Control, Option or Command" + } else { + "Control or Alt" + } + ) + .into(), + ); + self.stop_recording(); + cx.notify(); + cx.stop_propagation(); + return; + } if send_combo_is_reserved(self.composer_send_behavior, &combo) { self.conflict_notice = Some( format!("{} is reserved for the composer.", display_combo(&combo)).into(), ); - self.recording = None; + self.stop_recording(); cx.notify(); cx.stop_propagation(); return; @@ -145,11 +244,11 @@ impl ShortcutsPage { ) .into(), ); - self.recording = None; + self.stop_recording(); cx.notify(); } else { self.keymap.set(recording, combo); - self.recording = None; + self.stop_recording(); self.conflict_notice = None; self.commit(cx); } @@ -172,14 +271,6 @@ impl ShortcutsPage { theme: &Theme, cx: &mut Context, ) -> gpui::Div { - let combo = self.keymap.get(id).to_string(); - let is_recording = recording == Some(id); - let non_default = combo != id.default_combo(); - let chip_text: SharedString = if is_recording { - "Press keys…".into() - } else { - display_combo(&combo).into() - }; // zeron settings.shortcuts.tsx row: min-h-[72px] px-5 gap-5. div() .min_h(px(72.0)) @@ -210,17 +301,47 @@ impl ShortcutsPage { .child(SharedString::from(description(id))), ), ) + .child(self.render_binding_control(id, ix, recording, theme, cx)) + } + + fn render_binding_control( + &self, + id: ShortcutId, + ix: usize, + recording: Option, + theme: &Theme, + cx: &mut Context, + ) -> gpui::Div { + let accent = theme.accent; + let combo = self.keymap.get(id).to_string(); + let is_recording = recording == Some(id); + let non_default = combo != id.default_combo(); + let chip_text: SharedString = if is_recording { + "Press keys…".into() + } else { + display_combo(&combo).into() + }; + div() + .flex() + .flex_none() + .items_center() + .gap(px(20.0)) .when(non_default && !is_recording, |el| { el.child( div() .id(("shortcut-reset", ix)) + .role(gpui::Role::Button) + .aria_label(format!("Reset {} shortcut", id.label())) + .min_h(px(24.0)) + .tab_index(0) + .focus_visible(move |style| style.border_2().border_color(accent)) .text_size(crate::typography::ui_rems(11.0)) .text_color(theme.text_muted.opacity(0.7)) .cursor_pointer() .hover(|s| s.text_color(theme.text)) .on_click(cx.listener(move |this, _, _, cx| { this.keymap.reset(id); - this.recording = None; + this.stop_recording(); this.commit(cx); })) .child(SharedString::from("Reset")), @@ -229,6 +350,14 @@ impl ShortcutsPage { .child( div() .id(("shortcut-combo", ix)) + .role(gpui::Role::Button) + .aria_label(format!( + "Change {} shortcut: {}", + id.label(), + display_combo(&combo) + )) + .tab_index(0) + .focus_visible(move |style| style.border_2().border_color(accent)) .min_w(px(96.0)) .px(px(12.0)) .py(px(6.0)) @@ -257,10 +386,7 @@ impl ShortcutsPage { } }) .on_click(cx.listener(move |this, _, window, cx| { - this.recording = Some(id); - this.conflict_notice = None; - window.focus(&this.focus, cx); - cx.notify(); + this.start_recording(id, window, cx); })) .child(chip_text), ) @@ -271,7 +397,7 @@ impl ShortcutsPage { pub fn conflict_owner(keymap: &KeymapConfig, id: ShortcutId, combo: &str) -> Option { ShortcutId::ALL .into_iter() - .find(|&other| other != id && keymap.get(other) == combo) + .find(|&other| other.available() && other != id && keymap.get(other) == combo) } pub fn send_combo_is_reserved(_behavior: ComposerSendBehavior, combo: &str) -> bool { @@ -287,11 +413,19 @@ pub fn modifier_send_label(is_macos: bool) -> &'static str { /// extends the match and appears on the page by construction /// (`every_shortcut_lands_in_a_rendered_group` holds the other half: its group /// name must be listed here). -const GROUP_ORDER: [&str; 5] = ["Files", "Browser", "Panels", "Sessions", "Jump to session"]; +const GROUP_ORDER: [&str; 6] = [ + "Files", + "Browser", + "Panels", + "Sessions", + "Jump to session", + "Appshots", +]; /// The section a shortcut's row renders under. fn group(id: ShortcutId) -> &'static str { match id { + ShortcutId::CaptureAppshot => "Appshots", ShortcutId::SaveFile => "Files", ShortcutId::BrowserReload => "Browser", ShortcutId::ToggleSidebar | ShortcutId::ToggleChanges | ShortcutId::ToggleTerminal => { @@ -309,6 +443,9 @@ fn group(id: ShortcutId) -> &'static str { /// `SHORTCUT_DEFINITIONS` descriptions, verbatim). fn description(id: ShortcutId) -> &'static str { match id { + ShortcutId::CaptureAppshot => { + "Capture the focused application from anywhere on your desktop." + } ShortcutId::SaveFile => "Save the active workspace file.", ShortcutId::BrowserReload => "Reload the focused browser tab.", ShortcutId::ToggleSidebar => "Show or hide sessions and settings navigation.", @@ -327,8 +464,15 @@ fn description(id: ShortcutId) -> &'static str { } impl Render for ShortcutsPage { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { use crate::settings::widgets; + self.appshot_capabilities = crate::appshots::capabilities(); + if self.appshots_page { + if std::mem::take(&mut self.appshots_focus_pending) { + window.focus(&self.focus, cx); + } + return self.render_appshots(cx); + } let theme = Theme::of(cx).clone(); let recording = self.recording; let escape_stops_active_agent = self.escape_stops_active_agent; @@ -479,6 +623,9 @@ impl Render for ShortcutsPage { // across cards. let mut groups: Vec = Vec::new(); for name in GROUP_ORDER { + if name == "Appshots" { + continue; + } let mut card = widgets::section_card(&theme); let ids = ShortcutId::ALL.into_iter().filter(|&id| group(id) == name); for (gx, id) in ids.enumerate() { @@ -511,9 +658,6 @@ impl Render for ShortcutsPage { .size_full() .overflow_y_scroll() .track_focus(&self.focus) - .on_key_down( - cx.listener(|this, event: &KeyDownEvent, _, cx| this.on_key_down(event, cx)), - ) .child( widgets::page_column() .child( @@ -554,7 +698,7 @@ impl Render for ShortcutsPage { .on_click( cx.listener(|this, _, _, cx| { this.keymap = KeymapConfig::default(); - this.recording = None; + this.stop_recording(); this.conflict_notice = None; this.commit(cx); this.set_escape_stops_active_agent(false, cx); @@ -595,6 +739,7 @@ impl Render for ShortcutsPage { ) .child(escape_behavior_row), ) + .into_any_element() } } @@ -602,6 +747,148 @@ impl Render for ShortcutsPage { mod tests { use super::*; + #[gpui::test] + fn appshots_setup_can_be_enabled_and_configured_by_keyboard(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut page = ShortcutsPage::new( + state, + KeymapConfig::default(), + false, + ComposerSendBehavior::default(), + false, + false, + AppshotDestination::Automatic, + cx, + ); + page.show_appshots(true); + page + }); + window + .update(cx, |page, w, cx| w.focus(&page.focus, cx)) + .unwrap(); + cx.update_window(window.into(), |_, w, cx| { + w.draw(cx).clear(); + }) + .unwrap(); + let press = |cx: &mut gpui::TestAppContext, key: &str| { + cx.update_window(window.into(), |_, w, cx| { + w.draw(cx).clear(); + let keystroke = gpui::Keystroke::parse(key).unwrap(); + w.dispatch_event( + gpui::PlatformInput::KeyDown(gpui::KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }), + cx, + ); + w.dispatch_event( + gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { keystroke }), + cx, + ); + }) + .unwrap(); + }; + press(cx, "tab"); + press(cx, "space"); + window + .update(cx, |page, _, _| assert!(page.appshots_enabled)) + .unwrap(); + press(cx, "tab"); + press(cx, "enter"); + window + .update(cx, |page, _, _| assert!(page.appshot_sound_enabled)) + .unwrap(); + // Skip the shortcut trigger and Automatic to choose Last session. + for key in ["tab", "tab", "tab", "space"] { + press(cx, key); + } + window + .update(cx, |page, _, _| { + assert_eq!(page.appshot_destination, AppshotDestination::LastSession) + }) + .unwrap(); + for key in ["tab", "enter"] { + press(cx, key); + } + window + .update(cx, |page, _, _| { + assert_eq!(page.appshot_destination, AppshotDestination::NewSession) + }) + .unwrap(); + for key in ["shift-tab", "space"] { + press(cx, key); + } + window + .update(cx, |page, _, _| { + assert_eq!(page.appshot_destination, AppshotDestination::LastSession) + }) + .unwrap(); + } + + #[gpui::test] + fn recorder_refuses_bound_actions_before_they_can_run(cx: &mut gpui::TestAppContext) { + use std::{cell::Cell, rc::Rc}; + let fired = Rc::new(Cell::new(false)); + let observed = fired.clone(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + cx.bind_keys([gpui::KeyBinding::new( + &crate::settings::platform_combo("mod-n"), + crate::shell::NewSession, + None, + )]); + cx.on_action(move |_: &crate::shell::NewSession, _| observed.set(true)); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + ShortcutsPage::new( + state, + KeymapConfig::default(), + false, + ComposerSendBehavior::default(), + false, + true, + AppshotDestination::Automatic, + cx, + ) + }); + window + .update(cx, |page, window, cx| { + page.start_recording(ShortcutId::CaptureAppshot, window, cx) + }) + .unwrap(); + cx.simulate_keystrokes(window.into(), &crate::settings::platform_combo("mod-n")); + window + .update(cx, |page, _, _| { + assert!(!fired.get(), "the existing action ran while recording"); + assert!( + page.conflict_notice + .as_deref() + .unwrap() + .contains("New session") + ); + assert_eq!( + page.keymap.capture_appshot, + ShortcutId::CaptureAppshot.default_combo() + ); + assert!(page.recording.is_none()); + assert!(page.recording_interceptor.is_none()); + }) + .unwrap(); + cx.simulate_keystrokes(window.into(), &crate::settings::platform_combo("mod-n")); + assert!( + fired.get(), + "finishing recording must restore normal actions" + ); + } + #[test] fn recording_outcomes() { assert_eq!( @@ -684,6 +971,25 @@ mod tests { ); } + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn appshot_binding_participates_in_existing_conflict_checks() { + let mut keymap = KeymapConfig::default(); + assert_eq!( + conflict_owner(&keymap, ShortcutId::CaptureAppshot, "mod-n"), + Some(ShortcutId::NewSession) + ); + keymap.set(ShortcutId::CaptureAppshot, "mod-alt-k".into()); + assert_eq!( + conflict_owner(&keymap, ShortcutId::NewSession, "mod-alt-k"), + Some(ShortcutId::CaptureAppshot) + ); + assert_eq!( + conflict_owner(&keymap, ShortcutId::CaptureAppshot, "mod-alt-k"), + None + ); + } + #[test] fn modifier_send_labels_are_platform_specific() { assert_eq!(modifier_send_label(true), "⌘ Enter"); diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 39bd4be8d..d1324502d 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -300,6 +300,7 @@ pub fn apply_keymap( platform_combo(fallback) } } + crate::appshots::set_shortcut(&keymap.capture_appshot); cx.clear_key_bindings(); // `clear_key_bindings` also removes the contextual editing actions that // gpui-base installed at startup. Reinitialize the component layer before @@ -391,11 +392,12 @@ pub enum SettingsSection { Files, Notifications, Shortcuts, + Appshots, Archived, } impl SettingsSection { - pub const ALL: [SettingsSection; 8] = [ + pub const ALL: [SettingsSection; 9] = [ SettingsSection::Devices, SettingsSection::Harnesses, SettingsSection::Agents, @@ -403,6 +405,7 @@ impl SettingsSection { SettingsSection::Files, SettingsSection::Notifications, SettingsSection::Shortcuts, + SettingsSection::Appshots, SettingsSection::Archived, ]; @@ -417,6 +420,7 @@ impl SettingsSection { SettingsSection::Files => "Files", SettingsSection::Notifications => "Notifications", SettingsSection::Shortcuts => "Shortcuts", + SettingsSection::Appshots => "Appshots", SettingsSection::Archived => "Archived sessions", } } @@ -1314,6 +1318,8 @@ pub struct Shell { panels: SessionPanels, /// The panel key of the chat currently shown ("" = new-chat canvas). active_chat: String, + /// Last selected session survives opening the blank Appshot destination. + last_appshot_chat: Option, /// Last rendered sidebar order (key + estimated height) — the FLIP baseline /// for the §1.6 resort glide. sidebar_prev_order: Vec<(String, f32)>, @@ -1485,6 +1491,8 @@ impl Shell { state.update(cx, |state, cx| { state.set_change_requests_visible(settings.sidebar_show_pull_request, cx) }); + crate::appshots::set_enabled(settings.appshots_enabled); + crate::appshots::set_capture_sound_enabled(settings.appshot_sound_enabled); // Bind the customizable shortcuts from the persisted keymap. apply_keymap(cx, &settings.keymap, settings.composer_send_behavior); // Dev/testing knob: `ZERON_OPEN_ROUTE=settings[/
]` boots @@ -1499,6 +1507,7 @@ impl Shell { Some("settings/appearance") => Route::Settings(SettingsSection::Appearance), Some("settings/notifications") => Route::Settings(SettingsSection::Notifications), Some("settings/shortcuts") => Route::Settings(SettingsSection::Shortcuts), + Some("settings/appshots") => Route::Settings(SettingsSection::Appshots), Some("settings/archived") => Route::Settings(SettingsSection::Archived), // `new` pins the new-chat canvas (suppresses boot auto-select). Some("new") => { @@ -1625,6 +1634,7 @@ impl Shell { settings, panels: SessionPanels::default(), active_chat: String::new(), + last_appshot_chat: None, sidebar_prev_order: Vec::new(), sidebar_resort: std::collections::HashMap::new(), sidebar_new_keys: std::collections::HashSet::new(), @@ -1664,6 +1674,59 @@ impl Shell { } } + /// Route a completed viewer-side capture only after its source window is + /// safely captured. The explicit target key avoids relying on the + /// state-observation/draft-swap effect ordering when opening the canvas. + pub fn receive_appshot( + &mut self, + appshot: crate::appshots::CapturedAppshot, + window: &mut Window, + cx: &mut Context, + ) { + use crate::appshots::AppshotDestination; + + let selected = self.state.read(cx).selected_chat.clone(); + let target = match self.settings.appshot_destination { + AppshotDestination::Automatic if selected.is_some() => selected, + AppshotDestination::LastSession if selected.is_some() => selected, + AppshotDestination::LastSession => self + .last_appshot_chat + .clone() + .filter(|id| self.state.read(cx).chats.iter().any(|chat| &chat.id == id)), + AppshotDestination::Automatic | AppshotDestination::NewSession => None, + }; + if let Some(chat_id) = &target { + self.open_chat(chat_id.clone(), cx); + } else if self.settings.appshot_destination == AppshotDestination::NewSession + || self.state.read(cx).selected_chat.is_some() + { + // Reuse upstream's project-filter and device defaults for a new + // canvas. Automatic capture on an existing canvas keeps its pick. + self.open_new_session(cx); + } else { + self.route = Route::Chat; + } + let key = target.unwrap_or_default(); + self.composer.update(cx, |composer, cx| { + composer.stage_appshot_for(key, appshot, cx) + }); + window.focus(&self.composer.focus_handle(cx), cx); + cx.notify(); + } + + pub fn show_appshot_error( + &mut self, + message: String, + window: &mut Window, + cx: &mut Context, + ) { + self.route = Route::Chat; + self.composer + .update(cx, |composer, cx| composer.show_appshot_error(message, cx)); + window.focus(&self.composer.focus_handle(cx), cx); + cx.notify(); + } + // ---- splash ---- fn on_state_changed(&mut self, state: &Entity, cx: &mut Context) { @@ -1884,6 +1947,9 @@ impl Shell { // Chat switch: restore THAT chat's panel state (per-session open flags; // snap, no tween — the panels belong to the destination chat). let selected = state.read(cx).selected_chat.clone().unwrap_or_default(); + if !selected.is_empty() { + self.last_appshot_chat = Some(selected.clone()); + } if selected != self.active_chat { self.suspend_file_images(cx); self.active_chat = selected; @@ -3377,18 +3443,24 @@ impl Shell { None => Empty.into_any_element(), } } - SettingsSection::Shortcuts => { + SettingsSection::Shortcuts | SettingsSection::Appshots => { if self.shortcuts_page.is_none() { let state = self.state.clone(); let keymap = self.settings.keymap.clone(); let escape_stops_active_agent = self.settings.escape_stops_active_agent; let composer_send_behavior = self.settings.composer_send_behavior; + let appshots_enabled = self.settings.appshots_enabled; + let appshot_sound_enabled = self.settings.appshot_sound_enabled; + let appshot_destination = self.settings.appshot_destination; let page = cx.new(|cx| { ShortcutsPage::new( state, keymap, escape_stops_active_agent, composer_send_behavior, + appshots_enabled, + appshot_sound_enabled, + appshot_destination, cx, ) }); @@ -3406,6 +3478,17 @@ impl Shell { ShortcutsEvent::ComposerSendBehaviorChanged(behavior) => { this.settings.composer_send_behavior = *behavior; } + ShortcutsEvent::AppshotsChanged { + enabled, + sound_enabled, + destination, + } => { + this.settings.appshots_enabled = *enabled; + this.settings.appshot_sound_enabled = *sound_enabled; + crate::appshots::set_capture_sound_enabled(*sound_enabled); + this.settings.appshot_destination = *destination; + crate::appshots::set_enabled(*enabled); + } } apply_keymap( cx, @@ -3419,7 +3502,12 @@ impl Shell { self.shortcuts_page = Some(page); } match &self.shortcuts_page { - Some(page) => page.clone().into_any_element(), + Some(page) => { + page.update(cx, |page, _| { + page.show_appshots(section == SettingsSection::Appshots) + }); + page.clone().into_any_element() + } None => Empty.into_any_element(), } } @@ -4690,6 +4778,7 @@ impl Shell { SettingsSection::Files => icons::FOLDER, SettingsSection::Notifications => icons::BELL, SettingsSection::Shortcuts => icons::KEYBOARD, + SettingsSection::Appshots => icons::MONITOR, SettingsSection::Archived => icons::ARCHIVE_MINIMALISTIC, }; // Match the user's dragged sidebar width — the pane container clips to @@ -4717,43 +4806,54 @@ impl Shell { .text_color(theme.text_muted.opacity(0.6)) .child(SharedString::from("Settings")), ) - .child(div().flex().flex_col().gap(px(2.0)).children( - SettingsSection::ALL.into_iter().map(|item| { - let selected = item == section; - div() - .id(SharedString::from(format!("settings-nav-{}", item.label()))) - .flex() - .flex_row() - .items_center() - .gap(px(8.0)) - .rounded(px(8.0)) - .px(px(Theme::SPACE_SM)) - .py(px(6.0)) - .text_size(crate::typography::ui_rems(13.0)) - .when(selected, |el| { - // Same tokens as the main sidebar's session - // rows — the two sidebars must feel alike. - el.bg(crate::theme::glass_selected_bg()) - .font_weight(gpui::FontWeight::MEDIUM) - }) - .text_color(if selected { - theme.text - } else { - theme.text_muted + .child( + div().flex().flex_col().gap(px(2.0)).children( + SettingsSection::ALL + .into_iter() + .filter(|item| { + *item != SettingsSection::Appshots + || crate::appshots::is_desktop() }) - .cursor_pointer() - .hover(|s| s.bg(theme.glass_hover()).text_color(theme.text)) - .on_click( - cx.listener(move |this, _, _, cx| this.open_settings(item, cx)), - ) - .child( - icon(section_icon(item)) - .size(px(16.0)) - .text_color(theme.text_muted), - ) - .child(SharedString::from(item.label())) - }), - )), + .map(|item| { + let selected = item == section; + div() + .id(SharedString::from(format!( + "settings-nav-{}", + item.label() + ))) + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .rounded(px(8.0)) + .px(px(Theme::SPACE_SM)) + .py(px(6.0)) + .text_size(crate::typography::ui_rems(13.0)) + .when(selected, |el| { + // Same tokens as the main sidebar's session + // rows — the two sidebars must feel alike. + el.bg(crate::theme::glass_selected_bg()) + .font_weight(gpui::FontWeight::MEDIUM) + }) + .text_color(if selected { + theme.text + } else { + theme.text_muted + }) + .cursor_pointer() + .hover(|s| s.bg(theme.glass_hover()).text_color(theme.text)) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_settings(item, cx) + })) + .child( + icon(section_icon(item)) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(SharedString::from(item.label())) + }), + ), + ), ) // Back pinned to the bottom (zeron settings-sidebar.tsx). .child( @@ -6600,6 +6700,7 @@ impl Shell { let _ = (text, border); let has_selection = self.state.read(cx).selected_chat.is_some(); let has_spaces = !self.state.read(cx).spaces.is_empty(); + let has_appshots = !self.composer.read(cx).staged_appshots().is_empty(); let no_project = self.state.read(cx).no_project; // Content outlet: selected chat → transcript; nothing selected → a @@ -6806,7 +6907,9 @@ impl Shell { .inset_0(), ) .child(status) - .when(has_spaces, |el| el.child(self.composer.clone())) + .when(has_spaces || has_appshots, |el| { + el.child(self.composer.clone()) + }) .child(self.render_terminal_container(cx)) }) .when(file_drag_active, |el| { @@ -10000,6 +10103,80 @@ mod exit_regressions { use super::*; use gpui::{AppContext, TestAppContext}; + #[gpui::test] + fn appshot_destinations_retain_last_session_and_use_new_canvas_defaults( + cx: &mut TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + settings::init(settings::UiSettings::default(), dir.path(), cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + window + .update(cx, |shell, window, cx| { + shell.state.update(cx, |state, cx| { + state.spaces = ["a", "b"] + .into_iter() + .map(|id| { + serde_json::from_value(serde_json::json!({ + "id": id, "deviceId": "local", "path": "/tmp", "gitDetected": false, + "createdAt": Utc::now(), + })) + .unwrap() + }) + .collect(); + state.chats = + vec![serde_json::from_value(serde_json::json!({ + "id": "last", "deviceId": "local", "spaceId": "a", "archived": false, + "createdAt": Utc::now(), + })).unwrap()]; + state.select_chat(Some("last".into()), cx); + }); + shell.on_state_changed(&shell.state.clone(), cx); + shell.settings.space_filter = Some("b".into()); + shell.open_new_session(cx); + shell.on_state_changed(&shell.state.clone(), cx); + assert!(shell.active_chat.is_empty()); + shell.settings.appshot_destination = + crate::appshots::AppshotDestination::LastSession; + shell.receive_appshot(crate::appshots::tests::shot(), window, cx); + assert_eq!(shell.state.read(cx).selected_chat.as_deref(), Some("last")); + assert_eq!(shell.composer.read(cx).appshots["last"].len(), 1); + shell.settings.appshot_destination = + crate::appshots::AppshotDestination::NewSession; + shell.receive_appshot(crate::appshots::tests::shot(), window, cx); + assert!(shell.state.read(cx).selected_chat.is_none()); + assert_eq!(shell.state.read(cx).selected_space.as_deref(), Some("b")); + assert_eq!(shell.composer.read(cx).appshots[""].len(), 1); + }) + .unwrap(); + } + #[gpui::test] fn pane_geometry_uses_one_animation_time_per_frame(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); @@ -10775,3 +10952,26 @@ mod shortcut_focus_regressions { } } } + +/// Native visual QA uses the production shell with isolated fixture data. +#[cfg(feature = "appshots-fixture")] +impl Shell { + pub fn fixture_appshots_settings(&mut self, open: bool, cx: &mut Context) { + if open { + self.open_settings(SettingsSection::Appshots, cx); + } else { + self.close_settings(cx); + } + } + pub fn fixture_appshots_composer(&self) -> Entity { + self.composer.clone() + } + pub fn fixture_appshots_sidebar(&mut self, collapsed: bool, cx: &mut Context) { + self.settings.sidebar_collapsed = collapsed; + cx.notify(); + } + pub fn fixture_appshots_transcript_start(&self, cx: &mut Context) { + self.transcript + .update(cx, |t, cx| t.fixture_appshots_start(cx)); + } +} diff --git a/crates/ui/src/sound.rs b/crates/ui/src/sound.rs index d7374bd9b..371c4ae6a 100644 --- a/crates/ui/src/sound.rs +++ b/crates/ui/src/sound.rs @@ -1,10 +1,11 @@ //! Session notification sounds — the herdr approach (state-transition chimes //! played through the platform's own audio CLI, zero Rust audio deps): //! -//! - two short chimes embedded in the binary (`assets/sounds/*.wav`, synthesized +//! - short chimes embedded in the binary (`assets/sounds/*.wav`, synthesized //! in-repo — no external assets): **done** (run finished) and **request** -//! (agent is asking a question); -//! - playback = write to a temp file, hand it to the system player on a +//! (agent is asking a question), plus an **Appshot** confirmation cue; +//! - macOS Appshots use a preloaded native player; other cues write to a +//! temp file and use the system player on a //! background thread: `afplay` (macOS), PowerShell `Media.SoundPlayer` //! (Windows), first of `paplay`/`pw-play`/`aplay`/`ffplay`/`mpv` (Linux — //! WAV, so even bare ALSA `aplay` decodes it); @@ -18,6 +19,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; const DISABLE_ENV: &str = "ZERON_DISABLE_SOUND"; static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); +#[cfg(any(target_os = "macos", target_os = "linux"))] +static SOUND_APPSHOT: &[u8] = include_bytes!("../assets/sounds/appshot.wav"); + static SOUND_DONE: &[u8] = include_bytes!("../assets/sounds/done.wav"); static SOUND_REQUEST: &[u8] = include_bytes!("../assets/sounds/request.wav"); @@ -33,16 +37,138 @@ pub enum Sound { /// Play a chime on a background thread. Silently a no-op when disabled or no /// player is available. pub fn play(sound: Sound) { + let data = match sound { + Sound::Done => SOUND_DONE, + Sound::Request => SOUND_REQUEST, + }; + play_in_background(data); +} + +/// Confirm captured pixels with a soft shutter and clear chime. +/// The caller honors the dedicated capture sound setting. +pub fn play_appshot() { + if std::env::var_os(DISABLE_ENV).is_some() { + return; + } + #[cfg(target_os = "macos")] + if macos_appshot::play() { + return; + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + play_in_background(SOUND_APPSHOT); +} + +/// Load the macOS capture cue before the first capture, without playing it. +pub fn prepare_appshot() { + #[cfg(target_os = "macos")] + if std::env::var_os(DISABLE_ENV).is_none() { + macos_appshot::sender(); + } +} + +#[cfg(target_os = "macos")] +mod macos_appshot { + use objc::rc::{StrongPtr, autoreleasepool}; + use objc::runtime::Object; + use objc::{class, msg_send, sel, sel_impl}; + use std::sync::{OnceLock, mpsc}; + + #[link(name = "AVFoundation", kind = "framework")] + unsafe extern "C" {} + + // Keep native objects on one worker. A bounded mailbox avoids a backlog of + // stale confirmations when captures arrive in a burst. + pub(super) fn sender() -> Option<&'static mpsc::SyncSender<()>> { + static SENDER: OnceLock>> = OnceLock::new(); + SENDER + .get_or_init(|| { + let (tx, rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("appshot-audio".into()) + .spawn(move || { + let player = autoreleasepool(load); + while rx.recv().is_ok() { + if std::env::var_os(super::DISABLE_ENV).is_some() { + continue; + } + let started = std::time::Instant::now(); + let played = player.as_ref().is_some_and(|player| { + autoreleasepool(|| unsafe { + // Pause preserves prepared audio resources; stop would discard them. + let _: () = msg_send![**player, pause]; + let _: () = msg_send![**player, setCurrentTime: 0.0_f64]; + let played: bool = msg_send![**player, play]; + played + }) + }); + tracing::debug!( + elapsed_ms = started.elapsed().as_millis(), + played, + "Appshot native playback requested" + ); + if !played { + super::play_in_background(super::SOUND_APPSHOT); + } + } + }) + .ok() + .map(|_| tx) + }) + .as_ref() + } + + fn load() -> Option { + let player = decode()?; + let ready: bool = unsafe { msg_send![*player, prepareToPlay] }; + if !ready { + tracing::warn!("Could not prepare native Appshot audio; using system player"); + return None; + } + Some(player) + } + + fn decode() -> Option { + unsafe { + let data: *mut Object = msg_send![class!(NSData), + dataWithBytes: super::SOUND_APPSHOT.as_ptr() + length: super::SOUND_APPSHOT.len()]; + let allocated: *mut Object = msg_send![class!(AVAudioPlayer), alloc]; + let mut error: *mut Object = std::ptr::null_mut(); + let player: *mut Object = msg_send![allocated, initWithData: data error: &mut error]; + if player.is_null() { + tracing::warn!("Could not decode native Appshot audio; using system player"); + return None; + } + Some(StrongPtr::new(player)) + } + } + + #[test] + fn embedded_appshot_decodes_natively_without_playback() { + autoreleasepool(|| { + let player = decode().expect("embedded Appshot should decode in AVAudioPlayer"); + let duration: f64 = unsafe { msg_send![*player, duration] }; + let playing: bool = unsafe { msg_send![*player, isPlaying] }; + assert!((duration - 0.67).abs() < 0.001); + assert!(!playing); + }); + } + + pub(super) fn play() -> bool { + match sender().map(|tx| tx.try_send(())) { + Some(Ok(()) | Err(mpsc::TrySendError::Full(()))) => true, + _ => false, + } + } +} + +fn play_in_background(data: &'static [u8]) { if std::env::var_os(DISABLE_ENV).is_some() { return; } std::thread::spawn(move || { - let data = match sound { - Sound::Done => SOUND_DONE, - Sound::Request => SOUND_REQUEST, - }; if let Err(err) = play_bytes(data) { - tracing::debug!(?sound, error = %err, "notification sound playback failed"); + tracing::debug!(error = %err, "sound playback failed"); } }); } @@ -259,7 +385,12 @@ mod tests { #[test] fn embedded_chimes_are_wav() { - for data in [SOUND_DONE, SOUND_REQUEST] { + for data in [ + SOUND_DONE, + SOUND_REQUEST, + #[cfg(any(target_os = "macos", target_os = "linux"))] + SOUND_APPSHOT, + ] { assert!(data.len() > 1000); assert_eq!(&data[..4], b"RIFF"); assert_eq!(&data[8..12], b"WAVE"); diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index 7b504a30d..8a9ef3b6c 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -4136,3 +4136,11 @@ mod tests { assert!(s.send_undelivered("c-remote", now)); } } + +#[cfg(feature = "appshots-fixture")] +impl AppState { + /// Keep fixture documents deterministic while using the real attachment RPC. + pub fn fixture_attachment_engine(&mut self, engine: EngineHandle) { + self.engine = Some(engine); + } +} diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index c70fceef3..87e7e2fb9 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -4514,6 +4514,7 @@ impl Transcript { &mut self, row_id: &SharedString, atts: &[crate::attachments::UserImageAttachment], + _window: &Window, cx: &mut Context, ) -> AnyElement { use crate::attachments::AttachmentSnapshot; @@ -4561,6 +4562,151 @@ impl Transcript { .then(|| self.state.read(cx).upload_progress_percent()) .flatten() }); + if let Some(appshot) = &att.appshot { + let has_image = matches!(&state, AttachmentSnapshot::Loaded(_)); + let theme = Theme::of(cx).clone(); + let accent = theme.accent; + let width = 240.0; + let mut card = div() + .id(SharedString::from(format!("{row_id}-appshot-{aix}"))) + .w(px(width)) + .max_w_full() + .flex_none() + .flex() + .flex_col() + .items_center() + .rounded(px(14.0)) + .p(px(8.0)) + .gap(px(6.0)) + .hover(|style| style.bg(crate::theme::ink(0.045))); + let image_frame = div() + .w_full() + .h(px(128.0)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(6.0)) + .overflow_hidden(); + card = match state { + AttachmentSnapshot::Loaded(image) => { + let preview = + crate::attachments::PreviewImage::new(image.name, image.image.clone()); + card.role(gpui::Role::Button) + .aria_label(format!( + "Preview {} Appshot: {}", + appshot.app_name, + appshot.title() + )) + .tab_index(0) + .cursor_pointer() + .focus_visible(move |style| style.border_2().border_color(accent)) + .on_click(cx.listener(move |this, _, window, cx| { + this.attachment_preview_return_focus = window.focused(cx); + preview.viewer.reset(); + this.attachment_preview = Some(preview.clone()); + window.focus(&this.attachment_preview_focus, cx); + cx.notify(); + })) + .child( + image_frame.child(crate::edge_fade::edge_faded( + 32.0, + false, + true, + img(image.image) + .w_full() + .h(px(126.0)) + .rounded(px(5.0)) + .object_fit(ObjectFit::Contain), + )), + ) + } + AttachmentSnapshot::Loading => card.child( + image_frame.child( + div() + .text_size(px(11.0)) + .text_color(theme.text_muted) + .child(if sending { + "Uploading Appshot…" + } else { + "Loading Appshot…" + }), + ), + ), + AttachmentSnapshot::Error { .. } => card.child( + image_frame.child( + div() + .text_size(px(11.0)) + .text_color(theme.text_muted) + .child(if sending { + "Uploading Appshot…" + } else { + "Appshot unavailable" + }), + ), + ), + }; + card = card + .child( + div() + .flex() + .items_center() + .gap(px(5.0)) + .max_w_full() + .child( + div() + .size(px(24.0)) + .flex_none() + .rounded(px(6.0)) + .flex() + .items_center() + .justify_center() + .child(match crate::appshots::presentation_icon(appshot) { + Some(icon) => img(icon) + .size(px(24.0)) + .object_fit(ObjectFit::Contain) + .into_any_element(), + None => crate::icons::icon(crate::icons::MONITOR) + .size(px(15.0)) + .text_color(theme.text_muted) + .into_any_element(), + }), + ) + .child( + div() + .truncate() + .text_size(px(11.0)) + .text_color(theme.text_muted) + .child(SharedString::from(format!( + "{} · Appshot", + appshot.app_name + ))), + ), + ) + .child( + div() + .w_full() + .truncate() + .text_center() + .text_size(px(12.0)) + .text_color(theme.text) + .child(SharedString::from(appshot.title().to_string())), + ); + if sending && (has_image || uploading.is_some()) { + card = card.child( + div() + .text_size(px(11.0)) + .text_color(theme.text_muted) + .child(SharedString::from( + uploading + .map(|pct| format!("Uploading {pct}%")) + .unwrap_or_else(|| "Uploading…".into()), + )), + ); + } + strip = strip.child(card); + continue; + } let frame = div() .flex_none() .w(px(ATT_THUMB_W)) @@ -4871,7 +5017,12 @@ impl Transcript { // HStack); image-only sends show no bubble at all. let mut column = div().w_full().flex().flex_col(); if !attachments.is_empty() { - column = column.child(self.render_user_attachments(&row.id, &attachments, cx)); + column = column.child(self.render_user_attachments( + &row.id, + &attachments, + window, + cx, + )); } if !badges.is_empty() { column = column.child( @@ -10030,3 +10181,11 @@ mod tests { assert!(rows_for_entry(&entry, false, &mut parse).is_empty()); } } + +#[cfg(feature = "appshots-fixture")] +impl Transcript { + pub fn fixture_appshots_start(&mut self, cx: &mut Context) { + self.list.scroll_to(gpui::ListOffset::default()); + cx.notify(); + } +} diff --git a/dist/macos/Info-dev.plist b/dist/macos/Info-dev.plist new file mode 100644 index 000000000..e660ad991 --- /dev/null +++ b/dist/macos/Info-dev.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Zeron Dev + CFBundleExecutable + zeron + CFBundleIconFile + zeron.icns + CFBundleIdentifier + sh.zeron.app.dev + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Zeron Dev + CFBundlePackageType + APPL + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 12.0 + + LSEnvironment + + ZERON_DATA_DIR + __DATA_DIR__ + ZERON_IPC_PORT + __IPC_PORT__ + + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/docs/appshots.md b/docs/appshots.md new file mode 100644 index 000000000..6c0acef7a --- /dev/null +++ b/docs/appshots.md @@ -0,0 +1,74 @@ +# Appshots + +Appshots capture an application window on the desktop and stage it in a composer +for review before sending. Enable the feature in **Settings → Appshots**, choose +a global shortcut and destination, and optionally enable capture sound. Invoke +the shortcut while another application is focused. Invoking it inside Zeron is +ignored. No message is sent automatically. + +## Platforms and permissions + +- macOS uses ScreenCaptureKit with a bounded CoreGraphics fallback. Screen + Recording permits pixels; optional Accessibility permission adds application + text. The source accessibility window is retained before asynchronous capture. +- Linux supports X11 capture and compatible screenshot portals. Portal capture + requires an advertised window target; cancellation ends the operation. Once a + portal request starts, failure does not silently fall through to X11 capture. + X11 text enrichment requires matching native window, process, title, and + retained AT-SPI object identity. Portal captures omit accessibility text because + the selected native window cannot currently be verified. +- iOS displays desktop Appshots received through the existing attachment + transport. It does not capture other iOS applications. Windows capture is not + implemented. + +Optional accessibility collection has a 900 ms budget. Linux races enrichment +against the overall deadline; macOS applies the remaining timeout to each queried +AX element. If enrichment is unavailable, the screenshot remains usable. +Native fallback dimensions are checked before acquisition, actual dimensions +before encoding, and encoded bytes before copying into Rust. + +## Presentation and delivery + +Composer and transcript cards identify the application and window, show a +contained preview, and open the full image on activation. macOS uses the locally +installed application icon where available; other clients use a source icon. +Accessibility text is agent context, not visible message text. + +Queued images use small cropped thumbnails. Narrow desktop windows show one +thumbnail with an additional-image count; desktop actions retain their fixed +slots when modifier hints appear. iOS uses one thumbnail, an image gallery, and +44-point edit/send/menu targets. Its queue scrolls after three rows. + +Visible desktop queue thumbnails have their own bounded lifecycle, independent +of eviction from the full-image cache. Full images load when opened. Failed +previews require user activation to retry. iOS retains prepared thumbnails while +their rows are realized. Queue editing preserves the original Appshot context +and attachment references on both clients. + +Capture and settings belong to the viewer's UI. Sending uses the existing +attachment upload/host acknowledgement flow and prompt context; no RPC or +persistent schema migration is introduced. Existing settings deserialize with +backward-compatible defaults. Appshots setup controls support Tab/Shift-Tab, +Enter/Space, accessible names and state, and visible keyboard focus. + +## Verification and visual fixtures + +The focused final suites passed on macOS (829 UI tests), native Linux (849 UI +tests), and iOS Simulator (107 unit tests plus one Appshots UI flow). App checks +passed on macOS and Linux. Native capture permission dialogs, real desktop +focus changes, interactive Linux portals, VoiceOver, and physical remote-device +delivery still require a live release pass; the fixtures do not establish them. + +The opt-in `appshots-fixture` Rust example renders isolated native GPUI frames +from supplied PNG fixtures. Run it with an output directory and a directory +containing `wide.png`, `tall.png`, and `square.png` after building with +`cargo build -p zeron-ui --example appshots-fixture --features appshots-fixture`. +It uses a temporary data directory and an ephemeral IPC listener. iOS's +`-demo -appshots` fixture supplies neutral images; `AppshotUITests` verifies +portrait/landscape presentation, image opening, the gallery, and queue actions. +Neither fixture sends an agent prompt. + +The initial whole-workspace review reported four failures, three reproduced on +an upstream baseline, plus existing upstream formatting differences. Focused +passing suites do not imply the global gate is clean. See the accompanying +review resolution and logs when preparing publication. diff --git a/docs/plans/2026-08-20-appshots-macos.md b/docs/plans/2026-08-20-appshots-macos.md new file mode 100644 index 000000000..9b247ae19 --- /dev/null +++ b/docs/plans/2026-08-20-appshots-macos.md @@ -0,0 +1,68 @@ +# macOS Appshots implementation plan + +> Historical design notes. The implemented behavior and current validation are +> documented in [Appshots](../appshots.md); proposals below may be superseded. + +## Behavior + +Implement a macOS-only global shortcut that captures the frontmost application +window before Zeron activates, collects bounded Accessibility context, routes +the result to the current or new-session composer, and stages it for review and +send. The paperclip and ordinary image intake remain unchanged. + +## Files and boundaries + +- `crates/ui/src/appshots.rs`: platform-neutral capture model, prompt + serialization, routing helpers, and macOS service boundary. +- `crates/ui/src/appshots/macos.rs`: ScreenCaptureKit capture with a bounded + CoreGraphics fallback for older macOS versions, Accessibility traversal, + permission probes, and Carbon global-hotkey registration. +- `crates/ui/src/lib.rs`: initialize and retain the capture service; deliver + captures to the main window without capturing Zeron itself. +- `crates/ui/src/settings.rs`: persist Appshots enabled/destination/first-use + state as device-local settings. +- `crates/ui/src/settings/shortcuts.rs`: expose Appshots enablement, + destination, permission status, and the fixed macOS shortcut alongside app + shortcuts. +- `crates/ui/src/shell.rs` and `crates/ui/src/shell/tabs.rs`: route a completed + capture to the selected composer or new-session canvas and restore chat UI. +- `crates/ui/src/composer.rs`: own Appshots per draft, render distinct staged + cards, remove/preview them, include them in send eligibility, serialize + semantic context, and restore them after failed sends. +- `crates/ui/src/attachments.rs`: construct a staged PNG from native bytes and + keep existing upload/path behavior authoritative. +- `crates/ui/Cargo.toml`: macOS framework/FFI dependencies only where needed. +- `docs/research/appshots.md` and `docs/toolcraft/agent-worklog.md`: record any + implementation decisions that differ from the exploration. + +No schema migration, timeline, layers, renderer replacement, or export path is +required. Screenshot files continue through `RunRequest.attachments`; the +semantic Appshot block is generated into `RunRequest.prompt` at the UI send +boundary so existing local and remote attachment delivery remains intact. + +## Verification + +- Unit tests for XML escaping, context limits, image/path association, + Appshot-only send eligibility, and destination decisions. +- Existing settings round-trip tests extended for backward-compatible defaults. +- Composer send/failure tests extended where practical for Appshot state. +- `cargo fmt --all -- --check`. +- `cargo check -p zeron-ui`. +- `cargo test -p zeron-ui`. +- A headed macOS smoke run with an isolated `ZERON_DATA_DIR` to validate global + shortcut delivery, permission recovery, frontmost-window ordering, + screenshot preview, accessibility degradation, and local send. Remote-path + behavior is covered by the existing queued attachment transport plus focused + serialization/path-rewrite tests. + +## Performance and safety limits + +- Capture work runs off the GPUI render path. +- Accessibility traversal is bounded by depth, node count, text size, and a + short deadline; partial output is marked truncated. +- Screenshot dimensions and encoded bytes reuse attachment limits. +- ScreenCaptureKit output is Retina-scaled and capped to 4096 px on its longest + edge before entering the attachment pipeline. +- Accessibility payloads are never logged and secure text values are skipped. +- Global callbacks enqueue work only; Objective-C/CoreFoundation ownership is + contained in the macOS module. diff --git a/docs/plans/2026-08-20-appshots-ux-refinement.md b/docs/plans/2026-08-20-appshots-ux-refinement.md new file mode 100644 index 000000000..3a63c9e91 --- /dev/null +++ b/docs/plans/2026-08-20-appshots-ux-refinement.md @@ -0,0 +1,32 @@ +# Appshots UX refinement implementation plan + +> Historical design notes. The implemented behavior and current validation are +> documented in [Appshots](../appshots.md); proposals below may be superseded. + +1. Extend `crates/ui/src/appshots.rs` and `appshots/macos.rs` with optional + application-icon presentation data and separate Screen Recording and + Accessibility permission requests. +2. Replace the vertical Appshot rows in `crates/ui/src/composer.rs` with a + fixed-height horizontal visual tray modeled on the proven Codex treatment; + preserve preview, removal, upload, routing, and prompt serialization. +3. Rework `crates/ui/src/settings/shortcuts.rs` into an explicit permission + checklist. Do not request permissions merely because the feature toggle was + enabled, and label Accessibility as optional. +4. Update pure layout tests and permission-facing copy. Validate with + `cargo test -p zeron-ui --lib`, `cargo check -p zeron-ui`, + `cargo build -p zeron`, and `git diff --check`. + +## Composer visual normalization follow-up + +1. Record the PNG pixel dimensions in `crates/ui/src/appshots.rs` and + `appshots/macos.rs`, then compute an explicit contained display size for a + shared 208×132 image box in `crates/ui/src/composer.rs`. +2. Make both the visual stage and the complete Appshot tile clipping + boundaries, bottom-align every aspect ratio, and apply a native bottom + edge-fade to the screenshot before layering the application icon. +3. Increase the icon-to-label spacing while keeping one fixed-height, + horizontally scrolling tray. Capture, removal, preview, persistence, + transport, and permission controls remain unchanged. +4. Add dimension-parser and landscape/portrait/square sizing tests, run the + `zeron-ui` test suite and macOS build checks, then inspect the result in the + signed `Zeron Dev.app`. diff --git a/docs/plans/2026-08-22-appshots-windows-linux.md b/docs/plans/2026-08-22-appshots-windows-linux.md new file mode 100644 index 000000000..dec69ffcf --- /dev/null +++ b/docs/plans/2026-08-22-appshots-windows-linux.md @@ -0,0 +1,50 @@ +# Windows and Linux Appshots implementation plan + +> Historical design notes. The implemented behavior and current validation are +> documented in [Appshots](../appshots.md); proposals below may be superseded. + +## Behavior and architecture + +1. Refactor `crates/ui/src/appshots.rs` behind an `AppshotBackend` trait and a + platform-neutral capability model. Shortcut readiness, active-window + capture, semantic text, and target selection are independent states; Linux + is selected at runtime as Wayland portal or X11. +2. Add `crates/ui/src/appshots/windows.rs` using `RegisterHotKey`, + `GetForegroundWindow`, Windows Graphics Capture, bounded UI Automation, + process metadata, and executable icons. Elevated/protected targets degrade + to screenshot-only or a concrete capture error; Zeron does not request + `UIAccess`. +3. Add `crates/ui/src/appshots/linux/{mod,portal,x11,atspi}.rs`. Wayland uses + the Screenshot portal's Active Window target when advertised, otherwise its + window picker; the Global Shortcuts portal is preferred. On X11, an + advertised portal Active Window target is also preferred before an EWMH + active window, passive key grab, direct drawable capture, window metadata, + and `_NET_WM_ICON`. AT-SPI enrichment is bounded and optional for both. +4. Add a Linux-only `zeron appshot` activation command and local activation + socket for desktops without the Global Shortcuts portal. It activates the + already-running headed viewport; it never starts a headless capture. +5. Rework `crates/ui/src/settings/shortcuts.rs` to render capabilities rather + than macOS permission assumptions. macOS retains explicit Screen Recording + and Accessibility actions; Windows shows ready states; Wayland explains + portal selection/setup; X11 reports native readiness. + +## Unchanged surfaces + +- Composer layout, routing, uploads, prompt serialization, remote delivery, + destination persistence, and staged-review semantics stay authoritative. +- No schema migration, timeline, layer model, export path, or paperclip change. +- Screenshot bytes remain ordinary staged PNG attachments; platform metadata + remains presentation/prompt context. + +## Verification + +- Pure tests for capability copy, PNG/pixel conversion, bounds, X11 property + parsing, and activation-path derivation. +- `cargo fmt --all -- --check`, `cargo test -p zeron-ui --lib`, + `cargo check -p zeron-ui`, and `cargo check -p zeron` on macOS. +- Target checks for Windows and Linux where the local toolchain/sysroot allows; + otherwise record the exact missing machine dependency and keep all platform + code target-gated for CI/native validation. +- Native release gates: Windows 10 1903+/11 (normal/elevated/protected and DPI + matrix), GNOME/KDE Wayland with portal v2/v3 behavior, and X11 with obscured, + fullscreen, and multi-monitor windows. diff --git a/docs/research/appshots.md b/docs/research/appshots.md new file mode 100644 index 000000000..75ea0858b --- /dev/null +++ b/docs/research/appshots.md @@ -0,0 +1,557 @@ +# Appshots for Zeron + +> Historical design notes. The implemented behavior and current validation are +> documented in [Appshots](../appshots.md); proposals below may be superseded. + +Status: implemented on the exploration branch; native validation ongoing + +Branch: `wip/appshots-exploration` + +Scope: desktop Zeron on macOS, Linux X11, and Linux Wayland + +## Summary + +An Appshot is a user-triggered capture of the frontmost application window. A +global keyboard shortcut works while Zeron is in the background, captures both +the visible window and machine-readable application context, then stages that +capture in a Zeron composer for review. It is not an attachment-menu action and +does not send a message automatically. + +The first useful release should support this vertical slice: + +1. The user invokes a global shortcut from any macOS application. +2. Zeron captures the frontmost window before activating itself. +3. Zeron opens the chosen composer and stages a screenshot plus accessibility + context. +4. The user can inspect, remove, annotate, and send the capture. +5. For a remotely hosted session, the existing queued-attachment path moves the + screenshot to the host while the semantic context rides in the prompt. + +## Reference behavior + +Inspection of Codex's installed desktop client shows that its Appshot feature: + +- registers a global shortcut (both Command keys on macOS by default); +- selects the frontmost application window without an app picker; +- captures the screenshot, application name, bundle identifier, window title, + and accessibility-derived text/tree, including off-screen content; +- stages the result rather than immediately sending it; +- supports automatic, last-chat, and new-chat destinations; +- serializes application metadata and accessibility content as structured + context while also attaching the image; +- requires Screen Recording and Accessibility permission on macOS. + +Codex's composer presents each capture as a 232×140 visual tile with the source +application icon over the screenshot, a concise title, hover removal, and one +horizontal attachment tray. Its native helper presents Screen Recording and +Accessibility as separate rows in one guided permission surface. + +## Product contract + +### Goal + +Make it effortless to give an agent rich context about the application the +user is currently looking at, without making the user save a screenshot, switch +to Zeron, attach a file, and manually copy otherwise invisible text. + +### Non-goals for the first release + +- Capturing an arbitrary rectangular screen region. +- Recording video or continuous screen state. +- Automatically submitting a captured window to an agent. +- Capturing from a headless or remote agent host. +- OCR as the primary source of semantic context. +- Continuous recording or bypassing platform capture consent. +- Replacing or changing the existing paperclip, paste, or drag-and-drop flows. + +### User-visible behavior + +- The shortcut operates while another application is focused. +- The frontmost eligible window at shortcut time is the capture target. +- Zeron never steals focus until the screenshot has been acquired. +- A staged Appshot is visually distinct from an ordinary image attachment. +- The staged tile shows a large contained screenshot, the source application + icon, and the window title when available. +- The user can preview the screenshot, remove the Appshot, and type an + instruction before sending. Semantic text remains attached without adding + technical character counts to the composer. +- A successful capture never sends on its own. +- A failed or permission-blocked capture presents a concrete recovery action. + +### Destination policy + +Three settings are proposed: + +- **Automatic**: use the visible/recent composer if it can accept input; + otherwise open a new-session draft. +- **Last session**: stage into the most recently active session composer, + restoring the window if necessary. +- **New session**: open the new-session canvas with the Appshot staged. The + existing space/device defaults remain authoritative. + +The recommended default is **Automatic**, with a conservative eligibility +rule: only reuse an existing composer if Zeron had an active session selected +recently and that session is still writable. Otherwise create a draft. The +exact recency threshold should be validated in use rather than copied blindly +from another product. + +## Control section inventory + +### Capture initiation + +- Enable or disable Appshots. +- Configure the global shortcut. +- Detect shortcut conflicts and registration failure. +- Show the currently registered shortcut in Settings. + +### Capture permissions + +- Explain why Screen Recording is needed. +- Explain that Accessibility is optional and adds off-screen application text. +- Never launch both permission prompts from the feature toggle. +- Request each permission from its own explicit user action. +- Open the relevant macOS Settings pane. +- Re-check permission state after the user returns. +- Allow screenshot-only capture whenever Screen Recording is granted; the + settings checklist, rather than every staged tile, communicates whether + semantic context is available. + +### Destination and draft + +- Choose Automatic, Last session, or New session. +- Restore or focus the Zeron window after capture. +- Stage the Appshot without submitting it. +- Preserve the draft if destination resolution or host connectivity is delayed. + +### Staged Appshot + +- Show source app icon/name and window title. +- Preview the screenshot. +- Show whether application text was captured and offer a disclosure view. +- Remove the Appshot. +- Send it with typed text or as an Appshot-only message. + +### Lifecycle and privacy + +- Explain which data will be sent. +- Delete abandoned temporary captures. +- Retain sent screenshots under the existing profile-scoped attachment rules. +- Never write accessibility text to logs. +- After successful staging, play a soft confirmation cue. Honor the + dedicated capture sound setting and `ZERON_DISABLE_SOUND`; failed captures stay silent. + +## Architecture + +### Placement + +Capture belongs to the headed UI side of Zeron, not the engine: + +```text +macOS global event / hotkey + | + v +native Appshot capture service + | + v +GPUI application coordinator -----> composer draft + | + v + existing attachment upload + | + local or remote session host +``` + +This boundary is required because the captured desktop belongs to the viewport +machine. A remote engine may be headless, may run on another operating system, +and must not receive local desktop permissions. + +### Native service + +Define a small platform-neutral Rust interface in the UI crate: + +```rust +trait AppshotCaptureService { + fn register_shortcut(&self, shortcut: GlobalShortcut) -> Result<()>; + fn permission_state(&self) -> AppshotPermissionState; + async fn capture_frontmost_window(&self) -> Result; +} +``` + +The implementation uses platform backends with independent capability states: + +```text +appshots/ +├── macos.rs +└── linux/ + ├── mod.rs + ├── portal.rs + ├── x11.rs + └── atspi.rs +``` + +Linux chooses its shortcut backend at runtime: Wayland uses XDG +Desktop Portal and X11 uses a passive key grab. Capture prefers the portal's +Active Window target wherever it is advertised, then uses the portal picker on +Wayland or EWMH plus direct drawable capture on X11. AT-SPI enrichment is +optional and independent on both Linux paths. Capability reporting +distinguishes ready, permission required, setup required, user selection, +checking, and unavailable. + +The macOS capture module is responsible for: + +- global shortcut detection; +- frontmost process and window identification; +- ScreenCaptureKit window capture; +- `AXUIElement` traversal and bounded serialization; +- permission probing and Settings deep links; +- returning capture results over a narrow IPC protocol. + +The implementation uses an in-process Rust/Objective-C bridge. TCC attribution +is kept correct in development by launching a separately signed `Zeron Dev.app` +through LaunchServices; the bundle also embeds its isolated data directory and +IPC port so privacy-driven relaunches cannot enter the personal instance. + +### Capture ordering + +Ordering is correctness-sensitive: + +1. Observe and identify the current frontmost window. +2. Capture its screenshot and accessibility snapshot. +3. Emit a completed capture to Zeron. +4. Resolve the destination. +5. activate/reveal Zeron and stage the result. + +Activating Zeron before steps 1-2 would capture Zeron itself. + +### Data model + +The composer needs a first-class object rather than treating the capture as an +undifferentiated image: + +```rust +struct CapturedAppshot { + id: String, + app_name: String, + bundle_identifier: Option, + window_title: Option, + accessibility: AccessibilitySnapshot, + screenshot: StagedAttachment, + captured_at: DateTime, +} + +struct AccessibilitySnapshot { + format_version: u32, + content: String, + truncated: bool, +} +``` + +`StagedAttachment` remains the screenshot carrier so decoding, thumbnails, +upload progress, queued transfers, transcript caching, and remote delivery are +reused. The enclosing Appshot supplies source identity and semantic context. + +Draft state should own Appshots next to ordinary staged attachments. This lets +the UI remove or inspect one coherently and prevents semantic context from +surviving after its screenshot is removed. + +### Prompt representation + +At send time, serialize Appshots as observed context and append ordinary image +paths through the existing attachment mechanism: + +```xml + + ...escaped, bounded accessibility snapshot... + +``` + +The prompt should explicitly tell the harness that this is untrusted content +observed in an application, not an instruction from the user. XML is only a +candidate wire representation; the important properties are escaping, version +stability, clear provenance, and an image/context association. + +For a first implementation the semantic block can be synthesized into +`RunRequest.prompt`, while `RunRequest.attachments` continues to carry the +screenshot path. That preserves compatibility with older engines and reuses +the existing pending-path rewriting. A dedicated protocol field becomes +worthwhile only if multiple consumers need structured Appshots before harness +dispatch or if transcript rendering must avoid parsing the prompt. + +### Remote delivery + +The viewer creates the capture bytes. On send: + +1. The composer allocates the usual upload identifier. +2. The screenshot follows queued attachment transfer to the chat's host. +3. Pending screenshot paths in both the image list and semantic block are + resolved by the host. +4. The harness receives an image content block plus the observed application + context. + +No Appshot-specific binary transport is required for the first version. + +## Security and privacy + +Accessibility content is high-risk input. It may contain secrets, invisible +controls, or prompt-injection text supplied by a website. The implementation +must: + +- label it as untrusted observed data; +- escape delimiters and reject malformed metadata; +- cap nodes, depth, per-node text, and total serialized bytes; +- prefer roles, labels, values, and useful document text over geometry noise; +- exclude secure text fields and password values; +- avoid logging payload content; +- keep the capture staged for user review before transmission; +- show degraded state when accessibility extraction is unavailable; +- apply existing profile and attachment isolation to the screenshot. + +The disclosure UI should summarize the amount and source of captured text. A +raw-tree inspection view is useful for trust and debugging but can follow the +first working slice. + +## Failure behavior + +| Failure | User outcome | +| --- | --- | +| Shortcut registration conflict | Setting shows conflict and capture remains disabled. | +| No eligible frontmost window | Non-blocking notice; no draft is changed. | +| Screen Recording denied | Permission explanation and direct recovery action. | +| Accessibility denied | Screenshot-only Appshot with a visible warning. | +| Accessibility traversal times out | Stage the screenshot with partial/truncated context. | +| Zeron window cannot be restored | Preserve capture in an inbox-like pending slot and notify. | +| Remote host is offline | Keep the draft; existing queued-transfer behavior applies on send. | +| Helper crashes | Restart lazily and report capture failure without affecting the engine. | + +## Persistence + +Settings are device-local because the shortcut and permissions describe a +specific desktop: + +- enabled; +- shortcut; +- destination policy; +- optional sound; +- first-use explanation completed. + +Unsent captures should initially live only in draft state and temporary files. +If Zeron already persists composer drafts, Appshot metadata and the screenshot +temporary-file contract must be persisted atomically; otherwise the first +slice should explicitly document that an application restart discards unsent +Appshots. + +No timeline, layer model, custom renderer, or export format is required. + +## Implementation slices + +### Slice 0: platform spike + +- Register and unregister a global shortcut. +- Capture frontmost window pixels without focusing Zeron. +- Read a bounded accessibility snapshot from Safari, Terminal, and a native + settings window. +- Compare helper-process and in-process implementations for TCC behavior, + packaging, latency, and crash isolation. +- Produce no permanent UI beyond diagnostic output. + +Exit criterion: a clear native boundary choice backed by signed development +build behavior. + +### Slice 1: local staged Appshot + +- Fixed default global shortcut. +- Screen Recording permission flow. +- Screenshot plus source app/window metadata. +- Automatic routing to current composer or new-session draft. +- Distinct staged Appshot card; preview and remove. +- Local session send through existing attachment upload. + +Exit criterion: shortcut in another app results in a reviewable, sendable +local Appshot without touching the paperclip flow. + +### Slice 2: semantic context + +- Accessibility permission and bounded tree extraction. +- Structured, escaped prompt serialization. +- Screenshot-only degradation and truncation indicators. +- Harness-level tests confirming image and context arrive together. + +Exit criterion: the agent can reason about visible and off-screen application +content, with provenance and prompt-injection framing. + +### Slice 3: remote and durable behavior + +- Remote-host send through pending attachment transfer. +- Destination setting and recent-composer eligibility rules. +- Draft/pending-capture recovery across window restoration failures. +- Full failure and offline coverage. + +### Slice 4: polish and additional platforms + +- Configurable shortcut, sound, and capture transition. +- Accessibility disclosure/inspection UI. +- Windows is outside this contribution: Zeron does not yet ship a Windows app. +- Wayland portal and X11 implementations with AT-SPI enrichment. + +## Verification strategy + +### Pure unit tests + +- destination resolution matrix; +- shortcut setting migration and conflict states; +- Appshot XML/structured serialization, escaping, and truncation; +- prompt attachment-path rewriting; +- removal keeps screenshot and semantic state coherent; +- accessibility redaction and bounded traversal. + +### Integration tests + +- native service result becomes composer state without submitting; +- local send supplies both image and semantic context; +- remote send resolves a pending screenshot path inside both prompt and + attachment list; +- permission denial degrades or blocks as specified; +- Zeron is not selected as the capture target due to activation ordering. + +### Manual macOS matrix + +- Safari with scrolled document content; +- Terminal with scrollback; +- Xcode or another complex native application; +- multi-window application and multiple displays; +- minimized, full-screen, and transient windows; +- Screen Recording only, Accessibility only, both denied, both granted; +- local session, remote online session, and remote offline session. + +## Open product decisions + +1. Should Automatic ever target a session whose agent is currently running, + where the eventual send becomes a steer? +2. Does a new-session Appshot use the existing last space/device immediately, + or pause on the canvas until the user confirms the destination? +3. How much extracted application text should be visible before send: a status + summary, a preview, or the full serialized snapshot? +4. Should one shortcut invocation capture only the frontmost window, or should + holding the shortcut open a window picker in a later release? + +## Recommended initial decisions + +- keep macOS as the reference experience while Linux matures behind + the same composer contract; +- screenshot-only degradation is allowed and clearly labeled; +- one shortcut invocation always captures the frontmost eligible window; +- no automatic submission; +- Automatic may stage into an idle or running selected session, but the user + still decides whether to Send or Steer; +- a new-session capture opens the canvas and preserves existing space/device + defaults without creating a chat until send; +- show source identity as an application icon and window title in a compact + visual tile; keep permission/semantic status in Settings; +- use a temporary structured prompt representation before extending the RPC or + document schemas. + +## Capture feedback follow-up (2026-09-09) + +macOS window selection excludes offscreen, transparent, and tiny helper windows. +When Accessibility supplies the focused window geometry, the screenshot target +must match that window; it is never replaced by a larger background document. +Without Accessibility, front-to-back visible-window selection remains available +for screenshot-only capture. The chosen window ID stays fixed during capture. + +After capture and staging, a bundled macOS app requests foreground activation +through `NSWorkspace.openApplication`, with activation enabled and new-instance +creation disabled. Bare development executables retain GPUI window activation. +This runs after pixels are captured so Zeron cannot replace the source image. + +Successful capture plays the original 0.67 s stereo rounded shutter and blended confirmation generated by +`scripts/generate-appshot-sound.py`. The dedicated capture sound preference and environment +kill switch apply. Capture or staging failures do not play a success cue. + +API references: +- [Window visibility](https://developer.apple.com/documentation/coregraphics/kcgwindowisonscreen) +- [Accessibility geometry decoding](https://developer.apple.com/documentation/applicationservices/1462933-axvaluegetvalue) +- [Application activation](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)) + +Composer tiles follow the captured window's aspect ratio at a shared 132 px +preview height. Portrait captures take less horizontal space; wider captures +expand up to a 320 px image cap and the current composer's available width. +Extreme panoramas scale down without cropping. Icons and titles keep common +baselines, and overflow remains horizontally scrollable. + +### Independent capture sound + +Settings → Shortcuts → Appshots includes a Capture sound toggle, persisted as +`appshotSoundEnabled`. It controls captures independently of session notification +sounds. On first load, existing settings inherit their previous `soundEnabled` +value; explicit capture preferences take precedence thereafter. The global +`ZERON_DISABLE_SOUND` override still mutes playback. The synthesized cue adapts the rounded sound-family auditions: three smooth shutter clicks 70 ms apart, then one blended C4–F4 resonance with a quiet C5 overtone. The notes share a softened attack rather than playing in two phases. The cue lasts 0.67 seconds and peaks at approximately -18.7 dBFS. Its quiet harmonic overtones decay quickly; there is no noise bed or resonant impact tail, and playback gain is never normalized upward. + +### Desktop-only controls and customizable shortcut + +The capture service starts only on macOS and Linux. Unsupported builds +omit the Appshots settings section and do not serialize its capture, sound, +destination or shortcut preferences. The iOS app has no Appshot capture controls. + +The default remains Control–Option–Space on macOS and Control–Alt–Space on +Linux. `keymap.captureAppshot` uses the existing recorder, conflict checks, +per-row Reset and Restore defaults. Recording intercepts key events before bound +actions, suspends capture, and restores normal behavior on acceptance, rejection, +Escape, blur or page release. Changes apply to native registration without restart. + +macOS resolves letters against the active ASCII-capable keyboard layout and +replaces its Carbon hotkey. X11 replaces +its passive grabs and cleans up partial registration failures. Wayland closes the +old portal session before rebinding with the preferred trigger; version 2 portals +can present their configuration UI for a changed preference. The compositor owns +the final binding, which the UI explicitly labels as a preference on Wayland. + +### Capture feedback and scope follow-up + +macOS acknowledges the capture immediately after staging its PNG, before the +optional accessibility traversal (which has a 900 ms deadline). The sound confirms +captured pixels; composer staging may still report its attachment-budget limit. +Capture failures before staging stay silent. Linux acknowledges successful capture +after its backend returns. The independent sound toggle and global mute still apply. + +Fresh composer cards fade in and settle upward by 8 px over 240 ms using the shared +motion curve. Entity-owned timestamps prevent replay when switching away and back; +restored cards do not animate, and reduced motion bypasses the entrance. + +Removed the experimental Windows backend, its Appshots-only dependencies and its +platform support claims. Existing unrelated Windows code is preserved. Linux native +portal, X11 and AT-SPI behavior still needs native validation. + +### Transparent capture-surface padding + +Some Chrome captures contain fully transparent outer columns in the PNG backing +surface. PNG dimensions alone then reserve a wider card than the visible window; +changing object-fit or thumbnail sizing cannot remove that invisible image area. +Before staging, Appshots now trim only fully transparent outer rows and columns, +preserve retained pixel values and color profiles, then derive dimensions from +the normalized PNG. Opaque captures remain byte-identical. Restored queue Appshots +apply the same normalization while preserving attachment identity and context. +Ordinary file attachments are not modified. + +Regression coverage includes right-side backing-surface padding, rounded corners, +interior transparency, alpha=1 edges, 16-bit samples, unchanged opaque margins and +queue restoration. Saved Chrome capture validation removed 388 transparent columns +from a 3024×1654 PNG without changing any retained RGBA pixel or its ICC profile. +A Discord comparison stayed byte-identical. Existing uploaded originals are not +rewritten; older captures are normalized when restored for editing. + + +### Native capture-audio startup + +On macOS, enabling capture sound preloads the embedded cue in AVAudioPlayer on +one dedicated worker. Successful captures request playback through a bounded +mailbox, avoiding a temporary-file write and afplay process startup per capture. +Replaying uses pause and rewind, preserving prepared resources. Native decode or +playback failure falls back to the existing system player; global mute still applies. + +Success feedback still follows image staging, including transparent-padding +processing, so failed staging remains silent. Debug logs split capture acquisition, +staging and native playback-request time. These timings exclude physical output +latency (for example Bluetooth buffering); native listening remains a separate check. diff --git a/docs/screenshots/appshots/README.md b/docs/screenshots/appshots/README.md new file mode 100644 index 000000000..dd8e3565a --- /dev/null +++ b/docs/screenshots/appshots/README.md @@ -0,0 +1,9 @@ +# Appshots screenshot evidence + +These 20 frames show the Appshots implementation using neutral fixtures: 14 native GPUI Metal exports and 6 iPhone 17 Pro simulator screenshots. The desktop source was unchanged by the v0.2.60 rebase. Light desktop exports omit the macOS compositor backdrop and therefore appear gray. + +Desktop coverage includes light/dark, narrow layouts, composer and transcript cards, queue previews, uploading/unavailable states, dedicated settings, keyboard enablement, destination selection and shortcut recording. Phone coverage includes portrait/landscape, horizontal cards, full-image preview, queue gallery and actions. + +These are presentation fixtures, not proof of live application capture, permission dialogs, portal interaction or physical remote-device delivery. The iOS offline host disables Send now. The desktop fixture emitted nonfatal resize and local IPC probe warnings while exporting all frames. + +Reproduce desktop frames with the `appshots-fixture` example and supplied neutral PNG inputs. Reproduce phone frames with `AppshotUITests` using the `-demo -appshots` dataset. See `docs/appshots.md` for the implementation and validation limits. diff --git a/docs/screenshots/appshots/appshots-chat-dark-1100.png b/docs/screenshots/appshots/appshots-chat-dark-1100.png new file mode 100644 index 000000000..8b6daab69 Binary files /dev/null and b/docs/screenshots/appshots/appshots-chat-dark-1100.png differ diff --git a/docs/screenshots/appshots/appshots-chat-light-1100.png b/docs/screenshots/appshots/appshots-chat-light-1100.png new file mode 100644 index 000000000..853b52282 Binary files /dev/null and b/docs/screenshots/appshots/appshots-chat-light-1100.png differ diff --git a/docs/screenshots/appshots/appshots-chat-light-320.png b/docs/screenshots/appshots/appshots-chat-light-320.png new file mode 100644 index 000000000..bdca28dad Binary files /dev/null and b/docs/screenshots/appshots/appshots-chat-light-320.png differ diff --git a/docs/screenshots/appshots/appshots-chat-light-390.png b/docs/screenshots/appshots/appshots-chat-light-390.png new file mode 100644 index 000000000..f1497a0d3 Binary files /dev/null and b/docs/screenshots/appshots/appshots-chat-light-390.png differ diff --git a/docs/screenshots/appshots/appshots-chat-light-700.png b/docs/screenshots/appshots/appshots-chat-light-700.png new file mode 100644 index 000000000..8bacc4749 Binary files /dev/null and b/docs/screenshots/appshots/appshots-chat-light-700.png differ diff --git a/docs/screenshots/appshots/appshots-ios-first-capture.png b/docs/screenshots/appshots/appshots-ios-first-capture.png new file mode 100644 index 000000000..a1910ee19 Binary files /dev/null and b/docs/screenshots/appshots/appshots-ios-first-capture.png differ diff --git a/docs/screenshots/appshots/appshots-ios-landscape.png b/docs/screenshots/appshots/appshots-ios-landscape.png new file mode 100644 index 000000000..eb7456be0 Binary files /dev/null and b/docs/screenshots/appshots/appshots-ios-landscape.png differ diff --git a/docs/screenshots/appshots/appshots-ios-lightbox.png b/docs/screenshots/appshots/appshots-ios-lightbox.png new file mode 100644 index 000000000..8a5726884 Binary files /dev/null and b/docs/screenshots/appshots/appshots-ios-lightbox.png differ diff --git a/docs/screenshots/appshots/appshots-ios-portrait.png b/docs/screenshots/appshots/appshots-ios-portrait.png new file mode 100644 index 000000000..e4de3dbe1 Binary files /dev/null and b/docs/screenshots/appshots/appshots-ios-portrait.png differ diff --git a/docs/screenshots/appshots/appshots-ios-queue-actions.png b/docs/screenshots/appshots/appshots-ios-queue-actions.png new file mode 100644 index 000000000..5865ce2aa Binary files /dev/null and b/docs/screenshots/appshots/appshots-ios-queue-actions.png differ diff --git a/docs/screenshots/appshots/appshots-ios-queue-gallery.png b/docs/screenshots/appshots/appshots-ios-queue-gallery.png new file mode 100644 index 000000000..283ae085b Binary files /dev/null and b/docs/screenshots/appshots/appshots-ios-queue-gallery.png differ diff --git a/docs/screenshots/appshots/appshots-settings-dark-1100.png b/docs/screenshots/appshots/appshots-settings-dark-1100.png new file mode 100644 index 000000000..ada6c22be Binary files /dev/null and b/docs/screenshots/appshots/appshots-settings-dark-1100.png differ diff --git a/docs/screenshots/appshots/appshots-settings-dark-600.png b/docs/screenshots/appshots/appshots-settings-dark-600.png new file mode 100644 index 000000000..66488d485 Binary files /dev/null and b/docs/screenshots/appshots/appshots-settings-dark-600.png differ diff --git a/docs/screenshots/appshots/appshots-settings-enabled-keyboard.png b/docs/screenshots/appshots/appshots-settings-enabled-keyboard.png new file mode 100644 index 000000000..110bc7aa6 Binary files /dev/null and b/docs/screenshots/appshots/appshots-settings-enabled-keyboard.png differ diff --git a/docs/screenshots/appshots/appshots-settings-last-session.png b/docs/screenshots/appshots/appshots-settings-last-session.png new file mode 100644 index 000000000..152567064 Binary files /dev/null and b/docs/screenshots/appshots/appshots-settings-last-session.png differ diff --git a/docs/screenshots/appshots/appshots-settings-light-1100.png b/docs/screenshots/appshots/appshots-settings-light-1100.png new file mode 100644 index 000000000..1618c915d Binary files /dev/null and b/docs/screenshots/appshots/appshots-settings-light-1100.png differ diff --git a/docs/screenshots/appshots/appshots-settings-recording.png b/docs/screenshots/appshots/appshots-settings-recording.png new file mode 100644 index 000000000..7ad6410c2 Binary files /dev/null and b/docs/screenshots/appshots/appshots-settings-recording.png differ diff --git a/docs/screenshots/appshots/appshots-transcript-dark-1100.png b/docs/screenshots/appshots/appshots-transcript-dark-1100.png new file mode 100644 index 000000000..ae4d8bc24 Binary files /dev/null and b/docs/screenshots/appshots/appshots-transcript-dark-1100.png differ diff --git a/docs/screenshots/appshots/appshots-transcript-narrow-390.png b/docs/screenshots/appshots/appshots-transcript-narrow-390.png new file mode 100644 index 000000000..6804ef2e8 Binary files /dev/null and b/docs/screenshots/appshots/appshots-transcript-narrow-390.png differ diff --git a/docs/screenshots/appshots/appshots-transcript-transfer-states.png b/docs/screenshots/appshots/appshots-transcript-transfer-states.png new file mode 100644 index 000000000..8f4e90ee1 Binary files /dev/null and b/docs/screenshots/appshots/appshots-transcript-transfer-states.png differ diff --git a/docs/toolcraft/agent-worklog.md b/docs/toolcraft/agent-worklog.md index c8a4d0fbd..b2a913438 100644 --- a/docs/toolcraft/agent-worklog.md +++ b/docs/toolcraft/agent-worklog.md @@ -56,3 +56,107 @@ Tier 2: compile and test the affected Rust UI crate, then visually inspect the s - `cargo test -p zeron-ui --lib settings::appearance` passes (4 tests). - A packaged debug build was opened on macOS and the Appearance page, installed-theme row, and empty import state were visually inspected at 1365 × 768. - The final visual pass found and removed a remaining overflowing helper sentence; long error paths are now explicitly truncated within the dialog. + +## 2026-08-20 — Appshots exploration + +> The Appshots entries below are historical. See [the current implementation](../appshots.md) +> for supported platforms, settings, and validation limits. + +- Product goal: capture the frontmost application from a global shortcut and + stage screenshot plus semantic application context in a Zeron composer. +- Visible output: a distinct, removable Appshot card in the destination draft; + the existing paperclip and ordinary attachment flows remain unchanged. +- Editable entities: global shortcut, destination policy, staged Appshot, and + the user's accompanying prompt. +- Required controls: documented by workflow in + `docs/research/appshots.md#control-section-inventory`. +- Export behavior: none. A send reuses Zeron's attachment upload and prompt + transport. +- Persistence: device-local settings; unsent-capture persistence remains an + explicit product decision. +- Layers/timeline/custom renderer: not required. +- Verification tier: native macOS spike, pure routing/serialization tests, + local and remote integration tests, and a manual permissions/application + matrix. +- Main architecture decision: desktop capture is viewer-side UI capability, + never an engine or remote-host responsibility. + +## 2026-08-20 — Appshots implementation and macOS hardening + +- Implemented the complete macOS capture-to-composer slice on + `wip/appshots-exploration` with `Control-Option-Space` as the fixed default. +- Replaced the initial event tap with Carbon global-hotkey registration, which + consumes the chord and does not require Input Monitoring permission. +- Uses ScreenCaptureKit's desktop-independent window capture on macOS 14+; + retains a CoreGraphics fallback for macOS 12/13 and transient failures. +- Window enumeration is no longer restricted to the current Space. Active + ScreenCaptureKit windows win, followed by on-screen state and window area. +- Added an isolated `Zeron Dev.app` workflow with bundle ID + `sh.zeron.app.dev`, stable Apple Development signing, its own data directory, + and IPC port. It launches through LaunchServices so TCC attributes capture + permissions to Zeron Dev rather than the terminal. +- The isolated data directory and IPC port are embedded in the development + bundle's `LSEnvironment`, so macOS privacy's “Quit & Reopen” preserves the + development instance instead of reopening against personal production data. + +## 2026-08-20 — Appshots composer and onboarding refinement + +- Product goal: make staged Appshots visually scannable without consuming the + composer and make the macOS permission sequence understandable before any + system prompt appears. +- Visible output: one horizontal tray of 232×148 Appshot tiles. Each tile uses + a contained window preview, the captured application's icon over the lower + edge, a concise window/app label, hover removal, and the existing lightbox. +- Permission workflow: enabling Appshots only enables the feature. Screen + Recording is the single required capture permission; Accessibility is an + optional enhancement for application text, including off-screen content. + The two permissions have separate status rows and user-triggered actions. +- Controls: feature toggle, destination selector, required Screen Recording + action, optional Accessibility action, and a permission-status refresh. +- Persistence and transport are unchanged. Application icons are presentation + metadata only and are never uploaded or serialized into prompts. +- Verification tier: pure tray-height and prompt tests, full `zeron-ui` unit + suite, macOS compile/build, and native visual/permission smoke testing. + +## 2026-08-21 — Appshots aspect-ratio and fade refinement + +- Product goal: make every Appshot read as one consistently sized composer + object even when source windows range from very wide to very tall. +- Visible output: a fixed, clipped stage containing a bottom-aligned image, + with explicit aspect-ratio sizing, a native bottom edge-fade, the app icon + floating above that fade, and more air before the title. +- Control inventory is unchanged: the tile still opens the lightbox and its + hover action removes it. No new settings, persistence, transport, export, + layer, or timeline behavior is introduced. +- Renderer decision: retain the native GPUI image renderer, but stop depending + on its intrinsic sizing. Read dimensions from the captured PNG and assign + exact contained dimensions inside both preview-level and card-level clips. +- Verification tier: pure PNG/aspect-ratio tests, full `zeron-ui` unit suite, + macOS build checks, and native composer inspection with landscape and + portrait captures. + +## 2026-08-22 — Windows and Linux Appshots + +- Product goal: extend the existing capture-to-composer contract to Windows, + Linux X11, and Linux Wayland without presenting their security models as if + they were macOS permissions. +- Visible output and editable entities are unchanged after capture. Settings + now inventory three workflow capabilities: global invocation, window capture + (including whether selection is required), and semantic application text. +- Backend decision: a UI-side `AppshotBackend` owns native initiation and + capture. Linux selects Wayland portal or X11 at runtime; AT-SPI enrichment is + independent so screenshot-only Appshots remain useful. +- Windows decision: use a thread-owned `RegisterHotKey`, foreground HWND, + Windows Graphics Capture, and bounded UI Automation. Do not request the + security-sensitive `UIAccess` privilege. +- Wayland decision: probe portal versions and advertised targets. Prefer + Active Window; fall back to the portal window picker. Prefer the Global + Shortcuts portal and expose `zeron appshot` for system-managed shortcut + configuration when the portal is absent. +- X11 decision: prefer a portal-advertised Active Window target, then use + `_NET_ACTIVE_WINDOW`, a passive key grab, direct drawable capture, EWMH + process/name/icon metadata, and the same optional AT-SPI layer. +- Persistence, transport, composer controls, export, layers, and timeline are + unchanged. Verification combines pure platform parsing tests, host checks, + cross-target compilation where available, and native Windows/GNOME/KDE/X11 + release matrices. diff --git a/scripts/generate-appshot-sound.py b/scripts/generate-appshot-sound.py new file mode 100644 index 000000000..038f64e46 --- /dev/null +++ b/scripts/generate-appshot-sound.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Generate the original rounded Appshot confirmation cue. + +Three separated shutter clicks followed by a single smooth C4-F4 resonance, adapted +from the rounded sound-family auditions. Standard-library-only, deterministic +stereo PCM; no external samples, noise bed or upward normalization. +""" +from pathlib import Path +import math +import struct +import wave + +RATE = 48000 +MASTER = .55 +COLORS = { + 'felt': [(1, 1, 1), (2, .075, .55), (3, .018, .35)], + 'clear': [(1, 1, 1), (2, .10, .55), (3, .035, .32)], +} +# Clicks: onset, amplitude, width. Notes: onset, Hz, amplitude, decay, color. +APPSHOT = ( + 'appshot', 'Appshot captured', 'Round shutter triple and blended confirmation', .67, + [(.009, .19, .00075), (.079, .21, .00058), (.149, .18, .00068)], + [(.180, 261.63, .030, .120, 'felt'), + (.180, 349.23, .038, .120, 'felt'), + (.180, 523.25, .009, .085, 'clear')], +) + + +def render(spec): + name, label, description, duration, clicks, notes = spec + count = round(duration * RATE) + dry = [] + for i in range(count): + t = i / RATE + click = 0.0 + for center, amplitude, width in clicks: + x = (t - center) / width + if abs(x) < 5: + click += amplitude * (1 - 2*x*x) * math.exp(-x*x) + tone = 0.0 + for start, frequency, amplitude, decay, color in notes: + u = t - start + if u < 0: + continue + for ratio, level, damping in COLORS[color]: + envelope = (1 - math.exp(-u/.018)) * math.exp(-u/(decay*damping)) + tone += amplitude * level * envelope * math.sin(2*math.pi*frequency*ratio*u) + dry.append((click,tone)) + frames=[] + for i,(click,tone) in enumerate(dry): + channels=[] + for delay in [.026,.035]: + k=i-round(delay*RATE) + reflection=.045*dry[k][1] if k>=0 else 0 + fade=min(1,(count-i)/(.060*RATE)) + channels.append((click+tone+reflection)*MASTER*fade) + frames.append(channels) + peak=max(abs(v) for frame in frames for v in frame) + assert peak < .13 + return frames, peak + + +def write(path, frames): + with wave.open(str(path),'wb') as f: + f.setnchannels(2); f.setsampwidth(2); f.setframerate(RATE) + f.writeframes(b''.join(struct.pack('/dev/null 2>&1 || PATH="$HOME/.cargo/bin:$PATH" +VERSION="$(grep -m1 '^version' "$ROOT/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')" +DEV_ROOT="$ROOT/target/macos-dev" +APP="$DEV_ROOT/Zeron Dev.app" +CONTENTS="$APP/Contents" +DATA_DIR="${ZERON_DEV_DATA_DIR:-$DEV_ROOT/data}" +IPC_PORT="${ZERON_DEV_IPC_PORT:-49777}" + +if pgrep -f -x "$CONTENTS/MacOS/zeron" >/dev/null 2>&1; then + echo "Zeron Dev is already running. Quit it before rebuilding the signed bundle." >&2 + exit 1 +fi + +cd "$ROOT" +cargo build -p zeron + +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DATA_DIR" +install -m 755 "$ROOT/target/debug/zeron" "$CONTENTS/MacOS/zeron" +sed "s/__VERSION__/$VERSION/g" "$ROOT/dist/macos/Info-dev.plist" >"$CONTENTS/Info.plist" +plutil -replace LSEnvironment.ZERON_DATA_DIR -string "$DATA_DIR" "$CONTENTS/Info.plist" +plutil -replace LSEnvironment.ZERON_IPC_PORT -string "$IPC_PORT" "$CONTENTS/Info.plist" + +if [[ ! -f "$CONTENTS/Resources/zeron.icns" ]]; then + ICONSET="$DEV_ROOT/zeron-dev.iconset" + mkdir -p "$ICONSET" + for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$ROOT/dist/macos/icon-1024.png" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + retina=$((size * 2)) + sips -z "$retina" "$retina" "$ROOT/dist/macos/icon-1024.png" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null + done + iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/zeron.icns" +fi + +# A real Apple Development identity gives TCC a stable signing requirement +# across rebuilds. Set ZERON_DEV_CODESIGN_IDENTITY explicitly when more than +# one identity is installed; otherwise fall back to an ad-hoc signature. +IDENTITY="${ZERON_DEV_CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null | sed -n 's/.*"\(Apple Development:[^"]*\)".*/\1/p' | head -1)" +fi +if [[ -n "$IDENTITY" ]]; then + codesign --force --sign "$IDENTITY" --identifier sh.zeron.app.dev "$APP" +else + codesign --force --sign - --identifier sh.zeron.app.dev "$APP" + echo "warning: no Apple Development signing identity found; macOS may ask for permissions again after a rebuild" >&2 +fi + +echo "running Zeron Dev (bundle sh.zeron.app.dev, data $DATA_DIR, IPC $IPC_PORT)" >&2 +# LaunchServices must own the process. Launching Contents/MacOS/zeron directly +# makes TCC attribute Screen Recording to the terminal (Warp, Terminal, etc.). +# -W keeps the script attached until the app exits. Runtime logs remain in the +# isolated data directory (`target/macos-dev/data/logs/zeron-headed.log`). +OPEN_ENV=( + --env "ZERON_DATA_DIR=$DATA_DIR" + --env "ZERON_IPC_PORT=$IPC_PORT" +) +if [[ -n "${ZERON_OPEN_ROUTE:-}" ]]; then + OPEN_ENV+=(--env "ZERON_OPEN_ROUTE=$ZERON_OPEN_ROUTE") +fi +exec open -W "${OPEN_ENV[@]}" "$APP" --args "$@"