Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 87 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion apps/ios/Zeron/App/DemoDataset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import Foundation
import Observation
import UIKit

@MainActor
@Observable
Expand Down Expand Up @@ -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"))],
Expand All @@ -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 += "<appshot app=\"\(names[index])\" window-title=\"\(titles[index])\" image=\"\(paths[index])\">PRIVATE_OBSERVED_TEXT_MUST_STAY_HIDDEN</appshot>\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] {
Expand Down
119 changes: 119 additions & 0 deletions apps/ios/Zeron/Composer/Appshots.swift
Original file line number Diff line number Diff line change
@@ -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[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
}

/// Keep the original context byte-for-byte during a text-only queue edit.
/// The separate attachment list remains owned by the queue row.
static func suffix(_ text: String) -> 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: "<!DOCTYPE", options: .caseInsensitive) == nil,
suffix.range(of: "<!ENTITY", options: .caseInsensitive) == nil else { return [:] }
let xml = "<appshots>" + suffix.dropFirst(marker.count) + "</appshots>"
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<String> = []
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) }
}
}
Loading
Loading