From 79b3782a8633716fd6a7d77456e818f207f79b3d Mon Sep 17 00:00:00 2001 From: foobisdweik Date: Tue, 5 May 2026 22:29:15 -0700 Subject: [PATCH] feat: pure-Swift AppIntents metadata generator for Linux --- Package.resolved | 6 +- Package.swift | 27 + Sources/AppIntentsGen/Emitter.swift | 407 +++++++++++++++ Sources/AppIntentsGen/Generator.swift | 46 ++ Sources/AppIntentsGen/MangledName.swift | 44 ++ Sources/AppIntentsGen/Scanner.swift | 521 ++++++++++++++++++++ Sources/AppIntentsGen/Schema.swift | 210 ++++++++ Sources/AppIntentsGen/SystemProtocols.swift | 87 ++++ Sources/PackLib/AppIntentsMetadata.swift | 243 +++++++++ Sources/PackLib/Packer.swift | 43 +- Sources/PackLib/Planner.swift | 35 +- Sources/xtool-appintents-gen/main.swift | 72 +++ Tests/AppIntentsGenTests/EmitterTests.swift | 306 ++++++++++++ Tests/AppIntentsGenTests/ScannerTests.swift | 212 ++++++++ 14 files changed, 2254 insertions(+), 5 deletions(-) create mode 100644 Sources/AppIntentsGen/Emitter.swift create mode 100644 Sources/AppIntentsGen/Generator.swift create mode 100644 Sources/AppIntentsGen/MangledName.swift create mode 100644 Sources/AppIntentsGen/Scanner.swift create mode 100644 Sources/AppIntentsGen/Schema.swift create mode 100644 Sources/AppIntentsGen/SystemProtocols.swift create mode 100644 Sources/PackLib/AppIntentsMetadata.swift create mode 100644 Sources/xtool-appintents-gen/main.swift create mode 100644 Tests/AppIntentsGenTests/EmitterTests.swift create mode 100644 Tests/AppIntentsGenTests/ScannerTests.swift diff --git a/Package.resolved b/Package.resolved index c50130ae..35b8e3ee 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e56563c7d1a95af4aae9af2b653b49ad313c2cee55c9f30cd55b18dde2379e26", + "originHash" : "475c3ebdf2c9d86ea013bbf46142e0dc31fb8f73fb3bd4998c86b9f01fbba4e6", "pins" : [ { "identity" : "aexml", @@ -348,8 +348,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { diff --git a/Package.swift b/Package.swift index fe39331b..7398563d 100644 --- a/Package.swift +++ b/Package.swift @@ -70,6 +70,11 @@ let package = Package( // TODO: just depend on tuist/XcodeProj instead .package(url: "https://github.com/yonaskolb/XcodeGen", from: "2.43.0"), + + // SwiftSyntax powers the Linux-native AppIntents metadata generator. + // Pin to the same major as the Swift toolchain xtool currently + // requires (swift-tools-version 6.0); 600.x tracks Swift 6 syntax. + .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0"), ], targets: [ .systemLibrary(name: "XADI"), @@ -170,10 +175,32 @@ let package = Package( name: "PackLib", dependencies: [ "XUtils", + "AppIntentsGen", .product(name: "Yams", package: "Yams"), .product(name: "XcodeGenKit", package: "XcodeGen", condition: .when(platforms: [.macOS])), ] ), + .target( + name: "AppIntentsGen", + dependencies: [ + "XUtils", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + ] + ), + .executableTarget( + name: "xtool-appintents-gen", + dependencies: [ + "AppIntentsGen", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .testTarget( + name: "AppIntentsGenTests", + dependencies: [ + "AppIntentsGen", + ] + ), .executableTarget( name: "xtool", dependencies: [ diff --git a/Sources/AppIntentsGen/Emitter.swift b/Sources/AppIntentsGen/Emitter.swift new file mode 100644 index 00000000..7686a9ba --- /dev/null +++ b/Sources/AppIntentsGen/Emitter.swift @@ -0,0 +1,407 @@ +import Foundation + +/// Writes the on-disk `Metadata.appintents/` bundle from a `ScannedModule`. +/// +/// **Schema source.** Apple's `appintentsmetadataprocessor` emits two files: +/// `extract.actionsdata` (JSON text) and `version.json`. The exact shapes +/// are not documented; the layout below is reverse-engineered from a +/// shipping reference IPA (Wispr Flow 1.55) and validated against the +/// AppIntents-using apps inside it (`StartStopRecordingAppIntent`, +/// `CreateNoteViaTextAppIntent`, `NoteAppIntent` in the widget extension). +/// The user's `scripts/appintents_audit.py` was used to extract those +/// references. +/// +/// We deliberately diverge from Apple's output in only one place: the +/// `generator` block names ourselves (`xtool-appintents-gen`) rather than +/// `xcode-tools`, and the version field carries the swift toolchain +/// identifier instead of the Xcode build number. Apple's daemon ignores +/// unrecognised generator strings; the field exists only for diagnostics. +/// +/// **Format note.** Despite the `extract.actionsdata` extension, the file +/// is plain UTF-8 JSON — not a property list. Earlier xtool revisions wrote +/// a binary plist here; iOS Shortcuts cannot parse that and silently skips +/// indexing the bundle, which was the root cause of intents never appearing +/// in the Shortcuts action picker. +public struct Emitter: Sendable { + + /// `extract.actionsdata.version` integer. Wispr Flow ships value `1`; + /// the schema has been stable across iOS 17 / 18 / 26 in our captures. + public static let actionsDataVersion: Int = 1 + + /// `version.json.version` string. Apple writes a string (`"3.0"`), + /// not an integer. Bumping this should only happen when Apple ships + /// a schema-breaking change in a new iOS major. + public static let versionJSONVersion: String = "3.0" + + public struct Inputs: Sendable { + /// Bundle identifier as produced by the planner (un-prefixed; the + /// AutoSigner rewrites this to the team-prefixed form before signing). + public let bundleIdentifier: String + + /// Swift module name (user-visible SPM library). + public let moduleName: String + + /// Free-form toolchain stamp, e.g. `swift-6.3.1`. Surfaced in + /// `version.json.toolsVersion` for diagnostics; iOS does not parse it. + public let toolchainVersion: String + + /// Minimum deployment target, e.g. `17.0`. + public let deploymentTarget: String + + /// Platform family; only `iOS` is exercised today. + public let platformFamily: String + + /// `true` if this product is an app extension (widget appex etc.). + /// Affects per-action `supportedModes`: app extension intents default + /// to `2` (widget configuration), main-app intents to `1`. + public let isAppExtension: Bool + + public init( + bundleIdentifier: String, + moduleName: String, + toolchainVersion: String, + deploymentTarget: String, + platformFamily: String = "iOS", + isAppExtension: Bool = false + ) { + self.bundleIdentifier = bundleIdentifier + self.moduleName = moduleName + self.toolchainVersion = toolchainVersion + self.deploymentTarget = deploymentTarget + self.platformFamily = platformFamily + self.isAppExtension = isAppExtension + } + } + + public init() {} + + /// Materialise the bundle. `outputDir` is the desired + /// `Metadata.appintents` directory; the emitter creates it (replacing + /// any existing one) and writes `extract.actionsdata` + `version.json` + /// inside. No `*.lproj/` subdirectory is written — phrase data lives + /// in `extract.actionsdata.autoShortcuts` per Apple's current schema. + public func emit( + module: ScannedModule, + inputs: Inputs, + outputDir: URL + ) throws { + try? FileManager.default.removeItem(at: outputDir) + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + + try writeActionsData(module: module, inputs: inputs, into: outputDir) + try writeVersionJSON(inputs: inputs, into: outputDir) + } + + // MARK: - extract.actionsdata + + private func writeActionsData( + module: ScannedModule, + inputs: Inputs, + into outputDir: URL + ) throws { + // `actions` is keyed by the bare type name (no module prefix). The + // `identifier` field inside each action also carries the bare name. + // The `fullyQualifiedTypeName` field is what carries the namespace. + var actions: [String: Any] = [:] + for intent in module.intents { + actions[intent.typeName] = actionDictionary(for: intent, inputs: inputs) + } + + var entities: [String: Any] = [:] + for entity in module.entities { + entities[entity.typeName] = entityDictionary(for: entity, inputs: inputs) + } + + let provider = module.shortcutsProviders.first + let autoShortcuts = (provider?.shortcuts ?? []).map { autoShortcutDictionary(for: $0) } + let autoShortcutProviderMangledName: String = { + guard let provider else { return "" } + return MangledName.encode( + module: provider.module.isEmpty ? inputs.moduleName : provider.module, + typeName: provider.typeName, + kind: provider.kind + ) + }() + + let payload: [String: Any] = [ + "version": Self.actionsDataVersion, + "generator": [ + "name": "xtool-appintents-gen", + "version": inputs.toolchainVersion, + ] as [String: Any], + "shortcutTileColor": 14, + "actions": actions, + "entities": entities, + "enums": module.enums.map(enumDictionary), + "queries": [String: Any](), + "autoShortcuts": autoShortcuts, + "autoShortcutProviderMangledName": autoShortcutProviderMangledName, + "negativePhrases": [Any](), + "assistantEntities": [Any](), + "assistantIntents": [Any](), + "assistantIntentNegativePhrases": [Any](), + "packages": module.packages.map { package in + [ + "identifier": package.typeName, + "mangledTypeName": MangledName.encode( + module: package.module.isEmpty ? inputs.moduleName : package.module, + typeName: package.typeName, + kind: package.kind + ) + ] as [String: Any] + }, + ] + + let data = try JSONSerialization.data( + withJSONObject: payload, + options: [.sortedKeys] + ) + try data.write(to: outputDir.appendingPathComponent("extract.actionsdata")) + } + + private func actionDictionary( + for intent: ScannedIntent, + inputs: Inputs + ) -> [String: Any] { + let intentModule = intent.module.isEmpty ? inputs.moduleName : intent.module + let mangled = MangledName.encode( + module: intentModule, + typeName: intent.typeName, + kind: intent.kind + ) + + let isDiscoverable = intent.isDiscoverable ?? true + let openAppWhenRun = intent.openAppWhenRun ?? false + + // Apple's bitfield: 0 = void perform(), 4 = perform returns a value + // through `IntentResult & ReturnsValue`. Higher bits cover dialogs + // / opens-intent / etc. — leave them off until we capture a real + // counter-example. + let outputFlags: Int = intent.returnsValue ? 4 : 0 + + // App-extension intents (e.g. WidgetConfigurationIntent inside a + // widget appex) get supportedModes=2; ordinary AppIntents get 1. + // Specialized intents like AudioRecordingIntent MUST run in the host + // app to access the microphone; force mode 1 for those. + var supportedModes: Int = inputs.isAppExtension ? 2 : 1 + if intent.protocolNames.contains(where: { $0.hasSuffix("AudioRecordingIntent") }) { + supportedModes = 1 + } + + let titleString = intent.title ?? intent.typeName + let descriptionString = intent.descriptionText ?? "" + + var dict: [String: Any] = [ + "identifier": intent.typeName, + "fullyQualifiedTypeName": "\(intentModule).\(intent.typeName)", + "mangledTypeName": mangled, + "mangledTypeNameV2": mangled, + "mangledTypeNameByBundleIdentifier": [String: Any](), + "mangledTypeNameByBundleIdentifierV2": [String: Any](), + "title": localizableString(titleString), + "descriptionMetadata": [ + "descriptionText": localizableString(descriptionString), + "searchKeywords": [Any](), + ] as [String: Any], + "visibilityMetadata": [ + "isDiscoverable": isDiscoverable, + "assistantOnly": false, + ] as [String: Any], + "availabilityAnnotations": availabilityAnnotations(), + "isDiscoverable": isDiscoverable, + "isAuthPolExplicit": false, + "authenticationPolicy": 0, + "openAppWhenRun": openAppWhenRun, + "outputFlags": outputFlags, + "presentationStyle": 0, + "supportedModes": supportedModes, + "requiredCapabilities": [Any](), + "effectiveBundleIdentifiers": [Any](), + "systemProtocols": SystemProtocols.resolve(for: intent.protocolNames), + "systemProtocolMetadata": SystemProtocols.metadata(for: intent.protocolNames), + "systemProtocolMetadataV2": SystemProtocols.metadata(for: intent.protocolNames), + "typeSpecificMetadata": [Any](), + "assistantDefinedSchemas": [Any](), + "assistantDefinedSchemaTraits": [Any](), + "parameters": intent.parameters.map(parameterDictionary), + ] + + // Surface the action's return value as a typed magic variable in + // Shortcuts. Shape mirrors `parameterDictionary`'s `valueType` + // wrapper. `typeIdentifier: 0` is a placeholder; if Shortcuts hides + // the variable or types it as opaque, audit a real reference IPA's + // `extract.actionsdata` and patch the type code. + if intent.returnsValue { + dict["outputType"] = [ + "primitive": [ + "wrapper": [ + "typeIdentifier": 0, + ] as [String: Any], + ] as [String: Any], + ] as [String: Any] + } + + // Note: Wispr Flow's reference IPA never emits a `categoryName` key + // on action entries. Apple may surface IntentDescription's + // categoryName argument through a separate channel (e.g. donated + // category index) — emitting an unknown key here risks Shortcuts + // rejecting the entry. Drop it until we capture a counter-example. + _ = intent.categoryName + return dict + } + + private func parameterDictionary(_ parameter: ScannedParameter) -> [String: Any] { + // `valueType` is a discriminated union in Apple's schema. The full + // type registry is not exposed here; we emit the primitive typeIdentifier + // form with `0` as a generic placeholder, which iOS appears to tolerate + // for parameters that are not actively used in voice/dialog flows. + let valueType: [String: Any] = [ + "primitive": [ + "wrapper": [ + "typeIdentifier": 0, + ] as [String: Any], + ] as [String: Any], + ] + + var dict: [String: Any] = [ + "name": parameter.propertyName, + "isOptional": parameter.isOptional, + "isInput": false, + "capabilities": 0, + "dynamicOptionsSupport": 0, + "inputConnectionBehavior": 0, + "title": localizableString(parameter.title ?? parameter.propertyName), + "valueType": valueType, + "resolvableInputTypes": [Any](), + "typeSpecificMetadata": [Any](), + ] + if let description = parameter.descriptionText { + dict["descriptionMetadata"] = [ + "descriptionText": localizableString(description), + "searchKeywords": [Any](), + ] as [String: Any] + } + if let defaultExpr = parameter.defaultValueExpression { + dict["defaultValueExpression"] = defaultExpr + } + return dict + } + + private func entityDictionary( + for entity: ScannedEntity, + inputs: Inputs + ) -> [String: Any] { + let entityModule = entity.module.isEmpty ? inputs.moduleName : entity.module + let mangled = MangledName.encode( + module: entityModule, + typeName: entity.typeName, + kind: entity.kind + ) + return [ + "identifier": entity.typeName, + "fullyQualifiedTypeName": "\(entityModule).\(entity.typeName)", + "mangledTypeName": mangled, + "mangledTypeNameV2": mangled, + "title": localizableString(entity.typeName), + "availabilityAnnotations": availabilityAnnotations(), + ] + } + + private func enumDictionary(_ enumDecl: ScannedEnum) -> [String: Any] { + [ + "identifier": enumDecl.typeName, + "cases": enumDecl.cases.map { caseName -> [String: Any] in + [ + "identifier": caseName, + "title": localizableString(caseName), + ] + }, + "title": localizableString(enumDecl.typeName), + ] + } + + private func autoShortcutDictionary(for shortcut: ScannedShortcut) -> [String: Any] { + var dict: [String: Any] = [ + "actionIdentifier": shortcut.intentTypeName, + "phraseTemplates": shortcut.phrases.map { phrase -> [String: Any] in + [ + "key": normalisePhrase(phrase), + "alternatives": [Any](), + ] + }, + "availabilityAnnotations": availabilityAnnotations(), + ] + if let shortTitle = shortcut.shortTitle { + dict["shortTitle"] = localizableString(shortTitle) + } + if let imageName = shortcut.systemImageName { + dict["systemImageName"] = imageName + } + return dict + } + + // MARK: - version.json + + private func writeVersionJSON(inputs: Inputs, into outputDir: URL) throws { + let payload: [String: Any] = [ + "version": Self.versionJSONVersion, + "toolsVersion": inputs.toolchainVersion, + ] + let data = try JSONSerialization.data( + withJSONObject: payload, + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: outputDir.appendingPathComponent("version.json")) + } + + // MARK: - Helpers + + /// Apple wraps every localizable string as `{key, alternatives}`. The + /// `key` is either an inline literal (when no string catalog is present) + /// or the catalog identifier; `alternatives` is reserved for variant + /// phrasings (Siri ranking) and is empty in the absence of source data. + private func localizableString(_ value: String) -> [String: Any] { + [ + "key": value, + "alternatives": [Any](), + ] + } + + /// `availabilityAnnotations` is a single-key map under + /// `LNPlatformNameWildcard` keyed by `introducedVersion` set to `*`. + /// Apple uses richer payloads for `@available(iOS X.Y, *)`-gated + /// declarations; we keep the wildcard form until the scanner captures + /// availability attributes. + private func availabilityAnnotations() -> [String: Any] { + [ + "LNPlatformNameWildcard": [ + "introducedVersion": "*", + ] as [String: Any], + ] + } + + /// `\(.applicationName)` → `${applicationName}`. Apple's `phraseTemplates` + /// embed `${name}`-style placeholders that the Shortcuts NLU substitutes + /// at runtime (`applicationName`, `parameter:`, etc.). + private func normalisePhrase(_ phrase: String) -> String { + var out = "" + var index = phrase.startIndex + while index < phrase.endIndex { + if phrase[index] == "\\", + let nextIndex = phrase.index(index, offsetBy: 1, limitedBy: phrase.endIndex), + nextIndex < phrase.endIndex, + phrase[nextIndex] == "(" { + if let closeIndex = phrase[nextIndex...].firstIndex(of: ")") { + var inner = String(phrase[phrase.index(after: nextIndex).. ScannedModule { + try generate( + scanRoots: sourceRoots.map { Scanner.ScanRoot(module: inputs.moduleName, url: $0) }, + inputs: inputs, + outputDir: outputDir + ) + } + + /// Module-aware variant. Each `ScanRoot` carries the SwiftPM target name + /// that owns the source files under it; scanned declarations get + /// stamped with that module so the emitter produces correct mangled + /// names for cross-module types. + @discardableResult + public func generate( + scanRoots: [Scanner.ScanRoot], + inputs: Emitter.Inputs, + outputDir: URL + ) throws -> ScannedModule { + let module = try Scanner().scan(roots: scanRoots) + guard !module.isEmpty else { + // No AppIntents declared — leave the bundle absent rather + // than write an empty metadata directory. + try? FileManager.default.removeItem(at: outputDir) + return module + } + try Emitter().emit(module: module, inputs: inputs, outputDir: outputDir) + return module + } +} diff --git a/Sources/AppIntentsGen/MangledName.swift b/Sources/AppIntentsGen/MangledName.swift new file mode 100644 index 00000000..ea1557a5 --- /dev/null +++ b/Sources/AppIntentsGen/MangledName.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Computes the lightweight Apple "mangled type name" string used inside +/// `Metadata.appintents/extract.actionsdata`. +/// +/// The format observed in shipping iOS apps (e.g. Wispr Flow 1.55) is a +/// stripped-down form of Swift symbol mangling, not the full Swift ABI +/// mangled symbol that nm/dyld would emit. Concretely: +/// +/// +/// +/// where `kindSuffix` is `V` for `struct`, `C` for `class`, `O` for `enum`. +/// Examples from Wispr: +/// * `Flow.StartStopRecordingAppIntent` (struct) +/// → `4Flow27StartStopRecordingAppIntentV` +/// * `Widgets.NoteAppIntent` (struct in widget extension) +/// → `7Widgets13NoteAppIntentV` +/// * `Flow.ShortcutsProvider` (autoShortcutProviderMangledName) +/// → `4Flow17ShortcutsProviderV` +/// +/// We deliberately do NOT prefix `$s` (the Swift 5 mangling marker) — Apple's +/// AppIntents pipeline uses the bare `` grammar internally and the +/// daemon recognises it without the Swift prefix. +/// +/// Limitations of this v0: +/// * Top-level types only. Nested types would need additional context grammar +/// (`` with no second length, etc.). None of iMoonshine's +/// intents are nested, and the same is true of every reference IPA we have +/// audited so far. Add nested-type support when an app needs it. +/// * Generic types are emitted as their bare name. AppIntents in the wild do +/// not use generics on the intent type itself. +public enum MangledName { + + /// Single mangled name. `module` and `typeName` must be ASCII identifiers + /// (Swift identifier rules). UTF-8 byte counts of single-codepoint ASCII + /// equal `String.count`, so we use `count` directly here. + public static func encode( + module: String, + typeName: String, + kind: ScannedDeclKind + ) -> String { + "\(module.count)\(module)\(typeName.count)\(typeName)\(kind.mangledSuffix)" + } +} diff --git a/Sources/AppIntentsGen/Scanner.swift b/Sources/AppIntentsGen/Scanner.swift new file mode 100644 index 00000000..05267ec3 --- /dev/null +++ b/Sources/AppIntentsGen/Scanner.swift @@ -0,0 +1,521 @@ +import Foundation +import SwiftParser +import SwiftSyntax + +/// Walks a list of source roots and harvests every AppIntents-related +/// declaration into a `ScannedModule`. The scanner is purely lexical: +/// it does not execute any Swift code and does not resolve types across +/// modules, so it sees only what the source itself spells out. +/// +/// Recognised conformance markers (matched by trailing identifier on the +/// inheritance clause; module-qualified spellings such as +/// `AppIntents.AppIntent` also match): +/// * `AppIntent`, `AudioRecordingIntent`, `OpenIntent`, +/// `ForegroundContinuableIntent`, `LiveActivityIntent` (treated as +/// intents). +/// * `AppShortcutsProvider` (treated as a shortcuts provider). +/// * `AppEntity` (entity). +/// * `AppEnum` (enum). +/// +/// Anything not recognised is ignored. The scanner is forgiving by design; +/// it would rather under-report than fail a build. +public struct Scanner: Sendable { + + /// A source root paired with the SwiftPM module that owns it. Used to + /// stamp scanned declarations with their declaring module so the emitter + /// can produce correct cross-module mangled names. + public struct ScanRoot: Sendable, Equatable { + public var module: String + public var url: URL + + public init(module: String, url: URL) { + self.module = module + self.url = url + } + } + + public init() {} + + /// Scan every `.swift` file under each root, recursively. Symlinks are + /// not followed. Hidden files are skipped. All decls are stamped with + /// the empty string for `module` (legacy single-module callers). + public func scan(roots: [URL]) throws -> ScannedModule { + try scan(roots: roots.map { ScanRoot(module: "", url: $0) }) + } + + /// Scan a list of `(module, root)` pairs. Each scanned declaration is + /// stamped with its declaring module name, which the emitter uses to + /// generate correct mangled names for cross-module types. + public func scan(roots: [ScanRoot]) throws -> ScannedModule { + var module = ScannedModule() + for root in roots { + for url in try Self.swiftFiles(under: root.url) { + let source = try String(contentsOf: url, encoding: .utf8) + module.merge(scan(source: source, module: root.module)) + } + } + return module + } + + /// Scan a single Swift source string. Useful in tests and for the + /// `xtool-appintents-gen` CLI. + public func scan(source: String) -> ScannedModule { + scan(source: source, module: "") + } + + public func scan(source: String, module: String) -> ScannedModule { + let tree = Parser.parse(source: source) + let visitor = AppIntentsVisitor(module: module, viewMode: .sourceAccurate) + visitor.walk(tree) + return visitor.module + } + + static func swiftFiles(under root: URL) throws -> [URL] { + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) + var files: [URL] = [] + while let url = enumerator?.nextObject() as? URL { + guard url.pathExtension == "swift" else { continue } + files.append(url) + } + return files.sorted { $0.path < $1.path } + } +} + +private final class AppIntentsVisitor: SyntaxVisitor { + var module = ScannedModule() + let moduleName: String + + init(module: String, viewMode: SyntaxTreeViewMode) { + self.moduleName = module + super.init(viewMode: viewMode) + } + + private static let intentProtocols: Set = [ + "AppIntent", + "AudioRecordingIntent", + "ForegroundContinuableIntent", + "LiveActivityIntent", + "OpenIntent", + "WidgetConfigurationIntent", + "AppIntentsPackage", + ] + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + let typeName = node.name.text + let protocols = inheritanceNames(node.inheritanceClause) + classify( + typeName: typeName, + kind: .struct, + protocols: protocols, + members: node.memberBlock.members + ) + return .visitChildren + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + let typeName = node.name.text + let protocols = inheritanceNames(node.inheritanceClause) + classify( + typeName: typeName, + kind: .class, + protocols: protocols, + members: node.memberBlock.members + ) + return .visitChildren + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + let typeName = node.name.text + let protocols = inheritanceNames(node.inheritanceClause) + let isAppEnum = protocols.contains { $0 == "AppEnum" } + if isAppEnum { + var cases: [String] = [] + for member in node.memberBlock.members { + if let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) { + for element in caseDecl.elements { + cases.append(element.name.text) + } + } + } + module.enums.append( + ScannedEnum( + typeName: typeName, + module: moduleName, + protocolNames: protocols, + cases: cases + ) + ) + } + return .visitChildren + } + + private func classify( + typeName: String, + kind: ScannedDeclKind, + protocols: [String], + members: MemberBlockItemListSyntax + ) { + let protocolSet = Set(protocols) + let isIntent = !protocolSet.isDisjoint(with: Self.intentProtocols.subtracting(["AppIntentsPackage"])) + let isProvider = protocols.contains("AppShortcutsProvider") + let isEntity = protocols.contains("AppEntity") + let isPackage = protocols.contains("AppIntentsPackage") + + if isIntent { + var intent = makeIntent( + typeName: typeName, + kind: kind, + protocols: protocols, + members: members + ) + intent.module = moduleName + module.intents.append(intent) + } + if isProvider { + var provider = makeShortcutsProvider(typeName: typeName, kind: kind, members: members) + provider.module = moduleName + module.shortcutsProviders.append(provider) + } + if isEntity { + module.entities.append( + ScannedEntity(typeName: typeName, kind: kind, module: moduleName, protocolNames: protocols) + ) + } + if isPackage { + module.packages.append(ScannedPackage(typeName: typeName, kind: kind, module: moduleName)) + } + } + + private func makeIntent( + typeName: String, + kind: ScannedDeclKind, + protocols: [String], + members: MemberBlockItemListSyntax + ) -> ScannedIntent { + var intent = ScannedIntent(typeName: typeName, kind: kind, protocolNames: protocols) + for member in members { + if let varDecl = member.decl.as(VariableDeclSyntax.self) { + handleIntentProperty(varDecl, into: &intent) + } + if let funcDecl = member.decl.as(FunctionDeclSyntax.self), + funcDecl.name.text == "perform" { + if let returnType = funcDecl.signature.returnClause?.type { + intent.returnsValue = Self.typeMentions(returnType, name: "ReturnsValue") + } else { + intent.returnsValue = false + } + } + } + return intent + } + + private func handleIntentProperty( + _ varDecl: VariableDeclSyntax, + into intent: inout ScannedIntent + ) { + let isStatic = varDecl.modifiers.contains { $0.name.text == "static" } + let bindings = varDecl.bindings + guard let binding = bindings.first, + let identPattern = binding.pattern.as(IdentifierPatternSyntax.self) else { return } + let propertyName = identPattern.identifier.text + + if isStatic { + switch propertyName { + case "title": + intent.title = stringLiteralValue(from: binding.initializer?.value) + case "description": + intent.descriptionText = intentDescriptionText(from: binding.initializer?.value) + intent.categoryName = intentDescriptionCategory(from: binding.initializer?.value) + case "openAppWhenRun": + intent.openAppWhenRun = booleanValue(from: binding.initializer?.value) + case "isDiscoverable": + intent.isDiscoverable = booleanValue(from: binding.initializer?.value) + default: + break + } + return + } + + // Instance property: candidate for `@Parameter`. + let isParameter = varDecl.attributes.contains { attr in + guard let attrSyntax = attr.as(AttributeSyntax.self) else { return false } + return attrSyntax.attributeName.trimmedDescription == "Parameter" + } + guard isParameter else { return } + guard let typeAnnotation = binding.typeAnnotation else { return } + let typeText = typeAnnotation.type.trimmedDescription + let isOptional = typeText.hasSuffix("?") || typeText.hasPrefix("Optional<") + + var parameter = ScannedParameter( + propertyName: propertyName, + typeName: typeText, + isOptional: isOptional + ) + + if let attr = varDecl.attributes.first?.as(AttributeSyntax.self), + attr.attributeName.trimmedDescription == "Parameter", + case let .argumentList(args)? = attr.arguments { + for arg in args { + guard let label = arg.label?.text else { continue } + if label == "title" { + parameter.title = stringLiteralValue(from: arg.expression) + } else if label == "description" { + parameter.descriptionText = stringLiteralValue(from: arg.expression) + } else if label == "default" { + parameter.defaultValueExpression = arg.expression.trimmedDescription + } + } + } + intent.parameters.append(parameter) + } + + private func makeShortcutsProvider( + typeName: String, + kind: ScannedDeclKind, + members: MemberBlockItemListSyntax + ) -> ScannedShortcutsProvider { + var provider = ScannedShortcutsProvider(typeName: typeName, kind: kind) + for member in members { + guard let varDecl = member.decl.as(VariableDeclSyntax.self), + varDecl.modifiers.contains(where: { $0.name.text == "static" }), + let binding = varDecl.bindings.first, + let identPattern = binding.pattern.as(IdentifierPatternSyntax.self), + identPattern.identifier.text == "appShortcuts" else { continue } + + let initializerExpr = binding.initializer?.value + let accessorBody = binding.accessorBlock.flatMap { accessorBlock -> ExprSyntax? in + if case .getter(let stmts) = accessorBlock.accessors { + // Find the last expression statement (the implicit return). + return stmts.last?.item.as(ExprSyntax.self) + } + return nil + } + let expr = initializerExpr ?? accessorBody + guard let expr else { continue } + provider.shortcuts = parseAppShortcutsExpression(expr) + break + } + return provider + } + + /// Walk a result builder body that produces `[AppShortcut]`. The body + /// is typically a sequence of `AppShortcut(intent:phrases:shortTitle:systemImageName:)` + /// calls. We accept either an array literal or a `@AppShortcutsBuilder` + /// block. + private func parseAppShortcutsExpression(_ expr: ExprSyntax) -> [ScannedShortcut] { + var collected: [ScannedShortcut] = [] + let collector = AppShortcutCollector { call in + if let shortcut = self.scanAppShortcutCall(call) { + collected.append(shortcut) + } + } + collector.walk(Syntax(expr)) + return collected + } + + private func scanAppShortcutCall(_ call: FunctionCallExprSyntax) -> ScannedShortcut? { + // Match `AppShortcut(...)` or `AppIntents.AppShortcut(...)`. + let calleeText = call.calledExpression.trimmedDescription + let calleeTail = calleeText.split(separator: ".").last.map(String.init) ?? calleeText + guard calleeTail == "AppShortcut" else { return nil } + + var intentTypeName: String? + var shortTitle: String? + var systemImageName: String? + var phrases: [String] = [] + + for arg in call.arguments { + guard let label = arg.label?.text else { continue } + switch label { + case "intent": + intentTypeName = intentTypeFromExpression(arg.expression) + case "phrases": + phrases = stringArrayLiteralValue(arg.expression) + case "shortTitle": + shortTitle = stringLiteralValue(from: arg.expression) + case "systemImageName": + systemImageName = stringLiteralValue(from: arg.expression) + default: + break + } + } + + guard let intentTypeName else { return nil } + return ScannedShortcut( + intentTypeName: intentTypeName, + phrases: phrases, + shortTitle: shortTitle, + systemImageName: systemImageName + ) + } + + /// Extract a type name from `MyIntent()` / `MyIntent.self` / + /// `Module.MyIntent()` style expressions. Returns the trailing + /// identifier so module qualification is stripped. + private func intentTypeFromExpression(_ expr: ExprSyntax) -> String? { + if let call = expr.as(FunctionCallExprSyntax.self) { + return call.calledExpression.trimmedDescription + .split(separator: ".").last.map(String.init) + } + if let memberAccess = expr.as(MemberAccessExprSyntax.self), + memberAccess.declName.baseName.text == "self" { + return memberAccess.base?.trimmedDescription + .split(separator: ".").last.map(String.init) + } + return expr.trimmedDescription + .split(separator: ".").last.map(String.init) + } + + private func inheritanceNames(_ clause: InheritanceClauseSyntax?) -> [String] { + guard let clause else { return [] } + return clause.inheritedTypes.map { inherited in + let text = inherited.type.trimmedDescription + return text.split(separator: ".").last.map(String.init) ?? text + } + } + + private func stringLiteralValue(from expr: ExprSyntax?) -> String? { + guard let expr else { return nil } + // Plain string literal: `"foo"`. + if let lit = expr.as(StringLiteralExprSyntax.self) { + return concatStringLiteralSegments(lit) + } + // `LocalizedStringResource("foo")` / `IntentDescription("foo", ...)`. + if let call = expr.as(FunctionCallExprSyntax.self), + let firstArg = call.arguments.first, + firstArg.label == nil, + let lit = firstArg.expression.as(StringLiteralExprSyntax.self) { + return concatStringLiteralSegments(lit) + } + return nil + } + + /// Specifically handles `IntentDescription("foo", categoryName: "bar")`. + private func intentDescriptionText(from expr: ExprSyntax?) -> String? { + guard let expr else { return nil } + if let lit = expr.as(StringLiteralExprSyntax.self) { + return concatStringLiteralSegments(lit) + } + if let call = expr.as(FunctionCallExprSyntax.self), + let firstArg = call.arguments.first, + firstArg.label == nil, + let lit = firstArg.expression.as(StringLiteralExprSyntax.self) { + return concatStringLiteralSegments(lit) + } + return nil + } + + private func intentDescriptionCategory(from expr: ExprSyntax?) -> String? { + guard let call = expr?.as(FunctionCallExprSyntax.self) else { return nil } + for arg in call.arguments where arg.label?.text == "categoryName" { + return stringLiteralValue(from: arg.expression) + } + return nil + } + + private func booleanValue(from expr: ExprSyntax?) -> Bool? { + guard let booleanLiteral = expr?.as(BooleanLiteralExprSyntax.self) else { return nil } + return booleanLiteral.literal.text == "true" + } + + private func stringArrayLiteralValue(_ expr: ExprSyntax) -> [String] { + guard let array = expr.as(ArrayExprSyntax.self) else { return [] } + var values: [String] = [] + for element in array.elements { + if let lit = element.expression.as(StringLiteralExprSyntax.self), + let s = concatStringLiteralSegments(lit) { + values.append(s) + } + } + return values + } + + /// Concatenate the static segments of a string literal. Returns `nil` + /// if the literal contains an interpolation we cannot represent + /// statically (e.g. `\(.applicationName)`) — callers should treat that + /// as "unknown" and fall back to the raw source text. + private func concatStringLiteralSegments(_ lit: StringLiteralExprSyntax) -> String? { + var out = "" + var hadInterpolation = false + for segment in lit.segments { + if let str = segment.as(StringSegmentSyntax.self) { + out.append(str.content.text) + } else if let interp = segment.as(ExpressionSegmentSyntax.self) { + hadInterpolation = true + // App Intents phrases use `\(.applicationName)` heavily; + // we keep the source spelling so the emitter can leave a + // placeholder that Shortcuts substitutes at runtime. + out.append("\\(\(interp.expressions.trimmedDescription))") + } + } + if hadInterpolation, out.isEmpty { return nil } + return out + } + + /// Returns true when `type` is, or contains as a nested component, an + /// identifier whose trailing name matches `name`. Used to detect + /// `ReturnsValue<...>` inside opaque/composition return types like + /// `some IntentResult & ReturnsValue`. Walks + /// `IdentifierTypeSyntax`, `MemberTypeSyntax`, `CompositionTypeSyntax`, + /// `SomeOrAnyTypeSyntax`, and any generic argument lists. + static func typeMentions(_ type: TypeSyntax, name: String) -> Bool { + if let ident = type.as(IdentifierTypeSyntax.self) { + if ident.name.text == name { return true } + if let args = ident.genericArgumentClause?.arguments { + for arg in args { + if typeMentions(TypeSyntax(arg.argument), name: name) { + return true + } + } + } + return false + } + if let member = type.as(MemberTypeSyntax.self) { + if member.name.text == name { return true } + if let args = member.genericArgumentClause?.arguments { + for arg in args { + if typeMentions(TypeSyntax(arg.argument), name: name) { + return true + } + } + } + return typeMentions(TypeSyntax(member.baseType), name: name) + } + if let composition = type.as(CompositionTypeSyntax.self) { + for element in composition.elements { + if typeMentions(element.type, name: name) { return true } + } + return false + } + if let some = type.as(SomeOrAnyTypeSyntax.self) { + return typeMentions(some.constraint, name: name) + } + if let attributed = type.as(AttributedTypeSyntax.self) { + return typeMentions(attributed.baseType, name: name) + } + return false + } +} + +/// Walks any expression tree and invokes the supplied closure for every +/// nested `FunctionCallExprSyntax`. We use this to harvest `AppShortcut(...)` +/// calls inside an `AppShortcutsBuilder` body without having to enumerate +/// the result-builder transform shapes by hand. +private final class AppShortcutCollector: SyntaxVisitor { + private let onCall: (FunctionCallExprSyntax) -> Void + + init(onCall: @escaping (FunctionCallExprSyntax) -> Void) { + self.onCall = onCall + super.init(viewMode: .sourceAccurate) + } + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + onCall(node) + return .visitChildren + } +} diff --git a/Sources/AppIntentsGen/Schema.swift b/Sources/AppIntentsGen/Schema.swift new file mode 100644 index 00000000..655f03aa --- /dev/null +++ b/Sources/AppIntentsGen/Schema.swift @@ -0,0 +1,210 @@ +import Foundation + +/// Output of `Scanner` — a normalised view of every AppIntents declaration +/// found in a source tree. The shapes here intentionally avoid mirroring +/// any Apple SPI; they are the inputs that `Emitter` consumes to produce +/// the on-disk `Metadata.appintents/` bundle. +public struct ScannedModule: Sendable, Equatable { + public var intents: [ScannedIntent] + public var shortcutsProviders: [ScannedShortcutsProvider] + public var entities: [ScannedEntity] + public var enums: [ScannedEnum] + public var packages: [ScannedPackage] + + public init( + intents: [ScannedIntent] = [], + shortcutsProviders: [ScannedShortcutsProvider] = [], + entities: [ScannedEntity] = [], + enums: [ScannedEnum] = [], + packages: [ScannedPackage] = [] + ) { + self.intents = intents + self.shortcutsProviders = shortcutsProviders + self.entities = entities + self.enums = enums + self.packages = packages + } + + public var isEmpty: Bool { + intents.isEmpty + && shortcutsProviders.isEmpty + && entities.isEmpty + && enums.isEmpty + && packages.isEmpty + } + + public mutating func merge(_ other: ScannedModule) { + intents.append(contentsOf: other.intents) + shortcutsProviders.append(contentsOf: other.shortcutsProviders) + entities.append(contentsOf: other.entities) + enums.append(contentsOf: other.enums) + packages.append(contentsOf: other.packages) + } +} + +/// Swift declaration kind. Used by the mangled-name calculator to pick +/// the trailing suffix character: `V` (struct), `C` (class), `O` (enum). +public enum ScannedDeclKind: String, Sendable, Equatable { + case `struct` + case `class` + case `enum` + + /// Trailing character used by Swift's symbol mangling. + public var mangledSuffix: Character { + switch self { + case .struct: return "V" + case .class: return "C" + case .enum: return "O" + } + } +} + +public struct ScannedPackage: Sendable, Equatable { + public var typeName: String + public var kind: ScannedDeclKind + public var module: String + + public init(typeName: String, kind: ScannedDeclKind = .struct, module: String = "") { + self.typeName = typeName + self.kind = kind + self.module = module + } +} + +public struct ScannedIntent: Sendable, Equatable { + public var typeName: String + public var kind: ScannedDeclKind + public var module: String + public var title: String? + public var descriptionText: String? + public var categoryName: String? + public var openAppWhenRun: Bool? + public var isDiscoverable: Bool? + public var protocolNames: [String] + public var parameters: [ScannedParameter] + public var returnsValue: Bool + + public init( + typeName: String, + kind: ScannedDeclKind = .struct, + module: String = "", + title: String? = nil, + descriptionText: String? = nil, + categoryName: String? = nil, + openAppWhenRun: Bool? = nil, + isDiscoverable: Bool? = nil, + protocolNames: [String] = [], + parameters: [ScannedParameter] = [], + returnsValue: Bool = false + ) { + self.typeName = typeName + self.kind = kind + self.module = module + self.title = title + self.descriptionText = descriptionText + self.categoryName = categoryName + self.openAppWhenRun = openAppWhenRun + self.isDiscoverable = isDiscoverable + self.protocolNames = protocolNames + self.parameters = parameters + self.returnsValue = returnsValue + } +} + +public struct ScannedParameter: Sendable, Equatable { + public var propertyName: String + public var typeName: String + public var title: String? + public var descriptionText: String? + public var defaultValueExpression: String? + public var isOptional: Bool + + public init( + propertyName: String, + typeName: String, + title: String? = nil, + descriptionText: String? = nil, + defaultValueExpression: String? = nil, + isOptional: Bool = false + ) { + self.propertyName = propertyName + self.typeName = typeName + self.title = title + self.descriptionText = descriptionText + self.defaultValueExpression = defaultValueExpression + self.isOptional = isOptional + } +} + +public struct ScannedShortcutsProvider: Sendable, Equatable { + public var typeName: String + public var kind: ScannedDeclKind + public var module: String + public var shortcuts: [ScannedShortcut] + + public init( + typeName: String, + kind: ScannedDeclKind = .struct, + module: String = "", + shortcuts: [ScannedShortcut] = [] + ) { + self.typeName = typeName + self.kind = kind + self.module = module + self.shortcuts = shortcuts + } +} + +public struct ScannedShortcut: Sendable, Equatable { + public var intentTypeName: String + public var phrases: [String] + public var shortTitle: String? + public var systemImageName: String? + + public init( + intentTypeName: String, + phrases: [String] = [], + shortTitle: String? = nil, + systemImageName: String? = nil + ) { + self.intentTypeName = intentTypeName + self.phrases = phrases + self.shortTitle = shortTitle + self.systemImageName = systemImageName + } +} + +public struct ScannedEntity: Sendable, Equatable { + public var typeName: String + public var kind: ScannedDeclKind + public var module: String + public var protocolNames: [String] + + public init( + typeName: String, + kind: ScannedDeclKind = .struct, + module: String = "", + protocolNames: [String] = [] + ) { + self.typeName = typeName + self.kind = kind + self.module = module + self.protocolNames = protocolNames + } +} + +public struct ScannedEnum: Sendable, Equatable { + public var typeName: String + public var module: String + public var protocolNames: [String] + public var cases: [String] + + public init(typeName: String, module: String = "", protocolNames: [String] = [], cases: [String] = []) { + self.typeName = typeName + self.module = module + self.protocolNames = protocolNames + self.cases = cases + } + + public var kind: ScannedDeclKind { .enum } +} diff --git a/Sources/AppIntentsGen/SystemProtocols.swift b/Sources/AppIntentsGen/SystemProtocols.swift new file mode 100644 index 00000000..fc0f533e --- /dev/null +++ b/Sources/AppIntentsGen/SystemProtocols.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Reverse-DNS identifiers Apple's AppIntents pipeline writes into +/// `extract.actionsdata.actions[*].systemProtocols` / +/// `systemProtocolMetadata` / `systemProtocolMetadataV2`. +/// +/// Each Swift conformance protocol on an `AppIntent` translates to zero or +/// more `com.apple.link.systemProtocol.*` strings. The mapping is internal +/// to Apple and not documented; values below were captured by inspecting +/// shipping iOS apps. When auditing a new reference IPA, extend this table +/// rather than hardcoding inline in the emitter. +/// +/// Confirmed entries (from Wispr Flow 1.55): +/// * `AudioRecordingIntent` → +/// `com.apple.link.systemProtocol.AudioRecording`, +/// `com.apple.link.systemProtocol.SessionStarting` +/// * `AppIntent` (plain) → no entries +/// +/// Best-guess entries (added without IPA confirmation, kept conservative — +/// a wrong entry can cause Shortcuts to reject the metadata, while a missing +/// one merely degrades indexing). Validate against a real IPA before relying +/// on these for production: +/// * `LiveActivityIntent` +/// → `com.apple.link.systemProtocol.LiveActivity` +/// * `WidgetConfigurationIntent` +/// → `com.apple.link.systemProtocol.WidgetConfiguration` +/// * `OpenIntent` +/// → `com.apple.link.systemProtocol.OpenApp` +/// * `ForegroundContinuableIntent` +/// → `com.apple.link.systemProtocol.ForegroundContinuable` +public enum SystemProtocols { + + private static let confirmed: [String: [String]] = [ + "AudioRecordingIntent": [ + "com.apple.link.systemProtocol.SessionStarting", + "com.apple.link.systemProtocol.AudioRecording", + ], + ] + + private static let provisional: [String: [String]] = [ + "LiveActivityIntent": [ + "com.apple.link.systemProtocol.LiveActivity", + ], + "WidgetConfigurationIntent": [ + "com.apple.link.systemProtocol.WidgetConfiguration", + ], + "OpenIntent": [ + "com.apple.link.systemProtocol.OpenApp", + ], + "ForegroundContinuableIntent": [ + "com.apple.link.systemProtocol.ForegroundContinuable", + ], + ] + + /// Returns the de-duplicated reverse-DNS list for the given Swift + /// conformance names. Order is stable: confirmed first, then provisional, + /// preserving the order in which protocol names appear on the type. + public static func resolve(for protocolNames: [String]) -> [String] { + var seen: Set = [] + var ordered: [String] = [] + for name in protocolNames { + // Strip module qualifier (e.g. `AppIntents.AudioRecordingIntent`). + let bare = name.split(separator: ".").last.map(String.init) ?? name + let entries = (confirmed[bare] ?? []) + (provisional[bare] ?? []) + for entry in entries where !seen.contains(entry) { + seen.insert(entry) + ordered.append(entry) + } + } + return ordered + } + + /// Returns the V2 metadata payload Apple writes alongside the flat + /// `systemProtocols` list. Each protocol gets two list elements: the + /// reverse-DNS string, then a `{"empty": {}}` marker dict. We do not + /// know what richer payloads might appear here on more elaborate intents + /// (focus filters, etc.); keep the empty marker until a counter-example + /// is captured. + public static func metadata(for protocolNames: [String]) -> [Any] { + var out: [Any] = [] + for entry in resolve(for: protocolNames) { + out.append(entry) + out.append(["empty": [String: Any]()] as [String: Any]) + } + return out + } +} diff --git a/Sources/PackLib/AppIntentsMetadata.swift b/Sources/PackLib/AppIntentsMetadata.swift new file mode 100644 index 00000000..9136849f --- /dev/null +++ b/Sources/PackLib/AppIntentsMetadata.swift @@ -0,0 +1,243 @@ +import AppIntentsGen +import Foundation +import XUtils + +/// Generates the `Metadata.appintents` bundle that iOS Shortcuts uses to +/// discover an app's `AppIntent` / `AppShortcut` declarations. +/// +/// xtool's SwiftPM-driven build does not run `appintentsmetadataprocessor` +/// (the proprietary Xcode build phase). Without the bundle, AppIntents +/// compile and run, but Shortcuts/Spotlight/Siri never index them, so the +/// app does not appear in the Shortcuts action picker. +/// +/// This step: +/// 1. Locates the processor binary (env override -> PATH -> macOS xcrun +/// -> Darwin SDK artifact bundle). +/// 2. Collects the per-source `*.appintentsmetadata` outputs that the +/// Swift frontend already emits when `AppIntents` is imported. +/// 3. Invokes the processor once per packed product, writing the +/// consolidated `Metadata.appintents` directory into the bundle root. +/// +/// If the processor cannot be located the step is skipped with a single +/// warning. Builds without AppIntents are unaffected. +public enum AppIntentsMetadata { + + /// A single (module, source-directory) pair for the native generator. + /// `module` is the SwiftPM target name (= Swift module name); `url` + /// is the directory whose `.swift` files belong to that module. + public struct SourceRoot: Sendable, Equatable { + public let module: String + public let url: URL + + public init(module: String, url: URL) { + self.module = module + self.url = url + } + } + + public struct Inputs: Sendable { + /// User-visible SPM library / module name (e.g. `iMoonshine`). + /// Used to namespace synthesised intent identifiers. + public let moduleName: String + + /// Synthetic builder target name (e.g. `iMoonshine-App`). + /// Used to locate per-product `.appintentsmetadata` files inside + /// `.build///.build/`. + public let buildModuleName: String + + public let bundleIdentifier: String + public let deploymentTarget: String + public let executableURL: URL + public let bundleURL: URL + public let buildDir: URL + public let sourceRoots: [SourceRoot] + + /// `true` when the product being packed is an app extension (e.g. a + /// widget appex). Drives `supportedModes` semantics in the emitted + /// metadata: appex intents get `supportedModes=2`, main-app intents `1`. + public let isAppExtension: Bool + + public init( + moduleName: String, + buildModuleName: String, + bundleIdentifier: String, + deploymentTarget: String, + executableURL: URL, + bundleURL: URL, + buildDir: URL, + sourceRoots: [SourceRoot] = [], + isAppExtension: Bool = false + ) { + self.moduleName = moduleName + self.buildModuleName = buildModuleName + self.bundleIdentifier = bundleIdentifier + self.deploymentTarget = deploymentTarget + self.executableURL = executableURL + self.bundleURL = bundleURL + self.buildDir = buildDir + self.sourceRoots = sourceRoots + self.isAppExtension = isAppExtension + } + } + + private static let envOverride = "XTOOL_APPINTENTS_PROCESSOR" + + private static let warnedKey = "XTOOL_APPINTENTS_WARNED" + + /// Best-effort search for the proprietary processor. + static func locateProcessor() async -> URL? { + if let override = ProcessInfo.processInfo.environment[envOverride], + !override.isEmpty, + FileManager.default.isExecutableFile(atPath: override) { + return URL(fileURLWithPath: override) + } + + if let path = try? await ToolRegistry.locate("appintentsmetadataprocessor"), + FileManager.default.isExecutableFile(atPath: path.path) { + return path + } + + // Darwin SDK artifact bundle (if a future SDK ships the tool). + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates = [ + home.appendingPathComponent(".xtool/SDKs/Darwin.artifactbundle/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor"), + home.appendingPathComponent(".xtool/SDKs/Darwin.artifactbundle/usr/local/bin/appintentsmetadataprocessor"), + ] + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate + } + + return nil + } + + /// Recursively collects `*.appintentsmetadata` files emitted by swiftc. + static func collectMetadataFiles(in root: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + + var matches: [URL] = [] + for case let url as URL in enumerator + where url.pathExtension == "appintentsmetadata" { + matches.append(url) + } + return matches + } + + /// Runs the processor for a single product. Returns silently if no + /// metadata files were emitted (the product does not use AppIntents). + public static func generate( + inputs: Inputs, + triple: String, + processor: URL + ) async throws { + let metadataFiles = collectMetadataFiles(in: inputs.buildDir) + .filter { $0.path.contains("/\(inputs.buildModuleName).build/") + || $0.path.contains("/\(inputs.buildModuleName).swiftmodule/") + || $0.deletingPathExtension().lastPathComponent.hasPrefix(inputs.buildModuleName) } + + guard !metadataFiles.isEmpty else { return } + + let outputDir = inputs.bundleURL.appendingPathComponent("Metadata.appintents", isDirectory: true) + try? FileManager.default.removeItem(at: outputDir) + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + + let listFile = outputDir.appendingPathComponent("source-files.txt") + let listing = metadataFiles.map(\.path).joined(separator: "\n") + try Data(listing.utf8).write(to: listFile) + defer { try? FileManager.default.removeItem(at: listFile) } + + let process = Process() + process.executableURL = processor + process.arguments = [ + "--toolchain-dir", processor.deletingLastPathComponent().deletingLastPathComponent().path, + "--module-name", inputs.moduleName, + "--target-triple", triple, + "--binary-file", inputs.executableURL.path, + "--bundle-identifier", inputs.bundleIdentifier, + "--output", outputDir.path, + "--source-files-list", listFile.path, + "--deployment-target", inputs.deploymentTarget, + "--platform-family", "iOS", + "--extract-metadata", + ] + process.standardOutput = FileHandle.standardError + try await process.runUntilExit() + } + + /// Public alias for tests + diagnostics. Calls into AppIntentsGen. + public static func runNativeGenerator(inputs: Inputs) throws { + try generateNative(inputs: inputs) + } + + /// Linux-native fallback: scan source with SwiftSyntax (`AppIntentsGen`) + /// and emit a best-effort `Metadata.appintents/` bundle. + /// + /// This path produces a smaller, more conservative bundle than Apple's + /// proprietary processor (no NLU graph, no dynamic-options metadata, + /// etc.). For the common AppIntent / AppShortcut surface — what most + /// apps need to be discoverable in Shortcuts — it is sufficient. + public static func generateNative(inputs: Inputs) throws { + guard !inputs.sourceRoots.isEmpty else { return } + let toolchainVersion: String = { + if let version = ProcessInfo.processInfo.environment["XTOOL_TOOLCHAIN_VERSION"], + !version.isEmpty { + return version + } + return "swift-unknown" + }() + let emitterInputs = Emitter.Inputs( + bundleIdentifier: inputs.bundleIdentifier, + moduleName: inputs.moduleName, + toolchainVersion: toolchainVersion, + deploymentTarget: inputs.deploymentTarget, + platformFamily: "iOS", + isAppExtension: inputs.isAppExtension + ) + let outputDir = inputs.bundleURL.appendingPathComponent("Metadata.appintents", isDirectory: true) + let scanRoots = inputs.sourceRoots.map { + Scanner.ScanRoot(module: $0.module, url: $0.url) + } + let module = try Generator().generate( + scanRoots: scanRoots, + inputs: emitterInputs, + outputDir: outputDir + ) + if module.isEmpty { return } + notifyNativeOnce(moduleName: inputs.moduleName) + } + + private static let nativeWarnedKey = "XTOOL_APPINTENTS_NATIVE_NOTIFIED" + + /// Emits a single notice the first time we run the Linux-native + /// generator in a packing session. + public static func notifyNativeOnce(moduleName: String) { + guard ProcessInfo.processInfo.environment[nativeWarnedKey] == nil else { return } + setenv(nativeWarnedKey, "1", 1) + FileHandle.standardError.write(Data(""" + note: appintentsmetadataprocessor not found; using xtool-appintents-gen \ + (Linux-native fallback) to produce Metadata.appintents/ for module \ + \(moduleName). Output covers the common AppIntent / AppShortcut \ + surface but does not match Xcode byte-for-byte. Set \(envOverride)=\ + /path/to/appintentsmetadataprocessor for byte-identical output. + + """.utf8)) + } + + /// Emits a single warning across the whole packing session when the + /// processor is unavailable AND the native fallback is also disabled. + public static func warnMissingOnce() { + guard ProcessInfo.processInfo.environment[warnedKey] == nil else { return } + setenv(warnedKey, "1", 1) + FileHandle.standardError.write(Data(""" + warning: appintentsmetadataprocessor not found. The packed app will \ + be missing Metadata.appintents and iOS Shortcuts will not index any \ + AppIntent / AppShortcut declarations. Set \(envOverride)=/path/to/\ + appintentsmetadataprocessor (e.g. from an Xcode install) to enable \ + this step. + + """.utf8)) + } +} diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index 9712ead2..ab69bcf9 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -84,14 +84,55 @@ public struct Packer: Sendable { isDirectory: true ) + let appIntentsProcessor = await AppIntentsMetadata.locateProcessor() + try await withThrowingTaskGroup(of: Void.self) { group in for product in plan.allProducts { + let productURL = product.directory(inApp: outputURL) try pack( product: product, binDir: binDir, - outputURL: product.directory(inApp: outputURL), + outputURL: productURL, &group ) + let sourceRoots = product.sourceRoots.map { + AppIntentsMetadata.SourceRoot( + module: $0.module, + url: URL(fileURLWithPath: $0.path) + ) + } + // `product.product` is the user-visible SPM library name + // (used to namespace intent identifiers); `product.targetName` + // is the synthetic builder target (`-App` / + // `-Extension`) where swiftc emits + // `*.appintentsmetadata` files. + let inputs = AppIntentsMetadata.Inputs( + moduleName: product.product, + buildModuleName: product.targetName, + bundleIdentifier: product.bundleID, + deploymentTarget: product.deploymentTarget, + executableURL: productURL.appendingPathComponent(product.product), + bundleURL: productURL, + buildDir: binDir, + sourceRoots: sourceRoots, + isAppExtension: product.type == .appExtension + ) + let triple = buildSettings.triple + if let processor = appIntentsProcessor { + group.addTask { + try await AppIntentsMetadata.generate( + inputs: inputs, + triple: triple, + processor: processor + ) + } + } else if !sourceRoots.isEmpty { + group.addTask { + try AppIntentsMetadata.generateNative(inputs: inputs) + } + } else { + AppIntentsMetadata.warnMissingOnce() + } } while !group.isEmpty { diff --git a/Sources/PackLib/Planner.swift b/Sources/PackLib/Planner.swift index b3f3e93b..f6fb2f35 100644 --- a/Sources/PackLib/Planner.swift +++ b/Sources/PackLib/Planner.swift @@ -129,6 +129,7 @@ public struct Planner: Sendable { matching: name ) var resources: [Plan.Resource] = [] + var sourceRoots: [Plan.SourceRoot] = [] var visited: Set = [] var targets = library.targets.map { (graph.root, $0) } while let (targetPackage, targetName) = targets.popLast() { @@ -142,6 +143,14 @@ public struct Planner: Sendable { if target.resources?.isEmpty == false { resources.append(.bundle(package: targetPackage.name, target: targetName)) } + // AppIntents declarations live in the root package's first-party + // targets. Skip third-party deps to keep the SwiftSyntax scan + // narrow and avoid surfacing unrelated intents from libraries. + if targetPackage.name == graph.root.name, + let targetPath = target.path, + target.moduleType.contains("Swift") { + sourceRoots.append(Plan.SourceRoot(module: target.name, path: targetPath)) + } for targetName in (target.targetDependencies ?? []) { targets.append((targetPackage, targetName)) } @@ -209,7 +218,8 @@ public struct Planner: Sendable { infoPlist: infoPlist, resources: resources, iconPath: iconPath, - entitlementsPath: entitlementsPath + entitlementsPath: entitlementsPath, + sourceRoots: sourceRoots ) } @@ -304,6 +314,20 @@ public struct Plan: Sendable { case root(source: String) } + /// A SwiftPM target's source directory paired with the target's name + /// (which is also its Swift module name). Used by the AppIntents native + /// generator to attribute each scanned declaration to its declaring + /// module so the emitter produces correct cross-module mangled names. + public struct SourceRoot: Sendable, Equatable { + public var module: String + public var path: String + + public init(module: String, path: String) { + self.module = module + self.path = path + } + } + public struct Product: Sendable { public var type: ProductType public var product: String @@ -314,6 +338,13 @@ public struct Plan: Sendable { public var iconPath: String? public var entitlementsPath: String? + /// Absolute paths to every source target that contributes to this + /// product. Consumed by the AppIntents Linux-native generator so + /// it can scan the actual user code rather than the synthetic + /// `xtool/.xtool-tmp/.../Sources//stub.c` shims that the + /// builder package assembles. + public var sourceRoots: [SourceRoot] + public var targetName: String { "\(self.product)-\(self.type.targetSuffix)" } @@ -406,6 +437,7 @@ private struct PackageDump: Decodable { struct Target: Decodable { let name: String let moduleType: String + let path: String? let productDependencies: [String]? let targetDependencies: [String]? let resources: [Resource]? @@ -421,6 +453,7 @@ private struct PackageDump: Decodable { } let name: String + let path: String? let products: [Product]? let targets: [Target]? let platforms: [Platform]? diff --git a/Sources/xtool-appintents-gen/main.swift b/Sources/xtool-appintents-gen/main.swift new file mode 100644 index 00000000..0f33b5ce --- /dev/null +++ b/Sources/xtool-appintents-gen/main.swift @@ -0,0 +1,72 @@ +import AppIntentsGen +import ArgumentParser +import Foundation + +@main +struct XToolAppIntentsGen: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "xtool-appintents-gen", + abstract: "Generate a Metadata.appintents bundle from Swift sources without needing Xcode.", + discussion: """ + Linux-native fallback for Apple's appintentsmetadataprocessor. \ + Walks the supplied source roots with SwiftSyntax, extracts every \ + AppIntent / AppShortcutsProvider / AppEntity / AppEnum declaration, \ + and writes Metadata.appintents/extract.actionsdata, version.json, \ + and en.lproj/nlu.appintents into the target directory. + """ + ) + + @Option(name: .shortAndLong, help: "Bundle identifier for the produced app or appex.") + var bundleIdentifier: String + + @Option(name: .shortAndLong, help: "Module name. Used as the namespace for synthesised type identifiers.") + var moduleName: String + + @Option(name: .long, help: "Minimum iOS deployment target, e.g. 17.0.") + var deploymentTarget: String = "17.0" + + @Option(name: .long, help: "Platform family written to version.json.") + var platformFamily: String = "iOS" + + @Option(name: .long, help: "Toolchain version stamp, e.g. swift-6.3.1.") + var toolchainVersion: String = "swift-unknown" + + @Option(name: .long, parsing: .upToNextOption, help: "One or more directories to scan recursively for *.swift files. Use the form '=' to attribute a directory to a specific Swift module; a bare path falls back to --module-name.") + var sourceRoot: [String] = [] + + @Option(name: .shortAndLong, help: "Path of the Metadata.appintents directory to write.") + var output: String + + func run() async throws { + let inputs = Emitter.Inputs( + bundleIdentifier: bundleIdentifier, + moduleName: moduleName, + toolchainVersion: toolchainVersion, + deploymentTarget: deploymentTarget, + platformFamily: platformFamily + ) + let scanRoots: [AppIntentsGen.Scanner.ScanRoot] = sourceRoot.map { entry in + if let eq = entry.firstIndex(of: "=") { + let module = String(entry[.. URL { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("xtool-appintents-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeInputs(isAppExtension: Bool = false) -> Emitter.Inputs { + Emitter.Inputs( + bundleIdentifier: "com.example.iMoonshine", + moduleName: "iMoonshine", + toolchainVersion: "swift-6.3.1", + deploymentTarget: "17.0", + isAppExtension: isAppExtension + ) + } + + @Test("Emits JSON extract.actionsdata + version.json with the expected top-level keys") + func emitsJSONFiles() throws { + let module = ScannedModule( + intents: [ + ScannedIntent( + typeName: "ToggleRecordingIntent", + kind: .struct, + title: "Toggle iMoonshine", + descriptionText: "Start or stop recording.", + categoryName: "iMoonshine", + openAppWhenRun: false, + isDiscoverable: true, + protocolNames: ["AudioRecordingIntent"], + parameters: [], + returnsValue: true + ) + ], + shortcutsProviders: [ + ScannedShortcutsProvider( + typeName: "iMoonshineShortcuts", + kind: .struct, + shortcuts: [ + ScannedShortcut( + intentTypeName: "ToggleRecordingIntent", + phrases: ["Toggle \\(.applicationName)"], + shortTitle: "iMoonshine Toggle", + systemImageName: "mic.circle.fill" + ) + ] + ) + ] + ) + + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let outputDir = dir.appendingPathComponent("Metadata.appintents") + try Emitter().emit(module: module, inputs: makeInputs(), outputDir: outputDir) + + let actionsURL = outputDir.appendingPathComponent("extract.actionsdata") + let versionURL = outputDir.appendingPathComponent("version.json") + #expect(FileManager.default.fileExists(atPath: actionsURL.path)) + #expect(FileManager.default.fileExists(atPath: versionURL.path)) + + // No nlu.appintents — phrases live inside extract.actionsdata.autoShortcuts now. + let lprojDir = outputDir.appendingPathComponent("en.lproj") + #expect(!FileManager.default.fileExists(atPath: lprojDir.path)) + + let actionsData = try Data(contentsOf: actionsURL) + guard let json = try JSONSerialization.jsonObject(with: actionsData) as? [String: Any] else { + Issue.record("extract.actionsdata is not JSON") + return + } + // Top-level keys we promise. + let expectedTopKeys: Set = [ + "version", "generator", "shortcutTileColor", "actions", "entities", + "enums", "queries", "autoShortcuts", "autoShortcutProviderMangledName", + "negativePhrases", "assistantEntities", "assistantIntents", + "assistantIntentNegativePhrases", + ] + #expect(expectedTopKeys.isSubset(of: Set(json.keys))) + + let actions = json["actions"] as? [String: Any] ?? [:] + #expect(actions.keys.contains("ToggleRecordingIntent")) + guard let action = actions["ToggleRecordingIntent"] as? [String: Any] else { + Issue.record("action entry missing") + return + } + #expect(action["fullyQualifiedTypeName"] as? String == "iMoonshine.ToggleRecordingIntent") + #expect(action["mangledTypeName"] as? String == "10iMoonshine21ToggleRecordingIntentV") + #expect(action["outputFlags"] as? Int == 4) // ReturnsValue + let outputType = action["outputType"] as? [String: Any] + let primitive = outputType?["primitive"] as? [String: Any] + let wrapper = primitive?["wrapper"] as? [String: Any] + #expect(wrapper?["typeIdentifier"] as? Int == 0) + #expect(action["openAppWhenRun"] as? Bool == false) + #expect(action["isDiscoverable"] as? Bool == true) + #expect((action["title"] as? [String: Any])?["key"] as? String == "Toggle iMoonshine") + + let protocols = action["systemProtocols"] as? [String] ?? [] + #expect(protocols.contains("com.apple.link.systemProtocol.AudioRecording")) + #expect(protocols.contains("com.apple.link.systemProtocol.SessionStarting")) + + let visibility = action["visibilityMetadata"] as? [String: Any] ?? [:] + #expect(visibility["isDiscoverable"] as? Bool == true) + #expect(visibility["assistantOnly"] as? Bool == false) + + // autoShortcuts surface the phrase templates. + let autoShortcuts = json["autoShortcuts"] as? [[String: Any]] ?? [] + #expect(autoShortcuts.count == 1) + let firstShortcut = autoShortcuts.first ?? [:] + #expect(firstShortcut["actionIdentifier"] as? String == "ToggleRecordingIntent") + #expect(firstShortcut["systemImageName"] as? String == "mic.circle.fill") + let templates = firstShortcut["phraseTemplates"] as? [[String: Any]] ?? [] + #expect(templates.first?["key"] as? String == "Toggle ${applicationName}") + + let providerMangled = json["autoShortcutProviderMangledName"] as? String + #expect(providerMangled == "10iMoonshine19iMoonshineShortcutsV") + + // version.json shape matches Apple's `{ toolsVersion, version }`. + let versionData = try Data(contentsOf: versionURL) + let version = try JSONSerialization.jsonObject(with: versionData) as? [String: Any] ?? [:] + #expect(version["version"] as? String == "3.0") + #expect(version["toolsVersion"] as? String == "swift-6.3.1") + } + + @Test("App-extension intents report supportedModes=2 (except AudioRecordingIntent)") + func appExtensionSupportedModes() throws { + let module = ScannedModule( + intents: [ + ScannedIntent( + typeName: "WidgetConfig", + kind: .struct, + isDiscoverable: true, + protocolNames: ["WidgetConfigurationIntent"], + parameters: [], + returnsValue: false + ), + ScannedIntent( + typeName: "AudioIntent", + kind: .struct, + isDiscoverable: true, + protocolNames: ["AudioRecordingIntent"], + parameters: [], + returnsValue: false + ) + ] + ) + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let outputDir = dir.appendingPathComponent("Metadata.appintents") + try Emitter().emit(module: module, inputs: makeInputs(isAppExtension: true), outputDir: outputDir) + + let actionsData = try Data(contentsOf: outputDir.appendingPathComponent("extract.actionsdata")) + let json = try JSONSerialization.jsonObject(with: actionsData) as? [String: Any] ?? [:] + let actions = json["actions"] as? [String: Any] ?? [:] + let widgetAction = actions["WidgetConfig"] as? [String: Any] ?? [:] + #expect(widgetAction["supportedModes"] as? Int == 2) + #expect(widgetAction["outputFlags"] as? Int == 0) + #expect(!widgetAction.keys.contains("outputType")) + + let audioAction = actions["AudioIntent"] as? [String: Any] ?? [:] + #expect(audioAction["supportedModes"] as? Int == 1) + #expect(!audioAction.keys.contains("outputType")) + } + + @Test("Provider declared in a different module mangles with that module's name") + func crossModuleProviderMangledName() throws { + // iMoonshine layout: host product is `iMoonshine`; both the + // `iMoonshineShortcuts` provider and `ToggleRecordingIntent` live in + // the shared `iMoonshineCore` library target. The emitter must use + // `iMoonshineCore` for the mangled names, not the host module. + let module = ScannedModule( + intents: [ + ScannedIntent( + typeName: "ToggleRecordingIntent", + kind: .struct, + module: "iMoonshineCore", + isDiscoverable: true, + protocolNames: ["AudioRecordingIntent"], + returnsValue: true + ) + ], + shortcutsProviders: [ + ScannedShortcutsProvider( + typeName: "iMoonshineShortcuts", + kind: .struct, + module: "iMoonshineCore", + shortcuts: [ + ScannedShortcut( + intentTypeName: "ToggleRecordingIntent", + phrases: ["Toggle iMoonshine"] + ) + ] + ) + ] + ) + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let outputDir = dir.appendingPathComponent("Metadata.appintents") + try Emitter().emit(module: module, inputs: makeInputs(), outputDir: outputDir) + + let data = try Data(contentsOf: outputDir.appendingPathComponent("extract.actionsdata")) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + + #expect(json["autoShortcutProviderMangledName"] as? String + == "14iMoonshineCore19iMoonshineShortcutsV") + + let actions = json["actions"] as? [String: Any] ?? [:] + let action = actions["ToggleRecordingIntent"] as? [String: Any] ?? [:] + #expect(action["fullyQualifiedTypeName"] as? String + == "iMoonshineCore.ToggleRecordingIntent") + #expect(action["mangledTypeName"] as? String + == "14iMoonshineCore21ToggleRecordingIntentV") + } + + @Test("Emits 'packages' array for AppIntentsPackage declarations") + func emitsPackages() throws { + let module = ScannedModule( + packages: [ + ScannedPackage(typeName: "iMoonshineIntentsPackage", kind: .struct, module: "iMoonshineCore") + ] + ) + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let outputDir = dir.appendingPathComponent("Metadata.appintents") + try Emitter().emit(module: module, inputs: makeInputs(), outputDir: outputDir) + + let actionsData = try Data(contentsOf: outputDir.appendingPathComponent("extract.actionsdata")) + let json = try JSONSerialization.jsonObject(with: actionsData) as? [String: Any] ?? [:] + let packages = json["packages"] as? [[String: Any]] ?? [] + #expect(packages.count == 1) + #expect(packages[0]["identifier"] as? String == "iMoonshineIntentsPackage") + #expect(packages[0]["mangledTypeName"] as? String == "14iMoonshineCore24iMoonshineIntentsPackageV") + } + + @Test("Skips writing the bundle when there are no AppIntents") + func emptyModule() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let outputDir = dir.appendingPathComponent("Metadata.appintents") + let module = try Generator().generate( + sourceRoots: [], + inputs: makeInputs(), + outputDir: outputDir + ) + #expect(module.isEmpty) + #expect(!FileManager.default.fileExists(atPath: outputDir.path)) + } +} + +@Suite("MangledName") struct MangledNameTests { + + @Test("Encodes top-level Swift types with the lightweight Apple grammar") + func encodesTopLevelTypes() { + // Reference fixtures from Wispr Flow 1.55: + // Flow.StartStopRecordingAppIntent (struct) -> 4Flow27StartStopRecordingAppIntentV + // Widgets.NoteAppIntent (struct) -> 7Widgets13NoteAppIntentV + // Flow.ShortcutsProvider (struct) -> 4Flow17ShortcutsProviderV + #expect(MangledName.encode(module: "Flow", typeName: "StartStopRecordingAppIntent", kind: .struct) + == "4Flow27StartStopRecordingAppIntentV") + #expect(MangledName.encode(module: "Widgets", typeName: "NoteAppIntent", kind: .struct) + == "7Widgets13NoteAppIntentV") + #expect(MangledName.encode(module: "Flow", typeName: "ShortcutsProvider", kind: .struct) + == "4Flow17ShortcutsProviderV") + } + + @Test("Picks the right kind suffix per declaration kind") + func kindSuffix() { + #expect(MangledName.encode(module: "M", typeName: "T", kind: .struct).last == "V") + #expect(MangledName.encode(module: "M", typeName: "T", kind: .class).last == "C") + #expect(MangledName.encode(module: "M", typeName: "T", kind: .enum).last == "O") + } +} + +@Suite("SystemProtocols") struct SystemProtocolsTests { + + @Test("AudioRecordingIntent maps to the confirmed pair of reverse-DNS protocols") + func audioRecording() { + let resolved = SystemProtocols.resolve(for: ["AudioRecordingIntent"]) + #expect(resolved == [ + "com.apple.link.systemProtocol.SessionStarting", + "com.apple.link.systemProtocol.AudioRecording", + ]) + } + + @Test("Plain AppIntent has no system protocols") + func plainAppIntent() { + #expect(SystemProtocols.resolve(for: ["AppIntent"]).isEmpty) + } + + @Test("De-duplicates across multiple conformances") + func dedup() { + let resolved = SystemProtocols.resolve(for: ["AudioRecordingIntent", "AudioRecordingIntent"]) + #expect(resolved.count == 2) + } + + @Test("Module-qualified protocol names match by trailing identifier") + func moduleQualified() { + let resolved = SystemProtocols.resolve(for: ["AppIntents.AudioRecordingIntent"]) + #expect(resolved.contains("com.apple.link.systemProtocol.AudioRecording")) + } +} diff --git a/Tests/AppIntentsGenTests/ScannerTests.swift b/Tests/AppIntentsGenTests/ScannerTests.swift new file mode 100644 index 00000000..5476bf56 --- /dev/null +++ b/Tests/AppIntentsGenTests/ScannerTests.swift @@ -0,0 +1,212 @@ +import Foundation +import Testing + +@testable import AppIntentsGen + +@Suite("Scanner") struct ScannerTests { + + @Test("Recognises a basic AppIntent with parameters") + func basicAppIntent() { + let source = """ + import AppIntents + + struct ToggleRecordingIntent: AppIntent { + static let title: LocalizedStringResource = "Toggle iMoonshine" + static let description = IntentDescription("Start or stop recording.", categoryName: "iMoonshine") + static let openAppWhenRun: Bool = false + static let isDiscoverable: Bool = true + + @Parameter(title: "Loud", description: "Whether to be loud") var loud: Bool + @Parameter(title: "Note") var note: String? + + func perform() async throws -> some IntentResult & ReturnsValue { + .result(value: "") + } + } + """ + let module = Scanner().scan(source: source) + #expect(module.intents.count == 1) + let intent = module.intents[0] + #expect(intent.typeName == "ToggleRecordingIntent") + #expect(intent.title == "Toggle iMoonshine") + #expect(intent.descriptionText == "Start or stop recording.") + #expect(intent.categoryName == "iMoonshine") + #expect(intent.openAppWhenRun == false) + #expect(intent.isDiscoverable == true) + #expect(intent.returnsValue == true) + #expect(intent.parameters.count == 2) + #expect(intent.parameters[0].propertyName == "loud") + #expect(intent.parameters[0].typeName == "Bool") + #expect(intent.parameters[0].title == "Loud") + #expect(intent.parameters[0].descriptionText == "Whether to be loud") + #expect(intent.parameters[0].isOptional == false) + #expect(intent.parameters[1].propertyName == "note") + #expect(intent.parameters[1].typeName == "String?") + #expect(intent.parameters[1].isOptional == true) + } + + @Test("Picks up AppShortcutsProvider phrases inside a result-builder body") + func shortcutsProvider() { + let source = """ + import AppIntents + + struct iMoonshineShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ToggleRecordingIntent(), + phrases: [ + "Toggle \\(.applicationName)", + "Start \\(.applicationName) recording", + "Stop \\(.applicationName) recording" + ], + shortTitle: "iMoonshine Toggle", + systemImageName: "mic.circle.fill" + ) + } + } + """ + let module = Scanner().scan(source: source) + #expect(module.shortcutsProviders.count == 1) + let provider = module.shortcutsProviders[0] + #expect(provider.typeName == "iMoonshineShortcuts") + #expect(provider.shortcuts.count == 1) + let shortcut = provider.shortcuts[0] + #expect(shortcut.intentTypeName == "ToggleRecordingIntent") + #expect(shortcut.shortTitle == "iMoonshine Toggle") + #expect(shortcut.systemImageName == "mic.circle.fill") + #expect(shortcut.phrases.count == 3) + #expect(shortcut.phrases[0] == "Toggle \\(.applicationName)") + } + + @Test("Returns an empty module when no AppIntents are present") + func emptyModule() { + let source = """ + import Foundation + + struct PlainOldStruct { + var x: Int = 0 + } + """ + let module = Scanner().scan(source: source) + #expect(module.isEmpty) + } + + @Test("Stamps the active module name onto each scanned declaration") + func moduleAttribution() { + let intentSource = """ + import AppIntents + + struct ToggleRecordingIntent: AppIntent { + static let title: LocalizedStringResource = "Toggle" + func perform() async throws -> some IntentResult { .result() } + } + + struct iMoonshineShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut(intent: ToggleRecordingIntent(), phrases: ["Toggle"]) + } + } + """ + let hostSource = """ + import AppIntents + struct OpenAppIntent: AppIntent { + static let title: LocalizedStringResource = "Open" + func perform() async throws -> some IntentResult { .result() } + } + """ + var module = Scanner().scan(source: intentSource, module: "iMoonshineCore") + module.merge(Scanner().scan(source: hostSource, module: "iMoonshine")) + + let providerModules = module.shortcutsProviders.map(\.module) + #expect(providerModules == ["iMoonshineCore"]) + + let intentByName = Dictionary(uniqueKeysWithValues: module.intents.map { ($0.typeName, $0) }) + #expect(intentByName["ToggleRecordingIntent"]?.module == "iMoonshineCore") + #expect(intentByName["OpenAppIntent"]?.module == "iMoonshine") + } + + @Test("returnsValue=false when perform() returns plain IntentResult without ReturnsValue") + func returnsValueRequiresReturnsValueProtocol() { + let source = """ + import AppIntents + + struct VoidIntent: AppIntent { + static let title: LocalizedStringResource = "Void" + func perform() async throws -> some IntentResult { .result() } + } + """ + let module = Scanner().scan(source: source) + #expect(module.intents.count == 1) + #expect(module.intents[0].returnsValue == false) + } + + @Test("returnsValue=true when ReturnsValue appears anywhere in composition") + func returnsValueDetectedInComposition() { + let source = """ + import AppIntents + + struct A: AppIntent { + static let title: LocalizedStringResource = "A" + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + .result(value: "x", dialog: "ok") + } + } + + struct B: AppIntent { + static let title: LocalizedStringResource = "B" + func perform() async throws -> ReturnsValue { fatalError() } + } + + struct C: AppIntent { + static let title: LocalizedStringResource = "C" + func perform() async throws -> AppIntents.ReturnsValue { fatalError() } + } + """ + let module = Scanner().scan(source: source) + let byName = Dictionary(uniqueKeysWithValues: module.intents.map { ($0.typeName, $0) }) + #expect(byName["A"]?.returnsValue == true) + #expect(byName["B"]?.returnsValue == true) + #expect(byName["C"]?.returnsValue == true) + } + + @Test("returnsValue=false when perform() has no return clause") + func returnsValueFalseWhenNoReturnClause() { + let source = """ + import AppIntents + + struct NoReturn: AppIntent { + static let title: LocalizedStringResource = "NR" + func perform() async throws { } + } + """ + let module = Scanner().scan(source: source) + #expect(module.intents.first?.returnsValue == false) + } + + @Test("Captures AppEntity and AppEnum declarations") + func entitiesAndEnums() { + let source = """ + import AppIntents + + struct Note: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation { .init(name: "Note") } + var displayRepresentation: DisplayRepresentation { .init(title: "x") } + static var defaultQuery = NoteQuery() + var id: String + } + + enum Mood: String, AppEnum { + case happy + case sad + static var typeDisplayRepresentation: TypeDisplayRepresentation { .init(name: "Mood") } + static var caseDisplayRepresentations: [Mood: DisplayRepresentation] { [:] } + } + """ + let module = Scanner().scan(source: source) + #expect(module.entities.count == 1) + #expect(module.entities[0].typeName == "Note") + #expect(module.enums.count == 1) + #expect(module.enums[0].typeName == "Mood") + #expect(module.enums[0].cases == ["happy", "sad"]) + } +}