diff --git a/Sources/CrawlBar/CrawlerCatalog/CrawlBarCrawlerClassifier.swift b/Sources/CrawlBar/CrawlerCatalog/CrawlBarCrawlerClassifier.swift new file mode 100644 index 0000000..6c2b7ad --- /dev/null +++ b/Sources/CrawlBar/CrawlerCatalog/CrawlBarCrawlerClassifier.swift @@ -0,0 +1,83 @@ +import CrawlBarCore +import Foundation + +enum CrawlBarCrawlerCategory: CaseIterable, Hashable { + case my + case suggested + case more + + var title: String { + switch self { + case .my: + "My Crawlers" + case .suggested: + "Suggested" + case .more: + "More Crawlers" + } + } +} + +enum CrawlBarCrawlerClassifier { + @MainActor + static func category( + app: CrawlBarAppConfig?, + installation: CrawlAppInstallation?) + -> CrawlBarCrawlerCategory + { + if self.isMyCrawler(app: app, installation: installation) { + return .my + } + guard let installation, self.matchesSuggestion(installation: installation) else { + return .more + } + return .suggested + } + + nonisolated static func isMyCrawler( + app: CrawlBarAppConfig?, + installation: CrawlAppInstallation?) + -> Bool + { + installation?.binaryPath != nil || self.hasUserCrawlerConfig(app) + } + + nonisolated static func statusInstallations( + _ installations: [CrawlAppInstallation], + appConfigsByID: [CrawlAppID: CrawlBarAppConfig]) + -> [CrawlAppInstallation] + { + installations.filter { installation in + guard installation.manifest.availability == .available else { return false } + guard let config = appConfigsByID[installation.id], config.enabled else { return false } + return self.isMyCrawler(app: config, installation: installation) + } + } + + @MainActor + static func matchesSuggestion(installation: CrawlAppInstallation) -> Bool { + guard let suggestion = installation.manifest.suggestion else { return false } + switch suggestion.kind { + case .always: + return true + case .app: + return suggestion.bundleIDs.contains { bundleID in + CrawlBarNativeAppLocator.url(for: bundleID) != nil + } + } + } + + nonisolated private static func hasUserCrawlerConfig(_ app: CrawlBarAppConfig?) -> Bool { + guard let app else { return false } + return app.binaryPath?.nilIfBlank != nil + || app.configPath?.nilIfBlank != nil + || app.refreshFrequency != nil + || app.autoRefreshEnabled + || app.shareEnabled + || app.shareAfterRefresh + || app.preferredRefreshAction != "refresh" + || app.preferredShareAction != "publish" + || app.preferredUpdateAction != "update" + || !app.configValues.isEmpty + } +} diff --git a/Sources/CrawlBar/Menu/CrawlBarMenuViews.swift b/Sources/CrawlBar/Menu/CrawlBarMenuViews.swift index f972241..92bb389 100644 --- a/Sources/CrawlBar/Menu/CrawlBarMenuViews.swift +++ b/Sources/CrawlBar/Menu/CrawlBarMenuViews.swift @@ -18,6 +18,7 @@ struct CrawlBarMenuHeaderView: View { let statuses: [CrawlAppID: CrawlAppStatus] let isRefreshing: Bool let refreshFrequency: RefreshFrequency + let suggestedCount: Int var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -57,6 +58,12 @@ struct CrawlBarMenuHeaderView: View { let current = self.installations.filter { self.effectiveState(for: $0, status: self.statuses[$0.id]) == .current }.count + if self.installations.isEmpty, self.suggestedCount > 0 { + return "\(self.suggestedCount) suggested · \(CrawlBarFrequencyLabel.text(for: self.refreshFrequency))" + } + if self.suggestedCount > 0 { + return "\(current) current · \(self.suggestedCount) suggested" + } return "\(current) current · \(CrawlBarFrequencyLabel.text(for: self.refreshFrequency))" } @@ -72,6 +79,61 @@ struct CrawlBarMenuHeaderView: View { } } +struct CrawlBarMenuSectionHeaderView: View { + let title: String + + var body: some View { + Text(self.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, CrawlBarMenuStyle.cardHorizontalPadding) + .padding(.top, 4) + .padding(.bottom, 1) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct CrawlBarMenuSuggestionView: View { + let installation: CrawlAppInstallation + let onOpen: () -> Void + @Environment(\.crawlBarMenuItemHighlighted) private var isHighlighted + + var body: some View { + HStack(alignment: .center, spacing: 10) { + CrawlBarBrandIcon(manifest: self.installation.manifest, appID: self.installation.id) + .frame(width: 26, height: 26) + .opacity(0.82) + VStack(alignment: .leading, spacing: 3) { + Text(CrawlBarCrawlerTitle.text(for: self.installation.id, manifest: self.installation.manifest)) + .font(.subheadline) + .lineLimit(1) + Text(self.detailText) + .font(.caption) + .foregroundStyle(CrawlBarMenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, CrawlBarMenuStyle.cardHorizontalPadding) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture(perform: self.onOpen) + } + + private var detailText: String { + guard let suggestion = self.installation.manifest.suggestion else { + return "Connect \(self.installation.manifest.binary.name)" + } + switch suggestion.kind { + case .always: + return "Connect \(self.installation.manifest.binary.name)" + case .app: + return "\(suggestion.name) is installed" + } + } +} + struct CrawlBarMenuCardView: View { let installation: CrawlAppInstallation let status: CrawlAppStatus? diff --git a/Sources/CrawlBar/Menu/CrawlBarStatusMenuBuilder.swift b/Sources/CrawlBar/Menu/CrawlBarStatusMenuBuilder.swift index bb3e69f..19ca7c7 100644 --- a/Sources/CrawlBar/Menu/CrawlBarStatusMenuBuilder.swift +++ b/Sources/CrawlBar/Menu/CrawlBarStatusMenuBuilder.swift @@ -22,26 +22,45 @@ struct CrawlBarStatusMenuBuilder { selectors: CrawlBarStatusMenuActionSelectors, openSettings: @escaping (CrawlAppID?) -> Void) { - let visibleInstallations = model.visibleInstallations + let myInstallations = model.myMenuInstallations + let suggestedInstallations = model.suggestedMenuInstallations menu.autoenablesItems = false menu.removeAllItems() menu.addItem(self.viewItem(for: CrawlBarMenuHeaderView( - installations: visibleInstallations, + installations: myInstallations, statuses: model.statuses, isRefreshing: model.isRefreshing, - refreshFrequency: model.refreshFrequency), enabled: false)) + refreshFrequency: model.refreshFrequency, + suggestedCount: suggestedInstallations.count), enabled: false)) - self.addCrawlers( - visibleInstallations, + self.addMyCrawlers( + myInstallations, statuses: model.statuses, to: menu, target: target, selector: selectors.showSettingsForApp, openSettings: openSettings) + self.addSuggestedCrawlers( + suggestedInstallations, + hasMyCrawlers: !myInstallations.isEmpty, + to: menu, + target: target, + selector: selectors.showSettingsForApp, + openSettings: openSettings) + menu.addItem(.separator()) + if model.moreCrawlerCount > 0 { + menu.addItem(self.actionItem( + title: "More Crawlers in Settings...", + action: selectors.showSettings, + target: target, + systemImage: "square.grid.2x2")) + menu.addItem(.separator()) + } + let refreshTitle = model.isRefreshing ? "Refreshing..." : "Refresh All" let refreshItem = self.actionItem( title: refreshTitle, @@ -78,7 +97,7 @@ struct CrawlBarStatusMenuBuilder { self.itemFactory.clearHighlights(in: menu) } - private func addCrawlers( + private func addMyCrawlers( _ installations: [CrawlAppInstallation], statuses: [CrawlAppID: CrawlAppStatus], to menu: NSMenu, @@ -101,6 +120,28 @@ struct CrawlBarStatusMenuBuilder { } } + private func addSuggestedCrawlers( + _ installations: [CrawlAppInstallation], + hasMyCrawlers: Bool, + to menu: NSMenu, + target: AnyObject, + selector: Selector, + openSettings: @escaping (CrawlAppID?) -> Void) + { + guard !installations.isEmpty else { return } + if hasMyCrawlers { + menu.addItem(self.viewItem(for: CrawlBarMenuSeparatorRowView(), enabled: false)) + } + menu.addItem(self.viewItem(for: CrawlBarMenuSectionHeaderView(title: "Suggested"), enabled: false)) + for installation in installations { + menu.addItem(self.suggestedMenuItem( + for: installation, + target: target, + selector: selector, + openSettings: openSettings)) + } + } + private func appMenuItem( for installation: CrawlAppInstallation, status: CrawlAppStatus?, @@ -120,6 +161,23 @@ struct CrawlBarStatusMenuBuilder { selector: selector) } + private func suggestedMenuItem( + for installation: CrawlAppInstallation, + target: AnyObject, + selector: Selector, + openSettings: @escaping (CrawlAppID?) -> Void) + -> NSMenuItem + { + let row = CrawlBarMenuSuggestionView( + installation: installation, + onOpen: { openSettings(installation.id) }) + return self.crawlerItem( + for: row, + installation: installation, + target: target, + selector: selector) + } + private func crawlerItem( for content: some View, installation: CrawlAppInstallation, diff --git a/Sources/CrawlBar/Menu/MenuModel.swift b/Sources/CrawlBar/Menu/MenuModel.swift index 5d1386f..86f4bd4 100644 --- a/Sources/CrawlBar/Menu/MenuModel.swift +++ b/Sources/CrawlBar/Menu/MenuModel.swift @@ -39,20 +39,37 @@ final class CrawlBarMenuModel: NSObject { NotificationCenter.default.removeObserver(self) } - var visibleInstallations: [CrawlAppInstallation] { + var myMenuInstallations: [CrawlAppInstallation] { self.installations.filter { installation in guard installation.manifest.availability == .available else { return false } - return self.appConfigs[installation.id]?.showInMenuBar ?? true + guard let config = self.appConfigs[installation.id] else { return false } + guard config.enabled, config.showInMenuBar else { return false } + return CrawlBarCrawlerClassifier.isMyCrawler(app: config, installation: installation) } } - var statusTargetInstallations: [CrawlAppInstallation] { + var suggestedMenuInstallations: [CrawlAppInstallation] { self.installations.filter { installation in guard installation.manifest.availability == .available else { return false } - return self.appConfigs[installation.id]?.enabled ?? installation.enabled + guard let config = self.appConfigs[installation.id] else { return false } + guard config.enabled, config.showInMenuBar else { return false } + return CrawlBarCrawlerClassifier.category(app: config, installation: installation) == .suggested } } + var moreCrawlerCount: Int { + self.installations.filter { installation in + guard let config = self.appConfigs[installation.id] else { return false } + return CrawlBarCrawlerClassifier.category(app: config, installation: installation) == .more + }.count + } + + var statusTargetInstallations: [CrawlAppInstallation] { + CrawlBarCrawlerClassifier.statusInstallations( + self.installations, + appConfigsByID: self.appConfigs) + } + func appConfig(for id: CrawlAppID) -> CrawlBarAppConfig? { self.appConfigs[id] } @@ -72,15 +89,14 @@ final class CrawlBarMenuModel: NSObject { let generation = UUID() self.refreshGeneration = generation self.isRefreshing = true - let appConfigs = self.appConfigs let registry = self.registry let statusService = self.statusService + let appConfigs = self.appConfigs self.refreshTask = Task.detached { let installations = (try? registry.installationsForStatus(includeDisabled: true)) ?? [] - let statusInstallations = installations.filter { installation in - guard installation.manifest.availability == .available else { return false } - return appConfigs[installation.id]?.enabled ?? installation.enabled - } + let statusInstallations = CrawlBarCrawlerClassifier.statusInstallations( + installations, + appConfigsByID: appConfigs) await MainActor.run { guard self.refreshGeneration == generation else { return } self.installations = installations diff --git a/Sources/CrawlBar/Settings/GeneralSettingsView.swift b/Sources/CrawlBar/Settings/GeneralSettingsView.swift index c07f45b..ae7cc9a 100644 --- a/Sources/CrawlBar/Settings/GeneralSettingsView.swift +++ b/Sources/CrawlBar/Settings/GeneralSettingsView.swift @@ -101,9 +101,9 @@ struct CrawlBarGeneralSettingsView: View { CrawlBarPanel(title: "Crawler Inventory") { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { - CrawlBarFact(label: "Ready", value: "\(self.readyCount)") - CrawlBarFact(label: "Missing CLI", value: "\(self.missingCount)") - CrawlBarFact(label: "Coming Soon", value: "\(self.comingSoonCount)") + CrawlBarFact(label: "My Crawlers", value: "\(self.count(.my))") + CrawlBarFact(label: "Suggested", value: "\(self.count(.suggested))") + CrawlBarFact(label: "More Crawlers", value: "\(self.count(.more))") } } } @@ -118,15 +118,7 @@ struct CrawlBarGeneralSettingsView: View { .padding(.vertical, CrawlBarSettingsLayout.detailVerticalPadding) } - private var readyCount: Int { - self.model.installations.values.filter { $0.manifest.availability == .available && $0.binaryPath != nil }.count - } - - private var missingCount: Int { - self.model.installations.values.filter { $0.manifest.availability == .available && $0.binaryPath == nil }.count - } - - private var comingSoonCount: Int { - self.model.installations.values.filter { $0.manifest.availability == .comingSoon }.count + private func count(_ section: CrawlBarCrawlerCategory) -> Int { + self.model.crawlerSections.first { $0.kind == section }?.apps.count ?? 0 } } diff --git a/Sources/CrawlBar/Settings/SettingsModel.swift b/Sources/CrawlBar/Settings/SettingsModel.swift index b854b82..5c722da 100644 --- a/Sources/CrawlBar/Settings/SettingsModel.swift +++ b/Sources/CrawlBar/Settings/SettingsModel.swift @@ -66,6 +66,19 @@ final class CrawlBarSettingsModel: NSObject, ObservableObject { NotificationCenter.default.removeObserver(self) } + var crawlerSections: [CrawlBarCrawlerSection] { + var grouped: [CrawlBarCrawlerCategory: [CrawlBarAppConfig]] = [:] + for app in self.apps { + let installation = self.installations[app.id] + let section = CrawlBarCrawlerClassifier.category(app: app, installation: installation) + grouped[section, default: []].append(app) + } + return CrawlBarCrawlerCategory.allCases.compactMap { section in + guard let apps = grouped[section], !apps.isEmpty else { return nil } + return CrawlBarCrawlerSection(kind: section, apps: apps) + } + } + var sidebarSelectionIsValid: Bool { switch self.selectedSidebarItem { case .general: diff --git a/Sources/CrawlBar/Settings/SettingsModelStatusActions.swift b/Sources/CrawlBar/Settings/SettingsModelStatusActions.swift index e726cae..7160fbf 100644 --- a/Sources/CrawlBar/Settings/SettingsModelStatusActions.swift +++ b/Sources/CrawlBar/Settings/SettingsModelStatusActions.swift @@ -14,10 +14,9 @@ extension CrawlBarSettingsModel { self.refreshTask = Task.detached { let installations = (try? registry.installationsForStatus(includeDisabled: true)) ?? [] let appConfigsByID = Dictionary(uniqueKeysWithValues: appsForStatus.map { ($0.id, $0) }) - let statusInstallations = installations.filter { installation in - guard installation.manifest.availability == .available else { return false } - return appConfigsByID[installation.id]?.enabled ?? installation.enabled - } + let statusInstallations = CrawlBarCrawlerClassifier.statusInstallations( + installations, + appConfigsByID: appConfigsByID) await MainActor.run { guard self.refreshGeneration == generation else { return } let installationsByID = Dictionary(uniqueKeysWithValues: installations.map { ($0.id, $0) }) diff --git a/Sources/CrawlBar/Settings/SettingsSidebar.swift b/Sources/CrawlBar/Settings/SettingsSidebar.swift index fb3e32b..9fb9068 100644 --- a/Sources/CrawlBar/Settings/SettingsSidebar.swift +++ b/Sources/CrawlBar/Settings/SettingsSidebar.swift @@ -6,6 +6,13 @@ enum CrawlBarSettingsSidebarItem: Hashable { case crawler(CrawlAppID) } +struct CrawlBarCrawlerSection: Identifiable { + let kind: CrawlBarCrawlerCategory + let apps: [CrawlBarAppConfig] + + var id: CrawlBarCrawlerCategory { self.kind } +} + struct CrawlBarGeneralSidebarRow: View { let isSelected: Bool @@ -34,8 +41,21 @@ struct CrawlBarSidebarSelectionBackground: View { } } +struct CrawlBarSidebarSectionHeader: View { + let title: String + + var body: some View { + Text(self.title) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.secondary) + .textCase(nil) + .padding(.top, 8) + } +} + struct CrawlBarSidebarRow: View { let app: CrawlBarAppConfig + let section: CrawlBarCrawlerCategory let manifest: CrawlAppManifest? let status: CrawlAppStatus? let binaryPath: String? @@ -66,6 +86,7 @@ struct CrawlBarSidebarRow: View { } private var rowState: CrawlAppState { + if self.section != .my { return .disabled } if self.manifest?.availability == .comingSoon { return .disabled } if !self.app.enabled { return .disabled } if self.binaryPath == nil { return .needsConfig } @@ -79,6 +100,8 @@ struct CrawlBarSidebarRow: View { private var subtitle: String { let binaryName = self.manifest?.binary.name ?? self.app.id.rawValue if self.manifest?.availability == .comingSoon { return "\(binaryName) · coming soon" } + if self.section == .suggested { return self.suggestedSubtitle } + if self.section == .more { return self.moreSubtitle } if !self.app.enabled { return "Disabled" } if self.binaryPath == nil { return "Missing \(binaryName)" } if self.rowState == .needsAuth { @@ -107,6 +130,27 @@ struct CrawlBarSidebarRow: View { } } + private var moreSubtitle: String { + guard let suggestion = self.manifest?.suggestion, + suggestion.kind == .app + else { + return "Not installed on this computer" + } + return "\(suggestion.name) is not installed" + } + + private var suggestedSubtitle: String { + guard let suggestion = self.manifest?.suggestion else { + return "Available crawler" + } + switch suggestion.kind { + case .always: + return "Available crawler" + case .app: + return "\(suggestion.name) is installed" + } + } + private var syncedAt: Date? { if let lastSyncAt = self.status?.lastSyncAt { return lastSyncAt @@ -119,6 +163,7 @@ struct CrawlBarSidebarRow: View { private var subtitleColor: Color { if self.isSelected { return Color.white.opacity(0.78) } + if self.section != .my { return Color.secondary } switch self.rowState { case .needsConfig, .needsAuth, .error: return Color.red diff --git a/Sources/CrawlBar/Settings/SettingsView.swift b/Sources/CrawlBar/Settings/SettingsView.swift index 04c0b20..b85239b 100644 --- a/Sources/CrawlBar/Settings/SettingsView.swift +++ b/Sources/CrawlBar/Settings/SettingsView.swift @@ -17,21 +17,26 @@ struct CrawlBarSettingsView: View { self.model.selectedSidebarItem = .general } } - Section("Crawlers") { - ForEach(self.model.apps) { app in - let item = CrawlBarSettingsSidebarItem.crawler(app.id) - CrawlBarSidebarRow( - app: app, - manifest: self.model.installations[app.id]?.manifest, - status: self.model.statuses[app.id], - binaryPath: self.model.installations[app.id]?.binaryPath, - isSelected: self.model.selectedSidebarItem == item) - .tag(item as CrawlBarSettingsSidebarItem?) - .contentShape(Rectangle()) - .listRowBackground(CrawlBarSidebarSelectionBackground(isSelected: self.model.selectedSidebarItem == item)) - .onTapGesture { - self.model.selectedSidebarItem = item - } + ForEach(self.model.crawlerSections) { section in + Section { + ForEach(section.apps) { app in + let item = CrawlBarSettingsSidebarItem.crawler(app.id) + CrawlBarSidebarRow( + app: app, + section: section.kind, + manifest: self.model.installations[app.id]?.manifest, + status: self.model.statuses[app.id], + binaryPath: self.model.installations[app.id]?.binaryPath, + isSelected: self.model.selectedSidebarItem == item) + .tag(item as CrawlBarSettingsSidebarItem?) + .contentShape(Rectangle()) + .listRowBackground(CrawlBarSidebarSelectionBackground(isSelected: self.model.selectedSidebarItem == item)) + .onTapGesture { + self.model.selectedSidebarItem = item + } + } + } header: { + CrawlBarSidebarSectionHeader(title: section.kind.title) } } } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Birdclaw.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Birdclaw.swift index cad5938..92f3caf 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Birdclaw.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Birdclaw.swift @@ -35,4 +35,5 @@ public extension BuiltInCrawlApps { .init(id: "remote", title: "Remote Host", optionIDs: ["remote_target", "remote_run_as"]), ], install: .init(method: .homebrew, package: "steipete/tap/bird")) + .withSuggestion(Self.alwaysSuggest("X")) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Discrawl.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Discrawl.swift index 2ca024d..3fc2a8c 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Discrawl.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Discrawl.swift @@ -42,4 +42,5 @@ public extension BuiltInCrawlApps { .init(id: "ai", title: "Embeddings", optionIDs: ["openai_api_key", "embedding_model"]), ], install: .init(method: .homebrew, package: "vincentkoc/tap/discrawl")) + .withSuggestion(Self.appSuggest("Discord", ["com.hnc.Discord"])) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gitcrawl.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gitcrawl.swift index 73d4b31..cea4c5c 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gitcrawl.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gitcrawl.swift @@ -40,4 +40,5 @@ public extension BuiltInCrawlApps { .init(id: "ai", title: "Embeddings", optionIDs: ["openai_api_key", "embedding_model"]), ], install: .init(method: .homebrew, package: "vincentkoc/tap/gitcrawl")) + .withSuggestion(Self.alwaysSuggest("GitHub")) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gogcli.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gogcli.swift index f8f6fd9..ae04adb 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gogcli.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Gogcli.swift @@ -37,4 +37,5 @@ public extension BuiltInCrawlApps { .init(id: "remote", title: "Remote Host", optionIDs: ["remote_target", "remote_run_as", "remote_env_file"]), ], install: .init(method: .homebrew, package: "openclaw/tap/gogcli")) + .withSuggestion(Self.alwaysSuggest("Google")) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Graincrawl.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Graincrawl.swift index f861084..42e9653 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Graincrawl.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Graincrawl.swift @@ -42,4 +42,5 @@ public extension BuiltInCrawlApps { .init(id: "sync", title: "Sync", optionIDs: ["sync_limit"]), ], install: .init(method: .homebrew, package: "vincentkoc/tap/graincrawl")) + .withSuggestion(Self.appSuggest("Granola", ["com.granola.app"])) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Notcrawl.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Notcrawl.swift index 7b820d3..cb33841 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Notcrawl.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Notcrawl.swift @@ -42,4 +42,5 @@ public extension BuiltInCrawlApps { .init(id: "ai", title: "Embeddings", optionIDs: ["openai_api_key", "embedding_model"]), ], install: .init(method: .homebrew, package: "vincentkoc/tap/notcrawl")) + .withSuggestion(Self.appSuggest("Notion", ["notion.id"])) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Slacrawl.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Slacrawl.swift index 80d4730..415fa03 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Slacrawl.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Slacrawl.swift @@ -41,4 +41,5 @@ public extension BuiltInCrawlApps { .init(id: "ai", title: "Embeddings", optionIDs: ["openai_api_key", "embedding_model"]), ], install: .init(method: .homebrew, package: "vincentkoc/tap/slacrawl")) + .withSuggestion(Self.appSuggest("Slack", ["com.tinyspeck.slackmacgap"])) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Telecrawl.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Telecrawl.swift index 14f5471..e988cc1 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Telecrawl.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Telecrawl.swift @@ -29,4 +29,5 @@ public extension BuiltInCrawlApps { exportsSecrets: false, localOnlyScopes: ["telegram-desktop", "sqlite", "encrypted-git-backup"]), install: .init(method: .homebrew, package: "steipete/tap/telecrawl")) + .withSuggestion(Self.appSuggest("Telegram", ["ru.keepcoder.Telegram", "org.telegram.desktop"])) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Wacli.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Wacli.swift index 2806086..c75a5a4 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Wacli.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps+Wacli.swift @@ -43,4 +43,5 @@ public extension BuiltInCrawlApps { .init(id: "whatsapp", title: "WhatsApp Account", optionIDs: ["account"]), ], install: .init(method: .homebrew, package: "openclaw/tap/wacli")) + .withSuggestion(Self.appSuggest("WhatsApp", ["net.whatsapp.WhatsApp"])) } diff --git a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps.swift b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps.swift index 1b8144c..b98a83b 100644 --- a/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps.swift +++ b/Sources/CrawlBarCore/BuiltInApps/BuiltInCrawlApps.swift @@ -29,4 +29,11 @@ public enum BuiltInCrawlApps { self.allByID[id] } + static func alwaysSuggest(_ name: String) -> CrawlAppManifest.Suggestion { + .init(kind: .always, name: name) + } + + static func appSuggest(_ name: String, _ bundleIDs: [String]) -> CrawlAppManifest.Suggestion { + .init(kind: .app, name: name, bundleIDs: bundleIDs) + } } diff --git a/Sources/CrawlBarCore/Manifest/CrawlAppManifest.swift b/Sources/CrawlBarCore/Manifest/CrawlAppManifest.swift index 121ccce..7d34117 100644 --- a/Sources/CrawlBarCore/Manifest/CrawlAppManifest.swift +++ b/Sources/CrawlBarCore/Manifest/CrawlAppManifest.swift @@ -17,6 +17,7 @@ public struct CrawlAppManifest: Codable, Equatable, Sendable, Identifiable { public var configOptions: [CrawlAppManifest.ConfigOption] public var configSections: [CrawlAppManifest.ConfigSection] public var install: CrawlAppManifest.Install? + package var suggestion: CrawlAppManifest.Suggestion? = nil public init( schemaVersion: Int = 1, diff --git a/Sources/CrawlBarCore/Manifest/CrawlAppManifestInstall.swift b/Sources/CrawlBarCore/Manifest/CrawlAppManifestInstall.swift deleted file mode 100644 index 1a431e6..0000000 --- a/Sources/CrawlBarCore/Manifest/CrawlAppManifestInstall.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public extension CrawlAppManifest { - enum InstallMethod: String, Codable, Equatable, Sendable { - case homebrew - } - - struct Install: Codable, Equatable, Sendable { - public var method: InstallMethod - public var package: String - - public init(method: InstallMethod, package: String) { - self.method = method - self.package = package - } - } -} diff --git a/Sources/CrawlBarCore/Manifest/CrawlAppManifestInstallSuggestion.swift b/Sources/CrawlBarCore/Manifest/CrawlAppManifestInstallSuggestion.swift new file mode 100644 index 0000000..e0c8447 --- /dev/null +++ b/Sources/CrawlBarCore/Manifest/CrawlAppManifestInstallSuggestion.swift @@ -0,0 +1,40 @@ +public extension CrawlAppManifest { + enum InstallMethod: String, Codable, Equatable, Sendable { + case homebrew + } + + struct Install: Codable, Equatable, Sendable { + public var method: InstallMethod + public var package: String + + public init(method: InstallMethod, package: String) { + self.method = method + self.package = package + } + } +} + +package extension CrawlAppManifest { + func withSuggestion(_ suggestion: Suggestion) -> Self { + var manifest = self + manifest.suggestion = suggestion + return manifest + } + + enum SuggestionKind: Equatable, Sendable { + case always + case app + } + + struct Suggestion: Equatable, Sendable { + package var kind: SuggestionKind + package var name: String + package var bundleIDs: [String] + + package init(kind: SuggestionKind, name: String, bundleIDs: [String] = []) { + self.kind = kind + self.name = name + self.bundleIDs = bundleIDs + } + } +} diff --git a/Sources/CrawlBarSelfTest/SelfTestConfig.swift b/Sources/CrawlBarSelfTest/SelfTestConfig.swift index b5d5e4c..388a88c 100644 --- a/Sources/CrawlBarSelfTest/SelfTestConfig.swift +++ b/Sources/CrawlBarSelfTest/SelfTestConfig.swift @@ -150,6 +150,7 @@ extension CrawlBarSelfTest { try Self.expect(BuiltInCrawlApps.gogcli.commands["status"] == ["auth", "list", "--check", "--json", "--no-input"], "Google status is wired") try Self.expect(BuiltInCrawlApps.wacli.availability == .available, "WhatsApp manifest is available") try Self.expect(BuiltInCrawlApps.wacli.commands["status"] == ["--account", "{config:account}", "--read-only", "--json", "doctor"], "WhatsApp status is wired") + try Self.expect(BuiltInCrawlApps.wacli.suggestion?.name == "WhatsApp", "WhatsApp manifest declares source app suggestion") try Self.expect(BuiltInCrawlApps.birdclaw.binary.name == "bird", "X app id uses bird executable") try Self.expect(BuiltInCrawlApps.telecrawl.availability == .available, "telecrawl is available") try Self.expect(BuiltInCrawlApps.telecrawl.commands["status"] == ["--json", "status"], "telecrawl uses JSON status command")