diff --git a/Nativ.xcodeproj/project.pbxproj b/Nativ.xcodeproj/project.pbxproj index 39d78cb9..fd2c5ef7 100644 --- a/Nativ.xcodeproj/project.pbxproj +++ b/Nativ.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 0B5170A046C3C587DE077072 /* PDFDocumentTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8513AE482EB6C3113080826A /* PDFDocumentTextExtractorTests.swift */; }; 0C9F43690B51C4F3012E9DD2 /* ControlPanelSidebarDragAndDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 087534BC45A233134507ED19 /* ControlPanelSidebarDragAndDrop.swift */; }; 0CEEC986F8617E501223DA3E /* NativExtensionMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D941186B3A4BA46FB90201DE /* NativExtensionMessaging.swift */; }; + 0D047002DDEE16DDEA5D947B /* NativExtensionPackageInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA21CD9722CBCB2CF7905AA /* NativExtensionPackageInstaller.swift */; }; 0DF842E2BE07D345918F3D54 /* Manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 5AF8F6359E4523814FB0EFDA /* Manifest.json */; }; 0E326825AE5A1A5EEA33D928 /* NativExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0DA5C4D0B6B5A094E13CCC8 /* NativExtensionTests.swift */; }; 0F60E87BC41F29381BD81F73 /* TextDocumentTextExtractors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91D4332044DCC5DCE4B27F6 /* TextDocumentTextExtractors.swift */; }; @@ -335,6 +336,7 @@ AEF81776985AD1104B189BFE /* SearXNGBrowsingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7552A0926132AC026ED6F530 /* SearXNGBrowsingProvider.swift */; }; AFBC36570418275354CE129B /* ExaBrowsingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769A36AA0753AB7F4CB1E371 /* ExaBrowsingProvider.swift */; }; B15353CDE6204176664DB3B2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0E2EE61C1652C49ECC69658 /* AppDelegate.swift */; }; + B2464ADB1853CEAEBBA5F9B6 /* NativExtensionPackageInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA21CD9722CBCB2CF7905AA /* NativExtensionPackageInstaller.swift */; }; B25D81298D9DB66967A5FB84 /* MarkdownText.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DB6DEB5C434C27FE9AF66 /* MarkdownText.swift */; }; B2B154008527C6291C17AC93 /* MLXImageModelResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61126D4235AFF77D82F1F80B /* MLXImageModelResolver.swift */; }; B38B5B7EFE4D34BBB26198AA /* ChatTranscriptScroller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 318F0B7627C7BB80B04FF747 /* ChatTranscriptScroller.swift */; }; @@ -466,6 +468,7 @@ F7F573F15C7DC5CD17061EB9 /* ChatProjectStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11A25CB7C798C39A206FACC /* ChatProjectStore.swift */; }; F945C21FDDC8AE619860D222 /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = B47FB8D68E53A933ACF71A58 /* Highlightr */; }; F9B4AAA3C467240C00807753 /* ChatReadFileToolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56181DA223153BACC750E96 /* ChatReadFileToolTests.swift */; }; + FB50CA1DB30A9FAD59C8ACFF /* NativExtensionPackageInstallerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989C43F61A70769AC90B5CF3 /* NativExtensionPackageInstallerTests.swift */; }; FBF7379DD55C920F6D5C4197 /* ControlPanelDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90494E1AE30EF515482DDBE7 /* ControlPanelDependencies.swift */; }; FC65EA7675E8441D11372F9C /* ChatDocumentContextBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F56569EDB0E77FAF6269F577 /* ChatDocumentContextBuilderTests.swift */; }; FD18166B4B3FAEB77E0D8C69 /* CodexCLIProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5BF52CED5DE873DD6BF02B /* CodexCLIProfile.swift */; }; @@ -689,6 +692,7 @@ 6BF298015C5DDB04B72C29A9 /* ControlPanelSidebarRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanelSidebarRows.swift; sourceTree = ""; }; 6CDD9C3FE179B2E8D76FC769 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 6D4ED7BDFBC000148E13EA19 /* PowerPointDocumentTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerPointDocumentTextExtractor.swift; sourceTree = ""; }; + 6DA21CD9722CBCB2CF7905AA /* NativExtensionPackageInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativExtensionPackageInstaller.swift; sourceTree = ""; }; 7069F17AAC82BF3F1FD1C95E /* NativBulkSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativBulkSelection.swift; sourceTree = ""; }; 711EAE65183E78E000DD3253 /* GitHubOAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubOAuthTests.swift; sourceTree = ""; }; 7405BB62742DE08EB640A2C5 /* VoiceShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceShortcut.swift; sourceTree = ""; }; @@ -748,6 +752,7 @@ 96185929F990EA1470079AAB /* NativEmbeddingsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativEmbeddingsClient.swift; sourceTree = ""; }; 97D987724FB28974BA95CF29 /* ChatTerminalToolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTerminalToolTests.swift; sourceTree = ""; }; 982F3BB563023885587DC860 /* NativNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativNotificationTests.swift; sourceTree = ""; }; + 989C43F61A70769AC90B5CF3 /* NativExtensionPackageInstallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativExtensionPackageInstallerTests.swift; sourceTree = ""; }; 995095FAF89C9D1DEC4158FB /* Exa.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = Exa.png; sourceTree = ""; }; 9A7A7ACB288953FD476C47E8 /* MCPServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPServerConfig.swift; sourceTree = ""; }; 9AF7F0D894C4EB6521F21D4D /* SystemMonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitorView.swift; sourceTree = ""; }; @@ -1407,6 +1412,7 @@ A05824815E9047B7042D766D /* NativExtensionCatalogClient.swift */, 586D3A7976823159DE5C00F9 /* NativExtensionHostBroker.swift */, 1DC0B3C7BCF8243FF1A3CA56 /* NativExtensionMarketplace.swift */, + 6DA21CD9722CBCB2CF7905AA /* NativExtensionPackageInstaller.swift */, 7BD28C5E1607C8E6025B7240 /* NativExtensionPlatform.swift */, 27F42CEC1B39477AC13352E3 /* NativExtensionStateStore.swift */, AA7F610FC8739BA80227DB26 /* NativKit.swift */, @@ -1583,6 +1589,7 @@ 2BDBC269531FA6C6DA9F5DDC /* ModelPrimaryTaskResolverTests.swift */, 6964F9653F9E59196C2EDACB /* NativBulkSelectionTests.swift */, 2336F0C9DD984723FB930AD4 /* NativExtensionCatalogTests.swift */, + 989C43F61A70769AC90B5CF3 /* NativExtensionPackageInstallerTests.swift */, E0DA5C4D0B6B5A094E13CCC8 /* NativExtensionTests.swift */, 780A9582305FC184F6C68D12 /* NativKitTests.swift */, 982F3BB563023885587DC860 /* NativNotificationTests.swift */, @@ -2022,6 +2029,7 @@ 584E2B79AC7BCD949CC53070 /* NativExtensionCatalogClient.swift in Sources */, 39ADE4524CF22ED36D505546 /* NativExtensionHostBroker.swift in Sources */, F4F5A265970521BCC2273D23 /* NativExtensionMarketplace.swift in Sources */, + B2464ADB1853CEAEBBA5F9B6 /* NativExtensionPackageInstaller.swift in Sources */, F6BFEEE99336BB34E3194214 /* NativExtensionPlatform.swift in Sources */, 2B988CEEF0FD7FCCD3862AF6 /* NativExtensionStateStore.swift in Sources */, C03E948D1C7E27B27838C4C6 /* NativKit.swift in Sources */, @@ -2240,6 +2248,8 @@ 83E74199C4F9A50350E3E354 /* NativComponents.swift in Sources */, 385B524770D5084E48B80F41 /* NativExtensionCatalogClient.swift in Sources */, 3D2458AA39D0AE10CCACE7CD /* NativExtensionCatalogTests.swift in Sources */, + 0D047002DDEE16DDEA5D947B /* NativExtensionPackageInstaller.swift in Sources */, + FB50CA1DB30A9FAD59C8ACFF /* NativExtensionPackageInstallerTests.swift in Sources */, ABB0814F3990772A1BEF7D0C /* NativExtensionStateStore.swift in Sources */, 0E326825AE5A1A5EEA33D928 /* NativExtensionTests.swift in Sources */, D849D6A8E4B6C43284891EA9 /* NativKit.swift in Sources */, diff --git a/Sources/Nativ/Features/ControlPanel/ControlPanelState.swift b/Sources/Nativ/Features/ControlPanel/ControlPanelState.swift index 20f3e5b8..b14e644b 100644 --- a/Sources/Nativ/Features/ControlPanel/ControlPanelState.swift +++ b/Sources/Nativ/Features/ControlPanel/ControlPanelState.swift @@ -165,7 +165,7 @@ final class ControlPanelContentState: ObservableObject { ) extensionManager.$records - .map(Self.enabledSidebarContributions) + .map(NativExtensionSidebarOrdering.contributions(from:)) .removeDuplicates() .sink { [weak self] value in self?.update { $0.extensionSidebarContributions = value } @@ -190,18 +190,4 @@ final class ControlPanelContentState: ObservableObject { guard next != snapshot else { return } snapshot = next } - - private static func enabledSidebarContributions( - records: [NativExtensionRecord] - ) -> [NativSidebarContribution] { - records - .filter { $0.isEnabled && $0.hasRuntime } - .flatMap(\.manifest.contributions.sidebar) - .sorted { - if $0.order == $1.order { - return $0.title.localizedStandardCompare($1.title) == .orderedAscending - } - return $0.order < $1.order - } - } } diff --git a/Sources/Nativ/Features/Extensions/ExtensionsHubView.swift b/Sources/Nativ/Features/Extensions/ExtensionsHubView.swift index 98b91d8e..36282ec3 100644 --- a/Sources/Nativ/Features/Extensions/ExtensionsHubView.swift +++ b/Sources/Nativ/Features/Extensions/ExtensionsHubView.swift @@ -2,6 +2,7 @@ import AppKit import NativExtensionSDK import NativServerKit import SwiftUI +import UniformTypeIdentifiers struct ExtensionsHubView: View { @ObservedObject var manager: NativExtensionManager @@ -168,6 +169,47 @@ struct HubEmptyHint: View { // MARK: - Extensions section +struct NoticeRow: View { + let tone: NativStatusTone + let title: String + var detail: String? + var onDismiss: (() -> Void)? + + private var symbol: String { + switch tone { + case .danger: "exclamationmark.triangle.fill" + case .warning: "exclamationmark.circle.fill" + default: "info.circle.fill" + } + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .foregroundStyle(tone.color) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .legacyTextStyle(.supporting) + if let detail { + Text(detail) + .legacyTextStyle(.metadata) + .foregroundStyle(.secondary) + } + } + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + if let onDismiss { + NativHoverCloseButton(action: onDismiss, help: "Dismiss") + } + } + .padding(12) + .background( + tone.color.opacity(0.08), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + } +} + private struct ExtensionsSectionView: View { @ObservedObject var manager: NativExtensionManager @State private var selection: Selection = .discover @@ -197,26 +239,70 @@ private struct ExtensionsSectionView: View { case .discover: NativExtensionMarketplaceView() case .installed: - if manager.records.isEmpty { - HubEmptyHint( - icon: "square.stack.3d.up.slash", - text: "No extensions installed." - ) - } else { - VStack(spacing: 12) { - ForEach(manager.records) { record in - ExtensionRow(record: record, manager: manager) - } - } - } + installedTab } } .onAppear { manager.refreshPermissionStatuses() } } -} + private var installedTab: some View { + VStack(alignment: .leading, spacing: 12) { + if let message = manager.lastErrorMessage { + NoticeRow(tone: .danger, title: message) { + manager.lastErrorMessage = nil + } + } + ForEach(manager.packageIssues) { issue in + NoticeRow( + tone: .warning, + title: "\(issue.packageName) was not loaded.", + detail: issue.message + ) + } + if manager.records.isEmpty { + HubEmptyHint( + icon: "square.stack.3d.up.slash", + text: "No extensions installed." + ) + } else { + ForEach(manager.records) { record in + ExtensionRow(record: record, manager: manager) + } + } + installFromFolderButton + } + } + + private var installFromFolderButton: some View { + HStack(spacing: 8) { + Button("Install from Folder…") { + installFromFolder() + } + .controlSize(.small) + Text("Load a .nativextension package you are developing.") + .legacyTextStyle(.metadata) + .foregroundStyle(.tertiary) + } + .padding(.top, 2) + } + + private func installFromFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.treatsFilePackagesAsDirectories = false + panel.allowedContentTypes = [UTType(exportedAs: "com.nativ.extension-package", conformingTo: .package)] + panel.allowsMultipleSelection = false + panel.prompt = "Install" + panel.message = "Choose a .nativextension package." + guard panel.runModal() == .OK, let url = panel.url else { + return + } + manager.installPackage(at: url) + } +} private struct ExtensionRow: View { let record: NativExtensionRecord @ObservedObject var manager: NativExtensionManager @@ -229,7 +315,12 @@ private struct ExtensionRow: View { HStack(spacing: 8) { Text(record.manifest.displayName) .legacyTextStyle(.compactCardTitle) - if record.isIncluded { includedBadge } + if record.isIncluded { + NativStatusBadge(text: "Included", tone: .active) + } + if record.isRemoved { + NativStatusBadge(text: "Removed", tone: .neutral) + } } Text(record.manifest.summary) .legacyTextStyle(.supporting) @@ -241,20 +332,31 @@ private struct ExtensionRow: View { .padding(.top, 1) } Spacer(minLength: 12) - Toggle( - "", - isOn: Binding( - get: { record.isEnabled }, - set: { manager.setEnabled($0, extensionID: record.id) } + if record.isRemoved { + Button("Restore") { + manager.restore(extensionID: record.id) + } + .controlSize(.small) + } else { + Toggle( + "", + isOn: Binding( + get: { record.isEnabled }, + set: { manager.setEnabled($0, extensionID: record.id) } + ) ) - ) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.small) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + removeMenu + } + } + if let errorMessage = record.errorMessage { + sectionDivider + NoticeRow(tone: .warning, title: errorMessage) } if !record.manifest.permissions.isEmpty { - Divider() - .padding(.vertical, 14) + sectionDivider permissions } } @@ -269,8 +371,27 @@ private struct ExtensionRow: View { ) } - private var includedBadge: some View { - NativStatusBadge(text: "Included", tone: .active) + private var sectionDivider: some View { + Divider() + .padding(.vertical, 14) + } + + private var removeMenu: some View { + Menu { + Button("Remove Extension", role: .destructive) { + manager.remove(extensionID: record.id) + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(width: 18) + .help( + record.isIncluded + ? "Hide this included extension" + : "Delete this extension package" + ) } private var permissions: some View { @@ -292,7 +413,7 @@ private struct ExtensionRow: View { ) -> some View { let status = manager.permissionStatus(permission) let actionTitle = extensionIsEnabled - ? manager.permissionActionTitle(permission) + ? manager.permissionActionTitle(permission, status: status) : nil if let actionTitle { Button { diff --git a/Sources/Nativ/Features/Extensions/NativExtensionPackageInstaller.swift b/Sources/Nativ/Features/Extensions/NativExtensionPackageInstaller.swift new file mode 100644 index 00000000..d8f9f418 --- /dev/null +++ b/Sources/Nativ/Features/Extensions/NativExtensionPackageInstaller.swift @@ -0,0 +1,241 @@ +import Foundation +import NativExtensionSDK + +enum NativExtensionPackageError: LocalizedError, Equatable { + case packageMustBeDirectory + case missingManifest + case duplicateIdentifier(String) + case externalPackageClaimsIncluded + case unsupportedExternalRuntime + case runtimeUnavailable + case olderVersionRejected(identifier: String, installed: String, candidate: String) + case malformedManifest(String) + + var errorDescription: String? { + switch self { + case .packageMustBeDirectory: + "Choose a .nativextension package." + case .missingManifest: + "The extension package does not contain Manifest.json." + case .duplicateIdentifier(let identifier): + "An extension with the identifier “\(identifier)” is already included with Nativ." + case .externalPackageClaimsIncluded: + "Only extensions shipped inside Nativ can declare themselves as included." + case .unsupportedExternalRuntime: + "An installed extension must declare the extensionFoundation runtime." + case .runtimeUnavailable: + "The extension was installed, but its ExtensionFoundation runtime is not available yet." + case .olderVersionRejected(let identifier, let installed, let candidate): + "“\(identifier)” \(installed) is already installed. Remove it before installing \(candidate)." + case .malformedManifest(let detail): + detail + } + } +} + +/// An installed package and where it lives on disk. +struct NativExtensionInstalledPackage: Sendable { + let manifest: NativExtensionManifest + let packageURL: URL +} + +/// A package that could not be loaded, kept so the Extensions page can explain +/// the failure instead of leaving the package silently absent. +struct NativExtensionPackageIssue: Identifiable, Hashable, Sendable { + let packageURL: URL + let message: String + + var id: URL { packageURL } + var packageName: String { packageURL.lastPathComponent } +} + +/// Filesystem half of the extension platform: reading, validating, installing, +/// and removing `.nativextension` packages. +/// +/// Deliberately free of AppKit, ExtensionFoundation, and `@MainActor` so the +/// install rules can be exercised directly in tests. +struct NativExtensionPackageInstaller { + /// Staging directories are hidden so a partially copied package is never + /// mistaken for an installed one; they are swept on load. + static let stagingPrefix = ".install-" + static let packageExtension = "nativextension" + + let fileManager: FileManager + let extensionsDirectory: URL + let hostVersion: String + + struct LoadResult { + let manifests: [String: NativExtensionInstalledPackage] + let issues: [NativExtensionPackageIssue] + } + + struct InstallResult { + let manifest: NativExtensionManifest + let replaced: NativExtensionManifest? + + /// A package asking for more than the previous version has to be + /// reviewed again. Asking for the same or less inherits the state the + /// user already chose, so a strictly safer update does not disable it. + var requiresReconsent: Bool { + guard let replaced else { return true } + return !Set(manifest.permissions).isSubset(of: Set(replaced.permissions)) + } + } + + func loadManifest(at packageURL: URL) throws -> NativExtensionManifest { + let manifestURL = packageURL.appendingPathComponent("Manifest.json") + guard fileManager.fileExists(atPath: manifestURL.path) else { + throw NativExtensionPackageError.missingManifest + } + let manifest: NativExtensionManifest + do { + manifest = try JSONDecoder().decode( + NativExtensionManifest.self, + from: Data(contentsOf: manifestURL) + ) + } catch let error as DecodingError { + throw NativExtensionPackageError.malformedManifest(Self.describe(error)) + } + try NativExtensionManifestValidator.validate(manifest, hostVersion: hostVersion) + return manifest + } + + /// `DecodingError`'s own description does not name the offending field, which + /// is the only thing an extension author needs in order to fix the file. + private static func describe(_ error: DecodingError) -> String { + func path(_ context: DecodingError.Context) -> String { + context.codingPath.map(\.stringValue).joined(separator: ".") + } + switch error { + case .keyNotFound(let key, let context): + let parent = path(context) + let location = parent.isEmpty ? "" : " in “\(parent)”" + return "Manifest.json is missing the required field “\(key.stringValue)”\(location)." + case .typeMismatch(_, let context), + .valueNotFound(_, let context), + .dataCorrupted(let context): + let field = path(context) + return field.isEmpty + ? "Manifest.json is not valid JSON." + : "Manifest.json has an unexpected value for “\(field)”." + @unknown default: + return "Manifest.json could not be read." + } + } + + func loadInstalledPackages( + reservedIdentifiers: Set + ) -> LoadResult { + guard let entries = try? fileManager.contentsOfDirectory( + at: extensionsDirectory, + includingPropertiesForKeys: [.isDirectoryKey] + ) else { + return LoadResult(manifests: [:], issues: []) + } + + var manifests: [String: NativExtensionInstalledPackage] = [:] + var issues: [NativExtensionPackageIssue] = [] + for entry in entries { + // Staging directories are hidden, so this pass is the only thing + // that ever sees one orphaned by an interrupted install. + if entry.lastPathComponent.hasPrefix(Self.stagingPrefix) { + try? fileManager.removeItem(at: entry) + continue + } + guard entry.pathExtension == Self.packageExtension else { + continue + } + do { + let manifest = try loadManifest(at: entry) + guard !reservedIdentifiers.contains(manifest.id) else { + continue + } + manifests[manifest.id] = NativExtensionInstalledPackage( + manifest: manifest, + packageURL: entry + ) + } catch { + issues.append( + NativExtensionPackageIssue( + packageURL: entry, + message: error.localizedDescription + ) + ) + } + } + return LoadResult(manifests: manifests, issues: issues) + } + + @discardableResult + func install( + from sourceURL: URL, + reservedIdentifiers: Set + ) throws -> InstallResult { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), + isDirectory.boolValue, + sourceURL.pathExtension == Self.packageExtension else { + throw NativExtensionPackageError.packageMustBeDirectory + } + + let manifest = try loadManifest(at: sourceURL) + guard !reservedIdentifiers.contains(manifest.id) else { + throw NativExtensionPackageError.duplicateIdentifier(manifest.id) + } + guard !manifest.included else { + throw NativExtensionPackageError.externalPackageClaimsIncluded + } + // The builtIn runtime means the code ships inside Nativ, so an installed + // package declaring it has nothing to run and would otherwise sit in the + // list forever reporting a missing runtime. + guard manifest.runtime == .extensionFoundation else { + throw NativExtensionPackageError.unsupportedExternalRuntime + } + + let destinationURL = packageURL(for: manifest.id) + let replaced = try? loadManifest(at: destinationURL) + if let replaced, + let candidateVersion = NativSemanticVersion(manifest.version), + let installedVersion = NativSemanticVersion(replaced.version), + candidateVersion < installedVersion { + throw NativExtensionPackageError.olderVersionRejected( + identifier: manifest.id, + installed: installedVersion.description, + candidate: candidateVersion.description + ) + } + + try fileManager.createDirectory( + at: extensionsDirectory, + withIntermediateDirectories: true + ) + let stagingURL = extensionsDirectory.appendingPathComponent( + "\(Self.stagingPrefix)\(UUID().uuidString)", + isDirectory: true + ) + do { + try fileManager.copyItem(at: sourceURL, to: stagingURL) + if fileManager.fileExists(atPath: destinationURL.path) { + _ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL) + } else { + try fileManager.moveItem(at: stagingURL, to: destinationURL) + } + } catch { + try? fileManager.removeItem(at: stagingURL) + throw error + } + + return InstallResult(manifest: manifest, replaced: replaced) + } + + func removePackage(at packageURL: URL) throws { + try fileManager.removeItem(at: packageURL) + } + + func packageURL(for identifier: String) -> URL { + extensionsDirectory.appendingPathComponent( + "\(identifier).\(Self.packageExtension)", + isDirectory: true + ) + } +} diff --git a/Sources/Nativ/Features/Extensions/NativExtensionPlatform.swift b/Sources/Nativ/Features/Extensions/NativExtensionPlatform.swift index 2a9e7f96..05b0318a 100644 --- a/Sources/Nativ/Features/Extensions/NativExtensionPlatform.swift +++ b/Sources/Nativ/Features/Extensions/NativExtensionPlatform.swift @@ -10,6 +10,15 @@ enum NativExtensionOrigin: Hashable, Sendable { case included case external(URL) case system + + /// Extensions shipped inside Nativ sort ahead of ones that were installed, + /// so a package manifest cannot order itself above first-party navigation. + var precedence: Int { + switch self { + case .included: 0 + case .external, .system: 1 + } + } } struct NativExtensionRecord: Identifiable, Hashable, Sendable { @@ -52,26 +61,33 @@ extension NativHostExtension { func performCommand(id: String) {} } -enum NativExtensionPackageError: LocalizedError { - case packageMustBeDirectory - case missingManifest - case duplicateIdentifier(String) - case externalPackageClaimsIncluded - case runtimeUnavailable - - var errorDescription: String? { - switch self { - case .packageMustBeDirectory: - "Choose a .nativextension package." - case .missingManifest: - "The extension package does not contain Manifest.json." - case .duplicateIdentifier(let identifier): - "An extension with the identifier “\(identifier)” is already included with Nativ." - case .externalPackageClaimsIncluded: - "Only extensions shipped inside Nativ can declare themselves as included." - case .runtimeUnavailable: - "The extension was installed, but its ExtensionFoundation runtime is not available yet." - } +/// Ordering for extension-contributed sidebar items. +/// +/// `order` comes from a package manifest, so an external extension could +/// otherwise place itself above Nativ's own navigation. Included extensions are +/// always ordered ahead of external ones regardless of the value they declare. +enum NativExtensionSidebarOrdering { + static func contributions( + from records: [NativExtensionRecord] + ) -> [NativSidebarContribution] { + records + .filter { $0.isEnabled && $0.hasRuntime } + .flatMap { record in + record.manifest.contributions.sidebar.map { + (precedence: record.origin.precedence, contribution: $0) + } + } + .sorted { lhs, rhs in + if lhs.precedence != rhs.precedence { + return lhs.precedence < rhs.precedence + } + if lhs.contribution.order != rhs.contribution.order { + return lhs.contribution.order < rhs.contribution.order + } + return lhs.contribution.title + .localizedStandardCompare(rhs.contribution.title) == .orderedAscending + } + .map(\.contribution) } } @@ -87,6 +103,7 @@ final class NativExtensionManager: ObservableObject { } @Published private(set) var systemExtensionCount = 0 @Published private(set) var permissionRevision = 0 + @Published private(set) var packageIssues: [NativExtensionPackageIssue] = [] @Published var lastErrorMessage: String? var onRecordsChanged: (() -> Void)? @@ -94,8 +111,9 @@ final class NativExtensionManager: ObservableObject { private let fileManager: FileManager private let extensionsDirectory: URL private let hostVersion: String + private let installer: NativExtensionPackageInstaller private let builtIns: [String: any NativHostExtension] - private var externalManifests: [String: (NativExtensionManifest, URL)] = [:] + private var externalManifests: [String: NativExtensionInstalledPackage] = [:] private var activeExtensionIDs = Set() private var hostContext: NativExtensionHostContext? private var systemMonitor: AppExtensionPoint.Monitor? @@ -106,9 +124,10 @@ final class NativExtensionManager: ObservableObject { private var systemRuntimeStartTasks: [String: Task] = [:] private var applicationActivationObserver: NSObjectProtocol? private let permissionDefaults: UserDefaults - private let permissionRequestKey = "nativ.extension-platform.requested-permissions.v1" + private static let permissionRequestKey = + "nativ.extension-platform.requested-permissions.v1" private var requestedPermissions: Set - private var permissionSnapshot: [NativExtensionPermission: NativExtensionPermissionStatus] = [:] + private var grantedPermissionSnapshot: Set = [] init( builtInExtensions: [any NativHostExtension], @@ -127,9 +146,12 @@ final class NativExtensionManager: ObservableObject { ?? "0.1.0" self.permissionDefaults = permissionDefaults requestedPermissions = Set( - permissionDefaults.stringArray( - forKey: "nativ.extension-platform.requested-permissions.v1" - ) ?? [] + permissionDefaults.stringArray(forKey: Self.permissionRequestKey) ?? [] + ) + installer = NativExtensionPackageInstaller( + fileManager: fileManager, + extensionsDirectory: self.extensionsDirectory, + hostVersion: self.hostVersion ) builtIns = Dictionary(uniqueKeysWithValues: builtInExtensions.map { ($0.manifest.id, $0) @@ -138,15 +160,7 @@ final class NativExtensionManager: ObservableObject { } var enabledSidebarContributions: [NativSidebarContribution] { - records - .filter { $0.isEnabled && $0.hasRuntime } - .flatMap(\.manifest.contributions.sidebar) - .sorted { - if $0.order == $1.order { - return $0.title.localizedStandardCompare($1.title) == .orderedAscending - } - return $0.order < $1.order - } + NativExtensionSidebarOrdering.contributions(from: records) } func isEnabled(extensionID: String) -> Bool { @@ -157,7 +171,7 @@ final class NativExtensionManager: ObservableObject { func launch(context: NativExtensionHostContext) { hostContext = context - permissionSnapshot = currentPermissionSnapshot() + grantedPermissionSnapshot = currentGrantedPermissions() if applicationActivationObserver == nil { applicationActivationObserver = NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, @@ -230,10 +244,12 @@ final class NativExtensionManager: ObservableObject { if case .external(let packageURL) = record.origin { builtIns[extensionID]?.deactivate() + stopSystemRuntime(extensionID: extensionID) activeExtensionIDs.remove(extensionID) do { - try fileManager.removeItem(at: packageURL) + try installer.removePackage(at: packageURL) stateStore.clear(extensionID: extensionID) + lastErrorMessage = nil reloadInstalledPackages() } catch { lastErrorMessage = error.localizedDescription @@ -269,46 +285,29 @@ final class NativExtensionManager: ObservableObject { } do { - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), - isDirectory.boolValue, - sourceURL.pathExtension == "nativextension" else { - throw NativExtensionPackageError.packageMustBeDirectory - } - let manifest = try Self.loadManifest( + let result = try installer.install( from: sourceURL, - hostVersion: hostVersion + reservedIdentifiers: Set(builtIns.keys) ) - guard builtIns[manifest.id] == nil else { - throw NativExtensionPackageError.duplicateIdentifier(manifest.id) - } - guard !manifest.included else { - throw NativExtensionPackageError.externalPackageClaimsIncluded - } - - try fileManager.createDirectory( - at: extensionsDirectory, - withIntermediateDirectories: true - ) - let stagingURL = extensionsDirectory - .appendingPathComponent(".install-\(UUID().uuidString)", isDirectory: true) - let destinationURL = extensionsDirectory - .appendingPathComponent("\(manifest.id).nativextension", isDirectory: true) - try fileManager.copyItem(at: sourceURL, to: stagingURL) - if fileManager.fileExists(atPath: destinationURL.path) { - _ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL) - } else { - try fileManager.moveItem(at: stagingURL, to: destinationURL) - } // Installing code and granting it permission to run are separate - // decisions. The user enables the extension after reviewing the - // manifest and permission badges in Extensions. - stateStore.set(.disabled, for: manifest.id) + // decisions. A first install, or an update that asks for more than + // the last one, returns to disabled so the user reviews it. + if result.requiresReconsent { + stateStore.set(.disabled, for: result.manifest.id) + } + setLastError(nil) reloadInstalledPackages() reconcileLifecycle() } catch { - lastErrorMessage = error.localizedDescription + setLastError(error.localizedDescription) + } + } + + private func setLastError(_ message: String?) { + guard lastErrorMessage != message else { + return } + lastErrorMessage = message } func makePage( @@ -340,32 +339,40 @@ final class NativExtensionManager: ObservableObject { } } + /// Whether the host itself holds the grant. System grants are process-wide, + /// so this is independent of which extension is asking. + private func isPermissionGranted(_ permission: NativExtensionPermission) -> Bool { + switch permission { + case .microphone: + NativSystemPermissionController.hasMicrophoneAccess() + case .systemAudioCapture: + NativSystemPermissionController.hasScreenCaptureAccess() + case .accessibilityInsertText: + NativSystemPermissionController.hasInsertTextAccess() + case .modelsSpeechToText, .overlay, .namespacedStorage: + true + case .notifications: + false + } + } + func permissionStatus( _ permission: NativExtensionPermission ) -> NativExtensionPermissionStatus { switch permission { case .microphone: - switch AVCaptureDevice.authorizationStatus(for: .audio) { - case .authorized: - return .granted - case .denied, .restricted: - return .denied - case .notDetermined: - return .notRequested - @unknown default: - return .notRequested - } - case .systemAudioCapture: - if NativSystemPermissionController.hasScreenCaptureAccess() { + if isPermissionGranted(permission) { return .granted } - return requestedPermissions.contains(permission.rawValue) - ? .denied - : .notRequested - case .accessibilityInsertText: - if NativSystemPermissionController.hasInsertTextAccess() { + return AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined + ? .notRequested + : .denied + case .systemAudioCapture, .accessibilityInsertText: + if isPermissionGranted(permission) { return .granted } + // macOS gives no way to tell "declined" from "never asked", so the + // distinction comes from whether this extension has prompted before. return requestedPermissions.contains(permission.rawValue) ? .denied : .notRequested @@ -377,9 +384,9 @@ final class NativExtensionManager: ObservableObject { } func permissionActionTitle( - _ permission: NativExtensionPermission + _ permission: NativExtensionPermission, + status: NativExtensionPermissionStatus ) -> String? { - let status = permissionStatus(permission) guard status != .granted else { return nil } @@ -437,11 +444,11 @@ final class NativExtensionManager: ObservableObject { func refreshPermissionStatuses() { permissionRevision &+= 1 - let nextSnapshot = currentPermissionSnapshot() - guard nextSnapshot != permissionSnapshot else { + let nextSnapshot = currentGrantedPermissions() + guard nextSnapshot != grantedPermissionSnapshot else { return } - permissionSnapshot = nextSnapshot + grantedPermissionSnapshot = nextSnapshot // An extension runtime receives its granted permissions at activation. // Restart active runtimes when a system grant changes so their broker @@ -457,22 +464,18 @@ final class NativExtensionManager: ObservableObject { } private func markPermissionRequested(_ permission: NativExtensionPermission) { - requestedPermissions.insert(permission.rawValue) + guard requestedPermissions.insert(permission.rawValue).inserted else { + return + } permissionDefaults.set( requestedPermissions.sorted(), - forKey: permissionRequestKey + forKey: Self.permissionRequestKey ) permissionRevision &+= 1 } - private func currentPermissionSnapshot() - -> [NativExtensionPermission: NativExtensionPermissionStatus] - { - Dictionary( - uniqueKeysWithValues: NativExtensionPermission.allCases.map { - ($0, permissionStatus($0)) - } - ) + private func currentGrantedPermissions() -> Set { + Set(NativExtensionPermission.allCases.filter(isPermissionGranted)) } private func reconcileLifecycle() { @@ -500,30 +503,12 @@ final class NativExtensionManager: ObservableObject { } private func reloadInstalledPackages() { - externalManifests.removeAll() - if let packageURLs = try? fileManager.contentsOfDirectory( - at: extensionsDirectory, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) { - for packageURL in packageURLs where packageURL.pathExtension == "nativextension" { - do { - let manifest = try Self.loadManifest( - from: packageURL, - hostVersion: hostVersion - ) - guard builtIns[manifest.id] == nil else { - continue - } - externalManifests[manifest.id] = (manifest, packageURL) - } catch { - NSLog( - "Nativ ignored extension package at %@: %@", - packageURL.path, - error.localizedDescription - ) - } - } + let result = installer.loadInstalledPackages( + reservedIdentifiers: Set(builtIns.keys) + ) + externalManifests = result.manifests + if packageIssues != result.issues { + packageIssues = result.issues } rebuildRecords() } @@ -541,13 +526,14 @@ final class NativExtensionManager: ObservableObject { ) } - nextRecords += externalManifests.values.map { manifest, packageURL in + nextRecords += externalManifests.values.map { installed in + let manifest = installed.manifest let systemIdentity = manifest.runtimeBundleIdentifier.flatMap { systemIdentities[$0] } return NativExtensionRecord( manifest: manifest, - origin: .external(packageURL), + origin: .external(installed.packageURL), state: stateStore.state(for: manifest), hasRuntime: systemIdentity != nil, runtimeBundleIdentifier: @@ -592,8 +578,8 @@ final class NativExtensionManager: ObservableObject { } records = nextRecords.sorted { - if $0.isIncluded != $1.isIncluded { - return $0.isIncluded + if $0.origin.precedence != $1.origin.precedence { + return $0.origin.precedence < $1.origin.precedence } return $0.manifest.displayName.localizedStandardCompare( $1.manifest.displayName @@ -682,7 +668,7 @@ final class NativExtensionManager: ObservableObject { ) let grantedPermissions = Set( record.manifest.permissions.filter { - self.permissionStatus($0) == .granted + self.isPermissionGranted($0) } ) guard let hostContext = self.hostContext else { @@ -752,25 +738,6 @@ final class NativExtensionManager: ObservableObject { systemProcesses.removeValue(forKey: extensionID)?.invalidate() } - private static func loadManifest( - from packageURL: URL, - hostVersion: String - ) throws -> NativExtensionManifest { - let manifestURL = packageURL.appendingPathComponent("Manifest.json") - guard FileManager.default.fileExists(atPath: manifestURL.path) else { - throw NativExtensionPackageError.missingManifest - } - let manifest = try JSONDecoder().decode( - NativExtensionManifest.self, - from: Data(contentsOf: manifestURL) - ) - try NativExtensionManifestValidator.validate( - manifest, - hostVersion: hostVersion - ) - return manifest - } - private static func defaultExtensionsDirectory(fileManager: FileManager) -> URL { let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first diff --git a/Sources/Nativ/Utilities/NativSystemPermissionController.swift b/Sources/Nativ/Utilities/NativSystemPermissionController.swift index 9e6d1669..5b32a66e 100644 --- a/Sources/Nativ/Utilities/NativSystemPermissionController.swift +++ b/Sources/Nativ/Utilities/NativSystemPermissionController.swift @@ -31,6 +31,10 @@ enum NativSystemPermissionController { } } + static func hasMicrophoneAccess() -> Bool { + AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + } + static func hasInsertTextAccess() -> Bool { // macOS can authorize synthesized paste events through either the // dedicated Post Event service or the broader Accessibility grant diff --git a/Sources/NativExtensionSDK/NativExtensionManifest.swift b/Sources/NativExtensionSDK/NativExtensionManifest.swift index 33ea6dd0..00bafd20 100644 --- a/Sources/NativExtensionSDK/NativExtensionManifest.swift +++ b/Sources/NativExtensionSDK/NativExtensionManifest.swift @@ -99,10 +99,35 @@ public struct NativExtensionContributions: Codable, Hashable, Sendable { self.shortcuts = shortcuts self.settings = settings } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sidebar = try container.decodeIfPresent( + [NativSidebarContribution].self, + forKey: .sidebar + ) ?? [] + commands = try container.decodeIfPresent( + [NativCommandContribution].self, + forKey: .commands + ) ?? [] + shortcuts = try container.decodeIfPresent( + [NativShortcutContribution].self, + forKey: .shortcuts + ) ?? [] + settings = try container.decodeIfPresent( + [NativSettingsContribution].self, + forKey: .settings + ) ?? [] + } } public struct NativExtensionManifest: Codable, Hashable, Identifiable, Sendable { public static let currentSchemaVersion = 1 + public static let defaultExtensionPoint = "com.nativ.extension" + /// The schema under which omitting the field was legal. Deliberately a + /// literal, not `currentSchemaVersion` — otherwise raising the current + /// version would silently reinterpret every existing manifest as new. + private static let assumedSchemaVersion = 1 public let schemaVersion: Int public let id: String @@ -132,7 +157,7 @@ public struct NativExtensionManifest: Codable, Hashable, Identifiable, Sendable included: Bool, enabledByDefault: Bool? = nil, runtime: NativExtensionRuntimeKind, - extensionPoint: String = "com.nativ.extension", + extensionPoint: String = NativExtensionManifest.defaultExtensionPoint, runtimeBundleIdentifier: String? = nil, contributions: NativExtensionContributions = .init(), permissions: [NativExtensionPermission] = [] @@ -154,6 +179,52 @@ public struct NativExtensionManifest: Codable, Hashable, Identifiable, Sendable self.permissions = permissions } + /// Only identity and presentation are required of an author. Structural + /// fields fall back to their empty or conventional values so a hand-written + /// manifest is not rejected for omitting boilerplate it does not use. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decodeIfPresent( + Int.self, + forKey: .schemaVersion + ) ?? Self.assumedSchemaVersion + id = try container.decode(String.self, forKey: .id) + version = try container.decode(String.self, forKey: .version) + minimumNativVersion = try container.decode( + String.self, + forKey: .minimumNativVersion + ) + displayName = try container.decode(String.self, forKey: .displayName) + summary = try container.decode(String.self, forKey: .summary) + developer = try container.decode(String.self, forKey: .developer) + systemImage = try container.decode(String.self, forKey: .systemImage) + included = try container.decodeIfPresent(Bool.self, forKey: .included) ?? false + enabledByDefault = try container.decodeIfPresent( + Bool.self, + forKey: .enabledByDefault + ) + runtime = try container.decode( + NativExtensionRuntimeKind.self, + forKey: .runtime + ) + extensionPoint = try container.decodeIfPresent( + String.self, + forKey: .extensionPoint + ) ?? Self.defaultExtensionPoint + runtimeBundleIdentifier = try container.decodeIfPresent( + String.self, + forKey: .runtimeBundleIdentifier + ) + contributions = try container.decodeIfPresent( + NativExtensionContributions.self, + forKey: .contributions + ) ?? .init() + permissions = try container.decodeIfPresent( + [NativExtensionPermission].self, + forKey: .permissions + ) ?? [] + } + public var isEnabledByDefault: Bool { included && (enabledByDefault ?? true) } diff --git a/Tests/NativTests/NativExtensionPackageInstallerTests.swift b/Tests/NativTests/NativExtensionPackageInstallerTests.swift new file mode 100644 index 00000000..49b7bd0b --- /dev/null +++ b/Tests/NativTests/NativExtensionPackageInstallerTests.swift @@ -0,0 +1,333 @@ +import Foundation +import NativExtensionSDK +import XCTest + +final class NativExtensionPackageInstallerTests: XCTestCase { + private var root: URL! + private var extensionsDirectory: URL! + private var installer: NativExtensionPackageInstaller! + + override func setUpWithError() throws { + try super.setUpWithError() + root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + extensionsDirectory = root.appendingPathComponent("Extensions", isDirectory: true) + try FileManager.default.createDirectory( + at: extensionsDirectory, + withIntermediateDirectories: true + ) + installer = NativExtensionPackageInstaller( + fileManager: .default, + extensionsDirectory: extensionsDirectory, + hostVersion: "1.0.0" + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + try super.tearDownWithError() + } + + // MARK: - Helpers + + private func makePackage( + id: String = "com.example.demo", + version: String = "1.0.0", + minimumNativVersion: String = "1.0.0", + included: Bool = false, + runtime: String = "extensionFoundation", + permissions: [String] = ["overlay"] + ) throws -> URL { + let packageURL = root.appendingPathComponent( + "\(UUID().uuidString).nativextension", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: packageURL, + withIntermediateDirectories: true + ) + let manifest: [String: Any] = [ + "schemaVersion": 1, + "id": id, + "version": version, + "minimumNativVersion": minimumNativVersion, + "displayName": "Demo", + "summary": "A demo extension.", + "developer": "Example", + "systemImage": "sparkles", + "included": included, + "runtime": runtime, + "runtimeBundleIdentifier": "com.example.demo.runtime", + "permissions": permissions, + ] + try write(manifest, to: packageURL.appendingPathComponent("Manifest.json")) + return packageURL + } + + @discardableResult + private func install( + _ packageURL: URL, + reserved: Set = [] + ) throws -> NativExtensionPackageInstaller.InstallResult { + try installer.install(from: packageURL, reservedIdentifiers: reserved) + } + + private func write(_ object: [String: Any], to url: URL) throws { + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + try data.write(to: url) + } + + private func installedManifest() throws -> NativExtensionManifest { + try installer.loadManifest(at: installer.packageURL(for: "com.example.demo")) + } + + // MARK: - Installing + + func testInstallCopiesPackageUnderItsIdentifier() throws { + let source = try makePackage() + + let result = try install(source) + + XCTAssertEqual(result.manifest.id, "com.example.demo") + XCTAssertNil(result.replaced) + XCTAssertTrue(result.requiresReconsent) + XCTAssertEqual(try installedManifest().version, "1.0.0") + } + + func testInstallRejectsAFileThatIsNotAPackageDirectory() throws { + let plainFile = root.appendingPathComponent("NotAPackage.nativextension") + try Data("nope".utf8).write(to: plainFile) + + XCTAssertThrowsError( + try install(plainFile) + ) { error in + XCTAssertEqual( + error as? NativExtensionPackageError, + .packageMustBeDirectory + ) + } + } + + func testInstallRejectsAnIdentifierShippedWithNativ() throws { + let source = try makePackage(id: "com.nativ.voice-dictation") + + XCTAssertThrowsError( + try install(source, reserved: ["com.nativ.voice-dictation"]) + ) { error in + XCTAssertEqual( + error as? NativExtensionPackageError, + .duplicateIdentifier("com.nativ.voice-dictation") + ) + } + } + + func testInstallRejectsARuntimeAnInstalledPackageCannotUse() throws { + let source = try makePackage(runtime: "builtIn") + + XCTAssertThrowsError(try install(source)) { error in + XCTAssertEqual( + error as? NativExtensionPackageError, + .unsupportedExternalRuntime + ) + } + } + + func testInstallRejectsAnExternalPackageClaimingToBeIncluded() throws { + let source = try makePackage(included: true) + + XCTAssertThrowsError( + try install(source) + ) { error in + XCTAssertEqual( + error as? NativExtensionPackageError, + .externalPackageClaimsIncluded + ) + } + } + + func testInstallRejectsAHostVersionItRequiresButDoesNotHave() throws { + let source = try makePackage(minimumNativVersion: "2.0.0") + + XCTAssertThrowsError( + try install(source) + ) { error in + XCTAssertEqual( + error as? NativExtensionManifestError, + .incompatibleHost(required: "2.0.0", current: "1.0.0") + ) + } + } + + // MARK: - Updating + + func testUpdateToANewerVersionReplacesTheInstalledPackage() throws { + try install(makePackage(version: "1.0.0")) + + let result = try install(makePackage(version: "1.2.0")) + + XCTAssertEqual(result.replaced?.version, "1.0.0") + XCTAssertEqual(try installedManifest().version, "1.2.0") + } + + func testUpdateKeepingTheSamePermissionsDoesNotRequireReconsent() throws { + try install(makePackage(version: "1.0.0", permissions: ["overlay"])) + + let result = try install( + makePackage(version: "1.1.0", permissions: ["overlay"]) + ) + + XCTAssertFalse(result.requiresReconsent) + } + + func testUpdateAskingForMorePermissionsRequiresReconsent() throws { + try install(makePackage(version: "1.0.0", permissions: ["overlay"])) + + let result = try install( + makePackage(version: "1.1.0", permissions: ["overlay", "microphone"]) + ) + + XCTAssertTrue(result.requiresReconsent) + } + + func testReinstallingTheSameVersionIsAllowed() throws { + try install(makePackage(version: "1.0.0")) + + let result = try install(makePackage(version: "1.0.0")) + + XCTAssertEqual(result.replaced?.version, "1.0.0") + XCTAssertEqual(try installedManifest().version, "1.0.0") + } + + func testDowngradeIsRejectedAndLeavesTheInstalledVersionInPlace() throws { + try install(makePackage(version: "2.0.0")) + + XCTAssertThrowsError( + try install(makePackage(version: "1.0.0")) + ) { error in + XCTAssertEqual( + error as? NativExtensionPackageError, + .olderVersionRejected( + identifier: "com.example.demo", + installed: "2.0.0", + candidate: "1.0.0" + ) + ) + } + XCTAssertEqual(try installedManifest().version, "2.0.0") + } + + // MARK: - Loading + + func testLoadReportsAPackageItCannotReadInsteadOfDroppingIt() throws { + let broken = extensionsDirectory + .appendingPathComponent("broken.nativextension", isDirectory: true) + try FileManager.default.createDirectory(at: broken, withIntermediateDirectories: true) + try Data("{".utf8).write(to: broken.appendingPathComponent("Manifest.json")) + + let result = installer.loadInstalledPackages(reservedIdentifiers: []) + + XCTAssertTrue(result.manifests.isEmpty) + XCTAssertEqual(result.issues.count, 1) + XCTAssertEqual(result.issues.first?.packageName, "broken.nativextension") + } + + func testLoadSkipsPackagesWhoseIdentifierIsReserved() throws { + try install(makePackage()) + + let result = installer.loadInstalledPackages( + reservedIdentifiers: ["com.example.demo"] + ) + + XCTAssertTrue(result.manifests.isEmpty) + XCTAssertTrue(result.issues.isEmpty) + } + + func testLoadSweepsStagingDirectoriesLeftByAnInterruptedInstall() throws { + let staging = extensionsDirectory.appendingPathComponent( + ".install-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + + _ = installer.loadInstalledPackages(reservedIdentifiers: []) + + XCTAssertFalse(FileManager.default.fileExists(atPath: staging.path)) + } + + // MARK: - Manifest authoring + + func testManifestOmittingOptionalStructureStillLoads() throws { + let packageURL = root.appendingPathComponent("Minimal.nativextension", isDirectory: true) + try FileManager.default.createDirectory(at: packageURL, withIntermediateDirectories: true) + try write( + [ + "id": "com.example.minimal", + "version": "1.0.0", + "minimumNativVersion": "1.0.0", + "displayName": "Minimal", + "summary": "No contributions or permissions.", + "developer": "Example", + "systemImage": "sparkles", + "runtime": "builtIn", + "contributions": ["sidebar": []], + ], + to: packageURL.appendingPathComponent("Manifest.json") + ) + + let manifest = try installer.loadManifest(at: packageURL) + + XCTAssertEqual(manifest.schemaVersion, NativExtensionManifest.currentSchemaVersion) + XCTAssertEqual(manifest.extensionPoint, "com.nativ.extension") + XCTAssertFalse(manifest.included) + XCTAssertTrue(manifest.permissions.isEmpty) + XCTAssertTrue(manifest.contributions.sidebar.isEmpty) + } + + func testMissingRequiredFieldNamesTheFieldInTheError() throws { + let packageURL = root.appendingPathComponent("Broken.nativextension", isDirectory: true) + try FileManager.default.createDirectory(at: packageURL, withIntermediateDirectories: true) + try write( + [ + "id": "com.example.broken", + "version": "1.0.0", + "minimumNativVersion": "1.0.0", + "displayName": "Broken", + "summary": "Missing developer.", + "systemImage": "sparkles", + "runtime": "builtIn", + ], + to: packageURL.appendingPathComponent("Manifest.json") + ) + + XCTAssertThrowsError(try installer.loadManifest(at: packageURL)) { error in + let message = (error as? NativExtensionPackageError)?.errorDescription ?? "" + XCTAssertTrue( + message.contains("developer"), + "Expected the error to name the missing field, got: \(message)" + ) + } + } + + func testMissingManifestIsReportedDistinctly() throws { + let packageURL = root.appendingPathComponent("Empty.nativextension", isDirectory: true) + try FileManager.default.createDirectory(at: packageURL, withIntermediateDirectories: true) + + XCTAssertThrowsError(try installer.loadManifest(at: packageURL)) { error in + XCTAssertEqual(error as? NativExtensionPackageError, .missingManifest) + } + } + + // MARK: - Removing + + func testRemoveDeletesTheInstalledPackage() throws { + try install(makePackage()) + let installedURL = installer.packageURL(for: "com.example.demo") + + try installer.removePackage(at: installedURL) + + XCTAssertFalse(FileManager.default.fileExists(atPath: installedURL.path)) + XCTAssertTrue( + installer.loadInstalledPackages(reservedIdentifiers: []).manifests.isEmpty + ) + } +} diff --git a/project.yml b/project.yml index 9db98952..e5df526f 100644 --- a/project.yml +++ b/project.yml @@ -222,6 +222,7 @@ targets: - path: Sources/Nativ/Utilities/NativSystemPermissionController.swift - path: Sources/Nativ/Features/Extensions/NativExtensionStateStore.swift - path: Sources/Nativ/Features/Extensions/NativExtensionCatalogClient.swift + - path: Sources/Nativ/Features/Extensions/NativExtensionPackageInstaller.swift - path: Sources/Nativ/Features/MCP/MCPServerCatalog.swift - path: Sources/Nativ/Features/MCP/GitHubOAuth.swift - path: Sources/Nativ/Resources/MCPCatalog.json