498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
144 lines
5.4 KiB
Swift
144 lines
5.4 KiB
Swift
// AIAgentShortcutRun.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Ephemeral App Group payload for one keyboard → host → Shortcuts hop.
|
|
// The keyboard writes titles here, then opens `osgkeyboard://skill/run`.
|
|
// The host consumes the payload (once) and opens the Shortcuts URL.
|
|
|
|
import Foundation
|
|
|
|
public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
|
|
public let skillID: String
|
|
public let titles: [String]
|
|
public let createdAt: Date
|
|
|
|
public init(skillID: String, titles: [String], createdAt: Date = Date()) {
|
|
self.skillID = skillID
|
|
self.titles = titles
|
|
self.createdAt = createdAt
|
|
}
|
|
|
|
public var joinedTitles: String {
|
|
titles.joined(separator: "\n")
|
|
}
|
|
}
|
|
|
|
public enum AIAgentShortcutRun {
|
|
public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
|
|
/// Last skill-handoff lines, readable from the App Group plist on device.
|
|
public static let recentTracesKey = "diag.skills.recent.v1"
|
|
/// Drop payloads older than this; a leftover write must not fire later.
|
|
public static let payloadTTL: TimeInterval = 60
|
|
|
|
public static func shortcutsRunURL(
|
|
name: String,
|
|
text: String,
|
|
xSuccess: String? = nil,
|
|
xError: String? = nil,
|
|
xCancel: String? = nil
|
|
) -> URL? {
|
|
var components = URLComponents()
|
|
components.scheme = "shortcuts"
|
|
let usesCallback = xSuccess != nil || xError != nil || xCancel != nil
|
|
if usesCallback {
|
|
components.host = "x-callback-url"
|
|
components.path = "/run-shortcut"
|
|
} else {
|
|
components.host = "run-shortcut"
|
|
}
|
|
var items = [
|
|
URLQueryItem(name: "name", value: name),
|
|
URLQueryItem(name: "input", value: "text"),
|
|
URLQueryItem(name: "text", value: text)
|
|
]
|
|
if let xSuccess {
|
|
items.append(URLQueryItem(name: "x-success", value: xSuccess))
|
|
}
|
|
if let xError {
|
|
items.append(URLQueryItem(name: "x-error", value: xError))
|
|
}
|
|
if let xCancel {
|
|
items.append(URLQueryItem(name: "x-cancel", value: xCancel))
|
|
}
|
|
components.queryItems = items
|
|
return components.url
|
|
}
|
|
|
|
/// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
|
|
public static func trace(_ message: String) {
|
|
OSGDiag.log(message, category: "skills")
|
|
persistTrace(message)
|
|
}
|
|
|
|
/// Keep a short ring so we can copy traces off the phone without root `log collect`.
|
|
private static func persistTrace(_ message: String) {
|
|
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
|
var lines = defaults.stringArray(forKey: recentTracesKey) ?? []
|
|
let stamp = ISO8601DateFormatter().string(from: Date())
|
|
lines.append("\(stamp) \(message)")
|
|
if lines.count > 60 {
|
|
lines = Array(lines.suffix(60))
|
|
}
|
|
defaults.set(lines, forKey: recentTracesKey)
|
|
}
|
|
|
|
/// Single-line preview so Console keeps the format (`\\n` for newlines).
|
|
public static func preview(_ text: String, limit: Int = 1200) -> String {
|
|
let escaped = text
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\r\n", with: "\\n")
|
|
.replacingOccurrences(of: "\n", with: "\\n")
|
|
.replacingOccurrences(of: "\r", with: "\\n")
|
|
.replacingOccurrences(of: "\t", with: "\\t")
|
|
if escaped.count <= limit { return escaped }
|
|
return String(escaped.prefix(limit)) + "…(chars=\(text.count))"
|
|
}
|
|
|
|
public static func traceBody(_ label: String, _ text: String) {
|
|
#if DEBUG
|
|
trace("\(label) chars=\(text.count) body=\(preview(text))")
|
|
#else
|
|
trace("\(label) chars=\(text.count)")
|
|
#endif
|
|
}
|
|
|
|
/// Opens the Shortcuts Add sheet for an iCloud share token.
|
|
/// Do not `open` the HTTPS share page from the app — Universal Links
|
|
/// often land on Gallery and drop the token.
|
|
public static func shortcutsInstallURL(from shareURL: URL) -> URL? {
|
|
guard let token = iCloudShareToken(from: shareURL) else { return nil }
|
|
return URL(string: "shortcuts://shortcuts/\(token)")
|
|
}
|
|
|
|
public static func iCloudShareToken(from shareURL: URL) -> String? {
|
|
guard let host = shareURL.host, host.contains("icloud.com") else { return nil }
|
|
let parts = shareURL.path.split(separator: "/").map(String.init)
|
|
guard let index = parts.firstIndex(of: "shortcuts"),
|
|
parts.count > index + 1 else { return nil }
|
|
let token = parts[index + 1]
|
|
guard token != "api", !token.isEmpty else { return nil }
|
|
return token
|
|
}
|
|
|
|
public static func openShortcutURL(name: String) -> URL? {
|
|
var components = URLComponents()
|
|
components.scheme = "shortcuts"
|
|
components.host = "open-shortcut"
|
|
components.queryItems = [URLQueryItem(name: "name", value: name)]
|
|
return components.url
|
|
}
|
|
|
|
public static func encode(_ payload: AIAgentShortcutRunPayload) -> Data? {
|
|
try? JSONEncoder().encode(payload)
|
|
}
|
|
|
|
public static func decode(_ data: Data, now: Date = Date()) -> AIAgentShortcutRunPayload? {
|
|
guard let payload = try? JSONDecoder().decode(AIAgentShortcutRunPayload.self, from: data) else {
|
|
return nil
|
|
}
|
|
guard now.timeIntervalSince(payload.createdAt) <= payloadTTL else { return nil }
|
|
guard !payload.titles.isEmpty else { return nil }
|
|
return payload
|
|
}
|
|
}
|