From 308e23f80a3800df258819a0b0d52dd0079d0820 Mon Sep 17 00:00:00 2001 From: Roman Marinsky Date: Wed, 3 Jun 2026 22:54:49 +0300 Subject: [PATCH] Add file-open logging and picker support --- BrowserCat.xcodeproj/project.pbxproj | 12 + BrowserCat/App/AppDelegate.swift | 46 +++- BrowserCat/App/AppState.swift | 46 +++- .../Features/MenuBar/MenuBarContentView.swift | 32 ++- BrowserCat/Features/Picker/PickerView.swift | 39 ++- .../Picker/PickerWindowController.swift | 9 +- BrowserCat/Features/Picker/URLBar.swift | 41 ++- .../Features/Settings/AppsSettingsView.swift | 2 +- .../Settings/GeneralSettingsView.swift | 28 +- .../Settings/HistorySettingsView.swift | 65 ++++- .../Features/Settings/SettingsView.swift | 2 +- .../Managers/DefaultBrowserManager.swift | 85 ++++++- BrowserCat/Managers/HistoryManager.swift | 86 ++++++- BrowserCat/Managers/PickerCoordinator.swift | 77 +++--- BrowserCat/Managers/StatsManager.swift | 3 +- BrowserCat/Models/AppDefinition.swift | 164 +++++++++++- BrowserCat/Models/BrowserFileType.swift | 240 ++++++++++++++++++ BrowserCat/Models/HistoryEntry.swift | 28 +- BrowserCat/Models/InstalledApp.swift | 19 ++ BrowserCat/Resources/Info.plist | 206 ++++++++++++++- .../Resources/en.lproj/Localizable.strings | 48 ++++ .../Resources/uk.lproj/Localizable.strings | 48 ++++ BrowserCat/Services/BrowserLauncher.swift | 46 ++-- .../Services/FileShortcutResolver.swift | 59 +++++ BrowserCat/Services/SuggestionEngine.swift | 3 +- .../FileShortcutResolverTests.swift | 50 ++++ .../HistoryEntryCodableTests.swift | 47 ++++ BrowserCatTests/URLRuleMatcherTests.swift | 3 +- BrowserCatTests/URLRulesManagerTests.swift | 4 +- 29 files changed, 1420 insertions(+), 118 deletions(-) create mode 100644 BrowserCat/Models/BrowserFileType.swift create mode 100644 BrowserCat/Services/FileShortcutResolver.swift create mode 100644 BrowserCatTests/FileShortcutResolverTests.swift diff --git a/BrowserCat.xcodeproj/project.pbxproj b/BrowserCat.xcodeproj/project.pbxproj index 612fc70..81ac4c6 100644 --- a/BrowserCat.xcodeproj/project.pbxproj +++ b/BrowserCat.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0163D6715239BF30AE8708D8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB795FD1DE244D5D24C6B1C /* Logger.swift */; }; 01752B4248DB736BA70B193D /* StatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1134EF2231434249C16778B /* StatsManager.swift */; }; 03A6ABD205F7374E1657D70F /* BrowserDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04C9463C9B4C28CD7DBF298 /* BrowserDetector.swift */; }; + 0508C7AC751688AF122D3230 /* FileShortcutResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4CB57A8DABC5A053D716B9F /* FileShortcutResolverTests.swift */; }; 056282DF8AE0A0BE15F51A1B /* StatsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C38475D3D47BA26D4D3BEA /* StatsSettingsView.swift */; }; 0701BCAF2815B6A43289958E /* AppConfigStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2693C23DE7409F364E44ACCB /* AppConfigStorage.swift */; }; 082FD1C3D24CF9B3DF5F4B73 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1F53ECFF56533673400B1105 /* Assets.xcassets */; }; @@ -38,6 +39,7 @@ 4FABC8FA1EE2F75D3D8F125B /* BrowserCatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC287980C0A32719AD28551 /* BrowserCatApp.swift */; }; 52CF84848429992524CE2B5A /* URLRulesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060597A231BB4F68AA986E2E /* URLRulesManagerTests.swift */; }; 562B0C0EBAFC90342042CBBB /* RulesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8845DE995EB5854DEC195F /* RulesStorage.swift */; }; + 5638331E5D1CAA78F0798769 /* FileShortcutResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492E797F0389FB564953205B /* FileShortcutResolver.swift */; }; 5928D61B5977D2762A445A51 /* MergeUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D8B9153E920407F6EF3BB4 /* MergeUtility.swift */; }; 5F23D60EC26296251C82EF7F /* RuleEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD02DB18B09EEEDF904B7B0 /* RuleEditorSheet.swift */; }; 63E76A6F8555A49D6F8DB344 /* BrowserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381ABCC2D1D930571D164EA /* BrowserManager.swift */; }; @@ -68,6 +70,7 @@ DCA3D7431055DB8569224BA7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A957F8E312EE8E0A6C94BB59 /* AppDelegate.swift */; }; E008348FFCA420ABBE8D7BB6 /* StatsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AA04E72BAF8DBEB9DC52DFA /* StatsStorage.swift */; }; E385F11B8842AFA938C3FB59 /* HistoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9430B94E9BC5179165B21E /* HistoryStorage.swift */; }; + E39585E14DFE1C1EBE431A05 /* BrowserFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9876871CE74F5F244C21A415 /* BrowserFileType.swift */; }; E5FE06FA2FDA35544BCF5CB0 /* ProfilePopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA6AE8E5837D6A8E05FED7E /* ProfilePopover.swift */; }; E625C798F01739C5B757762E /* URLRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE940AF4C35B26D2C863C7CA /* URLRule.swift */; }; E6E59E083C3F8D168B90FBD6 /* BrowserCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05C80F13D979371092C2F3E /* BrowserCell.swift */; }; @@ -122,6 +125,7 @@ 3F7289D8A66C88095397AEE3 /* RuleSuggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleSuggestion.swift; sourceTree = ""; }; 40759059FBEC29D26C857797 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 47C5F2377087FF4CEF1668D3 /* BrowserCat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BrowserCat.entitlements; sourceTree = ""; }; + 492E797F0389FB564953205B /* FileShortcutResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileShortcutResolver.swift; sourceTree = ""; }; 49935385F6579EE0B58C7844 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; 4D511486B8EA914A2F76A225 /* DefaultBrowserManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultBrowserManager.swift; sourceTree = ""; }; 59E693DE5942139A67A1B87D /* AppDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDefinition.swift; sourceTree = ""; }; @@ -137,6 +141,7 @@ 91608371CF7BB07629497422 /* URLRuleMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRuleMatcherTests.swift; sourceTree = ""; }; 97B7A7F2090F5C572EBEB6A3 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 97F5D838F4BF991EB6612E7E /* InstalledBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledBrowser.swift; sourceTree = ""; }; + 9876871CE74F5F244C21A415 /* BrowserFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFileType.swift; sourceTree = ""; }; 99F43DC08E2F435561C5BEB9 /* HistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = ""; }; 9D51BBF8B8D0CAAEB1CE7E42 /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = ""; }; 9DA6D8F2AFAF4416E6BEFE80 /* URLUnwrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLUnwrapper.swift; sourceTree = ""; }; @@ -163,6 +168,7 @@ E1134EF2231434249C16778B /* StatsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsManager.swift; sourceTree = ""; }; E21B4E8F64A06EFD6A210EE0 /* HistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryEntry.swift; sourceTree = ""; }; E2CEBA310FF69A96215A582E /* BrowserLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserLauncher.swift; sourceTree = ""; }; + E4CB57A8DABC5A053D716B9F /* FileShortcutResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileShortcutResolverTests.swift; sourceTree = ""; }; EAA4D58CFE1B7EF7419E7616 /* SuggestionDismissalStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDismissalStorage.swift; sourceTree = ""; }; EB20A285B31B858C26E4E5EB /* OpenSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSource.swift; sourceTree = ""; }; ECD4C0B4D581484237336DAD /* PickerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerCoordinator.swift; sourceTree = ""; }; @@ -202,6 +208,7 @@ children = ( 59E693DE5942139A67A1B87D /* AppDefinition.swift */, FD47656D0F5910C589FA1DE6 /* BrowserDefinition.swift */, + 9876871CE74F5F244C21A415 /* BrowserFileType.swift */, D498C8270D3821EBE2FB800B /* BrowserProfile.swift */, 5B0586B5B2062AA3C3EBF16B /* DailyStats.swift */, E21B4E8F64A06EFD6A210EE0 /* HistoryEntry.swift */, @@ -297,6 +304,7 @@ AB258D4F2C7E469A60338F08 /* BrowserCatTests */ = { isa = PBXGroup; children = ( + E4CB57A8DABC5A053D716B9F /* FileShortcutResolverTests.swift */, 37973C8FED68180BD6D5A9CD /* HistoryEntryCodableTests.swift */, 1EE21EEB88854BC2685C50AA /* SmokeTests.swift */, 12B338E654D0EA330977540C /* SuggestionEngineTests.swift */, @@ -316,6 +324,7 @@ C04C9463C9B4C28CD7DBF298 /* BrowserDetector.swift */, E2CEBA310FF69A96215A582E /* BrowserLauncher.swift */, AD02E59208B7B2D5A04B1C9C /* ConfigDirectory.swift */, + 492E797F0389FB564953205B /* FileShortcutResolver.swift */, BE9430B94E9BC5179165B21E /* HistoryStorage.swift */, F1D8B9153E920407F6EF3BB4 /* MergeUtility.swift */, 2906C0DDC49483DB6B1BDFEF /* ProfileDetector.swift */, @@ -484,6 +493,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0508C7AC751688AF122D3230 /* FileShortcutResolverTests.swift in Sources */, FB05E7AE682D885067E62FC7 /* HistoryEntryCodableTests.swift in Sources */, 1E1E6E7C02AC9FC16A638B8B /* SmokeTests.swift in Sources */, F80CA77B50D32316CA36D90E /* SuggestionEngineTests.swift in Sources */, @@ -510,6 +520,7 @@ 922C0C3550B61CFF2BCDD18E /* BrowserConfigStorage.swift in Sources */, A72C28E511EE9FAB7675DA4A /* BrowserDefinition.swift in Sources */, 03A6ABD205F7374E1657D70F /* BrowserDetector.swift in Sources */, + E39585E14DFE1C1EBE431A05 /* BrowserFileType.swift in Sources */, D00F9F85C3A789A93BEF1069 /* BrowserLauncher.swift in Sources */, 63E76A6F8555A49D6F8DB344 /* BrowserManager.swift in Sources */, F17A3560F3BCEA64EC836066 /* BrowserProfile.swift in Sources */, @@ -518,6 +529,7 @@ B482CDE77A72210D26B23113 /* DefaultBrowserManager.swift in Sources */, 0A33D2BBB997B5E41111A10B /* FaviconManager.swift in Sources */, 2FD6EB21707A2B69F4806C54 /* FaviconView.swift in Sources */, + 5638331E5D1CAA78F0798769 /* FileShortcutResolver.swift in Sources */, 32606BF75E8AFF194C9DCCF5 /* GeneralSettingsView.swift in Sources */, AC29BAD9B762B13D72D93E71 /* HistoryEntry.swift in Sources */, E76DF418EF9630B8D7BE42F1 /* HistoryManager.swift in Sources */, diff --git a/BrowserCat/App/AppDelegate.swift b/BrowserCat/App/AppDelegate.swift index a7885ba..116f85e 100644 --- a/BrowserCat/App/AppDelegate.swift +++ b/BrowserCat/App/AppDelegate.swift @@ -44,6 +44,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - URL Handling + func application(_: NSApplication, open urls: [URL]) { + handleIncomingURLs(urls) + } + @objc private func handleURLEvent(_ event: NSAppleEventDescriptor, withReply _: NSAppleEventDescriptor) { guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue, let rawURL = URL(string: urlString) @@ -52,18 +56,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } - let url = URLUnwrapper.unwrap(rawURL) - if url != rawURL { - Log.app.info("Unwrapped URL: \(rawURL.absoluteString) → \(url.absoluteString)") + handleIncomingURLs([rawURL]) + } + + private func handleIncomingURLs(_ rawURLs: [URL]) { + let incomingURLs = rawURLs.map(normalizeIncomingURL) + let displayURLs = incomingURLs.map(\.displayURL) + let launchURLs = incomingURLs.map(\.launchURL) + guard let url = displayURLs.first else { return } + + appState.setPendingOpen(displayURLs: displayURLs, launchURLs: launchURLs) + fetchTitle(for: url) + + if rawURLs.count > 1 { + Log.app.info("Received \(rawURLs.count) URL(s); primary: \(url.absoluteString)") } else { - Log.app.info("Received URL: \(urlString)") + Log.app.info("Received URL: \(url.absoluteString)") } - appState.pendingURL = url - appState.pendingOriginalURL = (url != rawURL) ? rawURL : nil - appState.pendingURLTitle = nil - fetchTitle(for: url) - // Check URL rules before showing picker + // Check URL rules before showing picker. Multi-file opens use the first file/URL + // as the routing signal, then launch the full batch in the chosen browser. if let match = urlRulesManager.findMatch( for: url, browsers: appState.browsers, @@ -84,6 +96,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { pickerCoordinator.showPicker(state: appState) } + private func normalizeIncomingURL(_ rawURL: URL) -> (displayURL: URL, launchURL: URL) { + let shortcutURL = FileShortcutResolver.resolve(rawURL) + let displayURL = URLUnwrapper.unwrap(shortcutURL) + let launchURL = shortcutURL == rawURL ? rawURL : shortcutURL + + if shortcutURL != rawURL { + Log.app.info("Resolved shortcut: \(rawURL.absoluteString) → \(shortcutURL.absoluteString)") + } + + if displayURL != shortcutURL { + Log.app.info("Unwrapped URL: \(shortcutURL.absoluteString) → \(displayURL.absoluteString)") + } + + return (displayURL, launchURL) + } + // MARK: - Title Fetching private func fetchTitle(for url: URL) { diff --git a/BrowserCat/App/AppState.swift b/BrowserCat/App/AppState.swift index f017645..448c7b2 100644 --- a/BrowserCat/App/AppState.swift +++ b/BrowserCat/App/AppState.swift @@ -5,18 +5,21 @@ import os @Observable @MainActor final class AppState { - /// Unwrapped URL — used for rule matching, history, picker display, and suggestion analysis. + /// Normalized URL — used for rule matching, history, picker display, and suggestion analysis. var pendingURL: URL? - /// Original URL as received from the system — sent to the browser when launching, so wrappers - /// (Slack click tracking, Teams Safe Links security scan, OIDC handshake) still see the click. - /// `nil` when the URL didn't need unwrapping. - var pendingOriginalURL: URL? + /// Extra URLs from one system open request, usually multi-file opens from Finder. + /// The first item still lives in `pendingURL` to preserve the existing picker flow. + var pendingAdditionalURLs: [URL] = [] + /// URLs that should be launched. For wrapped links and shortcut files, this can differ + /// from the display/rule-matching URLs stored in `pendingURL` and `pendingAdditionalURLs`. + var pendingLaunchURLs: [URL] = [] var pendingURLTitle: String? var browsers: [InstalledBrowser] = [] var apps: [InstalledApp] = [] var lastOpenedURL: String? var isPickerVisible: Bool = false var isDefaultBrowser: Bool = false + var isDefaultWebFileHandler: Bool = false var focusedBrowserIndex: Int = 0 var urlRules: [URLRule] = [] @@ -54,4 +57,37 @@ final class AppState { appLanguage = SettingsStorage.shared.appLanguage Log.app.debug("AppState initialized") } + + var pendingDisplayURLs: [URL] { + guard let pendingURL else { return [] } + return [pendingURL] + pendingAdditionalURLs + } + + var launchURLsForPendingOpen: [URL] { + let displayURLs = pendingDisplayURLs + guard !pendingLaunchURLs.isEmpty, pendingLaunchURLs.count == displayURLs.count else { + return displayURLs + } + return pendingLaunchURLs + } + + func setPendingOpen(displayURLs: [URL], launchURLs: [URL]) { + guard let firstDisplayURL = displayURLs.first else { + clearPendingOpen() + return + } + + let normalizedLaunchURLs = launchURLs.count == displayURLs.count ? launchURLs : displayURLs + pendingURL = firstDisplayURL + pendingAdditionalURLs = Array(displayURLs.dropFirst()) + pendingLaunchURLs = normalizedLaunchURLs + pendingURLTitle = nil + } + + func clearPendingOpen() { + pendingURL = nil + pendingAdditionalURLs = [] + pendingLaunchURLs = [] + pendingURLTitle = nil + } } diff --git a/BrowserCat/Features/MenuBar/MenuBarContentView.swift b/BrowserCat/Features/MenuBar/MenuBarContentView.swift index 14061d5..5fb4c6f 100644 --- a/BrowserCat/Features/MenuBar/MenuBarContentView.swift +++ b/BrowserCat/Features/MenuBar/MenuBarContentView.swift @@ -49,7 +49,7 @@ struct MenuBarContentView: View { } if recentEntries.isEmpty { - Text("No recent URLs") + Text("No recent items") .foregroundStyle(.secondary) } else { ForEach(recentEntries) { entry in @@ -58,7 +58,7 @@ struct MenuBarContentView: View { } label: { Label( "\(timeText(entry.openedAt)) · \(shortPreview(entry))", - systemImage: iconForURL(entry.url) + systemImage: iconForEntry(entry) ) } } @@ -68,7 +68,7 @@ struct MenuBarContentView: View { Menu("History") { if todayEntries.isEmpty { - Text("No links today") + Text("No items today") } else { ForEach(todayEntries) { entry in Button { @@ -76,7 +76,7 @@ struct MenuBarContentView: View { } label: { Label( "\(timeText(entry.openedAt)) · \(shortPreview(entry)) — \(entry.appName)", - systemImage: iconForURL(entry.url) + systemImage: iconForEntry(entry) ) } } @@ -124,11 +124,20 @@ struct MenuBarContentView: View { } private func shortPreview(_ entry: HistoryEntry) -> String { - let value = normalized(entry.title) - ?? normalized(entry.domain) - ?? normalized(shortenURL(entry.url)) - ?? String(localized: "No URL") - return truncate(value, maxLength: 54) + let value: String? + switch entry.itemKind { + case .link: + value = normalized(entry.title) + ?? normalized(entry.domain) + ?? normalized(shortenURL(entry.url)) + case .file: + value = normalized(entry.fileName) + ?? normalized(entry.fileFormat) + ?? normalized(URL(string: entry.url)?.lastPathComponent) + } + let fallback = entry.itemKind == .file ? String(localized: "No file") : String(localized: "No URL") + let displayValue = value ?? fallback + return truncate(displayValue, maxLength: 54) } private func timeText(_ date: Date) -> String { @@ -178,6 +187,11 @@ struct MenuBarContentView: View { ("apple", "apple.logo"), ] + private func iconForEntry(_ entry: HistoryEntry) -> String { + guard entry.itemKind == .link else { return "doc" } + return iconForURL(entry.url) + } + private func iconForURL(_ urlString: String) -> String { guard let host = URL(string: urlString)?.host?.lowercased() else { return "globe" } for entry in Self.domainIcons { diff --git a/BrowserCat/Features/Picker/PickerView.swift b/BrowserCat/Features/Picker/PickerView.swift index 802c105..47d4a12 100644 --- a/BrowserCat/Features/Picker/PickerView.swift +++ b/BrowserCat/Features/Picker/PickerView.swift @@ -96,10 +96,22 @@ struct PickerItem: Identifiable { return prioritized + rest } - static func matchingApps(for url: URL?, in apps: [InstalledApp]) -> [InstalledApp] { + static func matchingApps( + for url: URL?, + in apps: [InstalledApp] + ) -> [InstalledApp] { guard let url else { return [] } + if url.isFileURL { + return InstalledApp.matchingFileApps(for: url, in: apps) + } return apps.filter { $0.matchesHost(of: url) } } + + static func matchingBrowsers(for url: URL?, in browsers: [InstalledBrowser]) -> [InstalledBrowser] { + guard let url else { return browsers } + guard url.isFileURL else { return browsers } + return BrowserFileType.isBrowserReadableFile(url) ? browsers : [] + } } struct PickerView: View { @@ -110,12 +122,15 @@ struct PickerView: View { @State private var profilePopoverBrowserID: String? private var browsers: [InstalledBrowser] { - appState.pickerBrowsers + PickerItem.matchingBrowsers(for: appState.pendingURL, in: appState.pickerBrowsers) } - /// Only apps that match the pending URL's host. + /// Only apps that match the pending URL's host or local file type. private var matchingApps: [InstalledApp] { - PickerItem.matchingApps(for: appState.pendingURL, in: appState.visibleApps) + PickerItem.matchingApps( + for: appState.pendingURL, + in: appState.visibleApps + ) } private var prioritizedAppIDs: Set { @@ -141,7 +156,11 @@ struct PickerView: View { private var normalBody: some View { VStack(spacing: 0) { // URL bar - URLBar(url: appState.pendingURL, title: appState.pendingURLTitle) + URLBar( + url: appState.pendingURL, + title: appState.pendingURLTitle, + additionalCount: appState.pendingAdditionalURLs.count + ) .padding(.horizontal, 12) .padding(.top, 12) .padding(.bottom, 8) @@ -162,8 +181,9 @@ struct PickerView: View { .padding(12) } - // Hint bar - hintBar + if appState.pendingURL?.isFileURL != true { + hintBar + } } .frame(minWidth: 380, maxWidth: 380, minHeight: 120, idealHeight: 300, maxHeight: 400) .onAppear { @@ -184,8 +204,9 @@ struct PickerView: View { .padding(.vertical, 12) } - // Hint bar - hintBar + if appState.pendingURL?.isFileURL != true { + hintBar + } } .onAppear { appState.focusedBrowserIndex = 0 diff --git a/BrowserCat/Features/Picker/PickerWindowController.swift b/BrowserCat/Features/Picker/PickerWindowController.swift index 6934607..e585f1a 100644 --- a/BrowserCat/Features/Picker/PickerWindowController.swift +++ b/BrowserCat/Features/Picker/PickerWindowController.swift @@ -61,7 +61,7 @@ final class PickerWindowController: NSObject { panel?.orderOut(nil) NSApp.setActivationPolicy(.accessory) appState.isPickerVisible = false - appState.pendingURL = nil + appState.clearPendingOpen() Log.picker.debug("Picker dismissed") } @@ -97,8 +97,11 @@ final class PickerWindowController: NSObject { // MARK: - Key Handling private func handleKeyEvent(_ event: NSEvent) -> Bool { - let browsers = appState.pickerBrowsers - let matchingApps = PickerItem.matchingApps(for: appState.pendingURL, in: appState.visibleApps) + let browsers = PickerItem.matchingBrowsers(for: appState.pendingURL, in: appState.pickerBrowsers) + let matchingApps = PickerItem.matchingApps( + for: appState.pendingURL, + in: appState.visibleApps + ) let prioritizedAppIDs = Set(matchingApps.map(\.id)) let items = PickerItem.buildItems( browsers: browsers, diff --git a/BrowserCat/Features/Picker/URLBar.swift b/BrowserCat/Features/Picker/URLBar.swift index 292a52e..7ab9d6d 100644 --- a/BrowserCat/Features/Picker/URLBar.swift +++ b/BrowserCat/Features/Picker/URLBar.swift @@ -3,33 +3,47 @@ import SwiftUI struct URLBar: View { let url: URL? let title: String? + var additionalCount: Int = 0 @State private var showCopied = false var body: some View { HStack(spacing: 8) { - Image(systemName: "link") + Image(systemName: url?.isFileURL == true ? "doc" : "link") .foregroundStyle(.secondary) .font(.system(size: 12)) VStack(alignment: .leading, spacing: 2) { - if let title, !title.isEmpty { - Text(title) + if let primaryText, !primaryText.isEmpty { + Text(primaryText) .font(.system(size: 11, weight: .medium)) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(.primary) } - Text(hostname) - .font(.system(size: title != nil ? 10 : 12, design: .monospaced)) + Text(secondaryText) + .font(.system(size: primaryText != nil ? 10 : 12, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) - .foregroundStyle(title != nil ? .secondary : .primary) + .foregroundStyle(primaryText != nil ? .secondary : .primary) .textSelection(.enabled) } Spacer() + if additionalCount > 0 { + Text("+\(additionalCount)") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + Capsule() + .fill(.tertiary) + ) + .help(String(localized: "Additional files")) + } + Button { copyURL() } label: { @@ -48,15 +62,26 @@ struct URLBar: View { ) } - private var hostname: String { + private var primaryText: String? { + if let url, url.isFileURL { + return url.lastPathComponent + } + guard let title, !title.isEmpty else { return nil } + return title + } + + private var secondaryText: String { guard let url else { return String(localized: "No URL") } + if url.isFileURL { + return url.deletingLastPathComponent().path + } return url.host() ?? url.absoluteString } private func copyURL() { guard let url else { return } NSPasteboard.general.clearContents() - NSPasteboard.general.setString(url.absoluteString, forType: .string) + NSPasteboard.general.setString(url.isFileURL ? url.path : url.absoluteString, forType: .string) showCopied = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { showCopied = false diff --git a/BrowserCat/Features/Settings/AppsSettingsView.swift b/BrowserCat/Features/Settings/AppsSettingsView.swift index 8ce9664..dc12b19 100644 --- a/BrowserCat/Features/Settings/AppsSettingsView.swift +++ b/BrowserCat/Features/Settings/AppsSettingsView.swift @@ -132,7 +132,7 @@ struct AppsSettingsView: View { Text("Native Apps") .font(.headline) .foregroundStyle(.secondary) - Text("Shown only for links they support") + Text("Shown for matching links and files") .font(.caption) .foregroundStyle(.tertiary) } diff --git a/BrowserCat/Features/Settings/GeneralSettingsView.swift b/BrowserCat/Features/Settings/GeneralSettingsView.swift index 1a14f8a..2abec73 100644 --- a/BrowserCat/Features/Settings/GeneralSettingsView.swift +++ b/BrowserCat/Features/Settings/GeneralSettingsView.swift @@ -23,6 +23,32 @@ struct GeneralSettingsView: View { } } } + + Text("Set as Default also configures supported web and dev file handlers.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Web & Dev Files") { + HStack { + if appState.isDefaultWebFileHandler { + Label("BrowserCat handles web and dev files", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label("BrowserCat does not handle web and dev files", systemImage: "xmark.circle") + .foregroundStyle(.secondary) + + Spacer() + + Button("Set for Files") { + defaultBrowserManager?.setAsDefaultForWebFiles(state: appState) + } + } + } + + Text("Applies to browser-readable files plus developer/config files like .env, YAML, shell scripts, Dockerfile, Makefile, and systemd units.") + .font(.caption) + .foregroundStyle(.secondary) } Section("Startup") { @@ -40,7 +66,7 @@ struct GeneralSettingsView: View { } Section("Menu Bar") { - Picker("Recent links", selection: Binding( + Picker("Recent items", selection: Binding( get: { appState.recentLinksCount }, set: { newValue in appState.recentLinksCount = newValue diff --git a/BrowserCat/Features/Settings/HistorySettingsView.swift b/BrowserCat/Features/Settings/HistorySettingsView.swift index b38a9fb..160eedf 100644 --- a/BrowserCat/Features/Settings/HistorySettingsView.swift +++ b/BrowserCat/Features/Settings/HistorySettingsView.swift @@ -49,7 +49,7 @@ struct HistorySettingsView: View { .foregroundStyle(.secondary) Text("No History") .font(.headline) - Text("URLs you open will appear here.") + Text("Links and files you open will appear here.") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -67,8 +67,10 @@ struct HistorySettingsView: View { historyRow(entry) .tag(entry.id) .contextMenu { - Button(String(localized: "Create Rule…")) { - ruleFromHistory = makeRule(from: entry) + if canCreateRule(from: entry), !ruleAlreadyCovers(entry) { + Button(String(localized: "Create Rule…")) { + ruleFromHistory = makeRule(from: entry) + } } } } @@ -80,16 +82,16 @@ struct HistorySettingsView: View { private func historyRow(_ entry: HistoryEntry) -> some View { let hasRule = ruleAlreadyCovers(entry) - let canCreate = entry.browserID != nil || legacyTargetResolves(for: entry) + let canCreate = canCreateRule(from: entry) return HStack(spacing: 10) { - FaviconView(urlString: entry.url, fallbackDomain: entry.domain, size: 20) + entryIcon(entry) VStack(alignment: .leading, spacing: 2) { - Text(entry.domain) + Text(primaryText(for: entry)) .font(.system(size: 12, weight: .medium)) - if let title = entry.title, !title.isEmpty { - Text(title) + if let subtitle = secondaryText(for: entry) { + Text(subtitle) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) @@ -117,6 +119,42 @@ struct HistorySettingsView: View { .padding(.vertical, 2) } + @ViewBuilder + private func entryIcon(_ entry: HistoryEntry) -> some View { + switch entry.itemKind { + case .link: + FaviconView(urlString: entry.url, fallbackDomain: entry.domain, size: 20) + case .file: + Image(systemName: "doc") + .font(.system(size: 18)) + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + } + } + + private func primaryText(for entry: HistoryEntry) -> String { + switch entry.itemKind { + case .link: + return entry.domain + case .file: + return entry.fileName ?? entry.domain + } + } + + private func secondaryText(for entry: HistoryEntry) -> String? { + switch entry.itemKind { + case .link: + return normalized(entry.title) + case .file: + let format = entry.fileFormat ?? entry.contentTypeIdentifier + let path = URL(string: entry.url)?.path + if let format, let path { + return "\(format) · \(path)" + } + return format ?? path + } + } + private func createRuleButton(for entry: HistoryEntry, hasRule: Bool, canCreate: Bool) -> some View { Button { ruleFromHistory = makeRule(from: entry) @@ -145,10 +183,15 @@ struct HistorySettingsView: View { } private func ruleAlreadyCovers(_ entry: HistoryEntry) -> Bool { + guard entry.itemKind == .link else { return false } guard let url = URL(string: entry.url) else { return false } return ruleMatcher.findMatchingRule(for: url, rules: appState.urlRules) != nil } + private func canCreateRule(from entry: HistoryEntry) -> Bool { + entry.itemKind == .link && (entry.browserID != nil || legacyTargetResolves(for: entry)) + } + private func legacyTargetResolves(for entry: HistoryEntry) -> Bool { appState.browsers.contains(where: { $0.displayName == entry.appName }) || appState.apps.contains(where: { $0.displayName == entry.appName }) @@ -260,4 +303,10 @@ struct HistorySettingsView: View { if !older.isEmpty { groups.append(DateGroup(label: String(localized: "Older"), entries: older)) } return groups } + + private func normalized(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } diff --git a/BrowserCat/Features/Settings/SettingsView.swift b/BrowserCat/Features/Settings/SettingsView.swift index f424a67..00199d1 100644 --- a/BrowserCat/Features/Settings/SettingsView.swift +++ b/BrowserCat/Features/Settings/SettingsView.swift @@ -36,7 +36,7 @@ struct SettingsView: View { Label("About", systemImage: "info.circle") } } - .frame(width: 450, height: 550) + .frame(width: 620, height: 620) .background(SettingsWindowConfigurator().frame(width: 0, height: 0)) .environment(\.locale, appState.appLanguage.locale) } diff --git a/BrowserCat/Managers/DefaultBrowserManager.swift b/BrowserCat/Managers/DefaultBrowserManager.swift index 3977d32..e44808b 100644 --- a/BrowserCat/Managers/DefaultBrowserManager.swift +++ b/BrowserCat/Managers/DefaultBrowserManager.swift @@ -1,11 +1,13 @@ import AppKit import os +import UniformTypeIdentifiers @MainActor final class DefaultBrowserManager { func checkIsDefault(state: AppState) { let httpDefault = isCurrentAppDefault(for: "http") let httpsDefault = isCurrentAppDefault(for: "https") + state.isDefaultWebFileHandler = isDefaultForWebFileTypes() if let httpDefault, let httpsDefault { state.isDefaultBrowser = httpDefault && httpsDefault @@ -29,12 +31,40 @@ final class DefaultBrowserManager { schemes: ["http", "https"], index: 0, state: state + ) { [weak self] in + self?.setDefaultApplication( + at: bundleURL, + contentTypes: BrowserFileType.defaultHandlerContentTypes, + index: 0, + state: state + ) + } + } + + func setAsDefaultForWebFiles(state: AppState) { + guard let bundleURL = Bundle.main.bundleURL as URL? else { return } + + setDefaultApplication( + at: bundleURL, + contentTypes: BrowserFileType.defaultHandlerContentTypes, + index: 0, + state: state ) } - private func setDefaultApplication(at bundleURL: URL, schemes: [String], index: Int, state: AppState) { + private func setDefaultApplication( + at bundleURL: URL, + schemes: [String], + index: Int, + state: AppState, + completion: (() -> Void)? = nil + ) { guard index < schemes.count else { - checkIsDefault(state: state) + if let completion { + completion() + } else { + checkIsDefault(state: state) + } return } @@ -57,7 +87,8 @@ final class DefaultBrowserManager { at: bundleURL, schemes: schemes, index: index + 1, - state: state + state: state, + completion: completion ) } } @@ -78,6 +109,54 @@ final class DefaultBrowserManager { return defaultAppURL.resolvingSymlinksInPath().path == Bundle.main.bundleURL.resolvingSymlinksInPath().path } + private func setDefaultApplication(at bundleURL: URL, contentTypes: [UTType], index: Int, state: AppState) { + guard index < contentTypes.count else { + checkIsDefault(state: state) + return + } + + let contentType = contentTypes[index] + NSWorkspace.shared.setDefaultApplication( + at: bundleURL, + toOpen: contentType + ) { error in + Task { @MainActor [weak self] in + guard let self else { return } + + if let error { + Log.app.error("Failed to set default web file handler (\(contentType.identifier)): \(error.localizedDescription)") + self.checkIsDefault(state: state) + return + } + + self.setDefaultApplication( + at: bundleURL, + contentTypes: contentTypes, + index: index + 1, + state: state + ) + } + } + } + + private func isDefaultForWebFileTypes() -> Bool { + let contentTypes = BrowserFileType.defaultHandlerContentTypes + guard !contentTypes.isEmpty else { return false } + + return contentTypes.allSatisfy { contentType in + guard let defaultAppURL = NSWorkspace.shared.urlForApplication(toOpen: contentType) else { + return false + } + + if let currentBundleID = Bundle.main.bundleIdentifier, + let defaultBundleID = Bundle(url: defaultAppURL)?.bundleIdentifier { + return currentBundleID == defaultBundleID + } + + return defaultAppURL.resolvingSymlinksInPath().path == Bundle.main.bundleURL.resolvingSymlinksInPath().path + } + } + private func openDefaultBrowserSettings() { let fallbackURLs = [ URL(string: "x-apple.systempreferences:com.apple.preference.general?DefaultWebBrowser"), diff --git a/BrowserCat/Managers/HistoryManager.swift b/BrowserCat/Managers/HistoryManager.swift index bddb2c7..7ef6de8 100644 --- a/BrowserCat/Managers/HistoryManager.swift +++ b/BrowserCat/Managers/HistoryManager.swift @@ -1,5 +1,6 @@ import Foundation import os +import UniformTypeIdentifiers @MainActor final class HistoryManager { @@ -25,7 +26,8 @@ final class HistoryManager { targetType: URLRule.TargetType?, state: AppState ) -> UUID { - let domain = url.host ?? url.absoluteString + let metadata = metadata(for: url) + let domain = metadata.domain let entry = HistoryEntry( url: url.absoluteString, domain: domain, @@ -34,14 +36,19 @@ final class HistoryManager { profileName: profileName, browserID: browserID, profileDirectoryName: profileDirectoryName, - targetType: targetType + targetType: targetType, + itemKind: metadata.itemKind, + fileName: metadata.fileName, + fileExtension: metadata.fileExtension, + fileFormat: metadata.fileFormat, + contentTypeIdentifier: metadata.contentTypeIdentifier ) state.history.insert(entry, at: 0) if state.history.count > maxHistoryEntries { state.history = Array(state.history.prefix(maxHistoryEntries)) } HistoryStorage.shared.save(state.history) - Log.history.debug("Recorded history entry for \(domain)") + log(entry) return entry.id } @@ -68,4 +75,77 @@ final class HistoryManager { HistoryStorage.shared.save(state.history) Log.history.debug("Cleared all history") } + + private struct EntryMetadata { + let itemKind: HistoryEntry.ItemKind + let domain: String + let fileName: String? + let fileExtension: String? + let fileFormat: String? + let contentTypeIdentifier: String? + } + + private func metadata(for url: URL) -> EntryMetadata { + guard url.isFileURL else { + return EntryMetadata( + itemKind: .link, + domain: url.host ?? url.absoluteString, + fileName: nil, + fileExtension: nil, + fileFormat: nil, + contentTypeIdentifier: nil + ) + } + + let fileName = url.lastPathComponent.isEmpty ? url.standardizedFileURL.path : url.lastPathComponent + let fileExtension = normalizedFileExtension(for: url) + let contentType = contentType(for: url) + let fileFormat = fileFormat(for: url, fileExtension: fileExtension, contentType: contentType) + + return EntryMetadata( + itemKind: .file, + domain: fileName, + fileName: fileName, + fileExtension: fileExtension, + fileFormat: fileFormat, + contentTypeIdentifier: contentType?.identifier + ) + } + + private func normalizedFileExtension(for url: URL) -> String? { + let value = url.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value.isEmpty ? nil : value + } + + private func contentType(for url: URL) -> UTType? { + if let type = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + return type + } + if let fileExtension = normalizedFileExtension(for: url) { + return UTType(filenameExtension: fileExtension) + } + return nil + } + + private func fileFormat(for url: URL, fileExtension: String?, contentType: UTType?) -> String? { + let fileName = url.lastPathComponent + if fileName.hasPrefix(".") || fileExtension == nil { + return fileName.isEmpty ? contentType?.identifier : fileName + } + if let fileExtension { + return ".\(fileExtension)" + } + return contentType?.identifier + } + + private func log(_ entry: HistoryEntry) { + switch entry.itemKind { + case .link: + Log.history.info("Opened link \(entry.url) in \(entry.appName)") + case .file: + let format = entry.fileFormat ?? entry.contentTypeIdentifier ?? "unknown" + let fileName = entry.fileName ?? entry.url + Log.history.info("Opened file \(fileName) format=\(format) in \(entry.appName)") + } + } } diff --git a/BrowserCat/Managers/PickerCoordinator.swift b/BrowserCat/Managers/PickerCoordinator.swift index c4fde37..ca5a382 100644 --- a/BrowserCat/Managers/PickerCoordinator.swift +++ b/BrowserCat/Managers/PickerCoordinator.swift @@ -33,58 +33,69 @@ final class PickerCoordinator { source: OpenSource = .pickerClick ) { guard let url = state.pendingURL else { return } - // Launch the original (wrapped) URL so Slack click tracking, Teams Safe Links security - // scanning, OIDC handshakes, etc. still see the click. The unwrapped URL is only used + let displayURLs = state.pendingDisplayURLs + // Launch the original/wrapped URL(s) so Slack click tracking, Teams Safe Links security + // scanning, OIDC handshakes, etc. still see the click. The normalized URL is only used // internally for rule matching, history, and suggestions. - let urlForLaunch = state.pendingOriginalURL ?? url - browserLauncher.open(url: urlForLaunch, with: browser, mode: mode, profile: profile) - let entryID = historyManager?.record( - url: url, - title: state.pendingURLTitle, - appName: browser.displayName, - profileName: profile?.displayName, - browserID: browser.id, - profileDirectoryName: profile?.directoryName, - targetType: .browser, - state: state - ) + let launchURLs = state.launchURLsForPendingOpen + browserLauncher.open(urls: launchURLs, with: browser, mode: mode, profile: profile) + let entryIDs = displayURLs.enumerated().map { index, displayURL in + historyManager?.record( + url: displayURL, + title: index == 0 ? state.pendingURLTitle : nil, + appName: browser.displayName, + profileName: profile?.displayName, + browserID: browser.id, + profileDirectoryName: profile?.directoryName, + targetType: .browser, + state: state + ) + } statsManager?.record(source) - resolveFinalURL(forEntry: entryID, sourceURL: urlForLaunch, displayURL: url, state: state) + for (index, entryID) in entryIDs.enumerated() { + guard launchURLs.indices.contains(index), displayURLs.indices.contains(index) else { continue } + resolveFinalURL(forEntry: entryID, sourceURL: launchURLs[index], displayURL: displayURLs[index], state: state) + } completeURLOpen(url, state: state) } func openURL(with app: InstalledApp, state: AppState, source: OpenSource = .pickerClick) { guard let url = state.pendingURL else { return } - let urlForLaunch = state.pendingOriginalURL ?? url - browserLauncher.open(url: urlForLaunch, with: app) - let entryID = historyManager?.record( - url: url, - title: state.pendingURLTitle, - appName: app.displayName, - profileName: nil, - browserID: app.id, - profileDirectoryName: nil, - targetType: .app, - state: state - ) + let displayURLs = state.pendingDisplayURLs + let launchURLs = state.launchURLsForPendingOpen + for launchURL in launchURLs { + browserLauncher.open(url: launchURL, with: app) + } + let entryIDs = displayURLs.enumerated().map { index, displayURL in + historyManager?.record( + url: displayURL, + title: index == 0 ? state.pendingURLTitle : nil, + appName: app.displayName, + profileName: nil, + browserID: app.id, + profileDirectoryName: nil, + targetType: .app, + state: state + ) + } statsManager?.record(source) - resolveFinalURL(forEntry: entryID, sourceURL: urlForLaunch, displayURL: url, state: state) + for (index, entryID) in entryIDs.enumerated() { + guard launchURLs.indices.contains(index), displayURLs.indices.contains(index) else { continue } + resolveFinalURL(forEntry: entryID, sourceURL: launchURLs[index], displayURL: displayURLs[index], state: state) + } completeURLOpen(url, state: state) } func reopenURL(_ urlString: String, state: AppState) { guard let url = URL(string: urlString) else { return } - state.pendingURL = url - state.pendingOriginalURL = nil + state.setPendingOpen(displayURLs: [url], launchURLs: [url]) showPicker(state: state) } private func completeURLOpen(_ url: URL, state: AppState) { state.lastOpenedURL = url.absoluteString SettingsStorage.shared.lastURL = url.absoluteString - state.pendingURL = nil - state.pendingOriginalURL = nil - state.pendingURLTitle = nil + state.clearPendingOpen() dismissPicker(state: state) suggestionsManager?.recompute(state: state) } diff --git a/BrowserCat/Managers/StatsManager.swift b/BrowserCat/Managers/StatsManager.swift index b6f8382..c11c6e2 100644 --- a/BrowserCat/Managers/StatsManager.swift +++ b/BrowserCat/Managers/StatsManager.swift @@ -67,7 +67,8 @@ final class StatsManager { for entry in history { let key = DailyStats.dayKey(for: entry.openedAt) var day = byDay[key] ?? DailyStats(day: key) - if let url = URL(string: entry.url), + if entry.itemKind == .link, + let url = URL(string: entry.url), let rule = urlRuleMatcher.findMatchingRule(for: url, rules: rules) { day.autoRouteCount += 1 diff --git a/BrowserCat/Models/AppDefinition.swift b/BrowserCat/Models/AppDefinition.swift index 95ad59e..697c632 100644 --- a/BrowserCat/Models/AppDefinition.swift +++ b/BrowserCat/Models/AppDefinition.swift @@ -10,8 +10,167 @@ struct AppDefinition { /// Optional URL converter: transforms an HTTPS URL into a deep link URL. /// If nil, the HTTPS URL is passed directly to the app via `open -a`. let convertURL: ((URL) -> URL?)? + /// File picker support for developer/config files. + let filePatterns: [String] + let handlesAllFiles: Bool + let filePickerPriority: Int? + + init( + bundleID: String, + displayName: String, + hostPatterns: [String] = [], + urlScheme: String? = nil, + convertURL: ((URL) -> URL?)? = nil, + filePatterns: [String] = [], + handlesAllFiles: Bool = false, + filePickerPriority: Int? = nil + ) { + self.bundleID = bundleID + self.displayName = displayName + self.hostPatterns = hostPatterns + self.urlScheme = urlScheme + self.convertURL = convertURL + self.filePatterns = filePatterns.map(Self.normalizeFilePattern) + self.handlesAllFiles = handlesAllFiles + self.filePickerPriority = filePickerPriority + } + + func matchesFile(_ url: URL) -> Bool { + guard url.isFileURL, filePickerPriority != nil else { return false } + if handlesAllFiles { return true } + + let patterns = Set(filePatterns) + guard !patterns.isEmpty else { return false } + return !BrowserFileType.fileMatchTokens(for: url).isDisjoint(with: patterns) + || BrowserFileType.isDeveloperFile(url) + } + + private static func normalizeFilePattern(_ pattern: String) -> String { + let value = pattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value.hasPrefix(".") ? String(value.dropFirst()) : value + } static let registry: [AppDefinition] = [ + // Developer editors and IDEs + AppDefinition( + bundleID: "com.sublimetext.4", + displayName: "Sublime Text", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 0 + ), + AppDefinition( + bundleID: "com.sublimetext.3", + displayName: "Sublime Text", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 1 + ), + AppDefinition( + bundleID: "com.todesktop.230313mzl4w4u92", + displayName: "Cursor", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 10 + ), + AppDefinition( + bundleID: "dev.zed.Zed", + displayName: "Zed", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 12 + ), + AppDefinition( + bundleID: "com.microsoft.VSCodeInsiders", + displayName: "VS Code Insiders", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 15 + ), + AppDefinition( + bundleID: "com.apple.dt.Xcode", + displayName: "Xcode", + filePatterns: BrowserFileType.developerFilePatterns, + filePickerPriority: 30 + ), + AppDefinition( + bundleID: "com.jetbrains.intellij", + displayName: "IntelliJ IDEA", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 40 + ), + AppDefinition( + bundleID: "com.jetbrains.intellij.ce", + displayName: "IntelliJ IDEA CE", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 41 + ), + AppDefinition( + bundleID: "com.jetbrains.pycharm", + displayName: "PyCharm", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 42 + ), + AppDefinition( + bundleID: "com.jetbrains.pycharm.ce", + displayName: "PyCharm CE", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 43 + ), + AppDefinition( + bundleID: "com.jetbrains.WebStorm", + displayName: "WebStorm", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 44 + ), + AppDefinition( + bundleID: "com.jetbrains.goland", + displayName: "GoLand", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 45 + ), + AppDefinition( + bundleID: "com.jetbrains.CLion", + displayName: "CLion", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 46 + ), + AppDefinition( + bundleID: "com.jetbrains.rider", + displayName: "Rider", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 47 + ), + AppDefinition( + bundleID: "com.jetbrains.rubymine", + displayName: "RubyMine", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 48 + ), + AppDefinition( + bundleID: "com.jetbrains.PhpStorm", + displayName: "PhpStorm", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 49 + ), + AppDefinition( + bundleID: "com.jetbrains.datagrip", + displayName: "DataGrip", + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 50 + ), + // Microsoft Teams AppDefinition( bundleID: "com.microsoft.teams2", @@ -151,7 +310,10 @@ struct AppDefinition { displayName: "VS Code", hostPatterns: ["vscode.dev", "insiders.vscode.dev"], urlScheme: "vscode", - convertURL: nil + convertURL: nil, + filePatterns: BrowserFileType.developerFilePatterns, + handlesAllFiles: true, + filePickerPriority: 14 ), // Obsidian AppDefinition( diff --git a/BrowserCat/Models/BrowserFileType.swift b/BrowserCat/Models/BrowserFileType.swift new file mode 100644 index 0000000..f9f3097 --- /dev/null +++ b/BrowserCat/Models/BrowserFileType.swift @@ -0,0 +1,240 @@ +import UniformTypeIdentifiers + +enum BrowserFileType { + static let browserReadableContentTypeIdentifiers = [ + "public.html", + "public.xhtml", + "public.svg-image", + "public.xml", + "public.json", + "public.plain-text", + "com.adobe.pdf", + "com.apple.webarchive", + "com.apple.web-internet-location", + "public.url", + "com.microsoft.internet-shortcut", + "org.ietf.mhtml", + ] + + static let developerContentTypeIdentifiers = [ + "ua.com.rmarinsky.browsercat.env-config", + "public.yaml", + "public.source-code", + "public.script", + "public.shell-script", + "public.python-script", + "public.ruby-script", + "public.perl-script", + "public.php-script", + "public.c-source", + "public.c-plus-plus-source", + "public.c-header", + "public.c-plus-plus-header", + "public.objective-c-source", + "public.objective-c-plus-plus-source", + "public.swift-source", + "com.netscape.javascript-source", + "com.apple.property-list", + "com.apple.applescript.text", + ] + + static let genericFileContentTypeIdentifiers = [ + "public.data", + ] + + static let developerFilePatterns = [ + "env", + "env.local", + "env.development", + "env.production", + "env.staging", + "env.test", + "env.example", + "env.sample", + "local", + "development", + "production", + "staging", + "test", + "example", + "sample", + "azure.yaml", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "config", + "properties", + "plist", + "editorconfig", + "gitignore", + "gitattributes", + "gitconfig", + "gitmodules", + "dockerignore", + "npmrc", + "nvmrc", + "yarnrc", + "tool-versions", + "bashrc", + "bash_profile", + "bash_aliases", + "profile", + "zprofile", + "zshenv", + "zshrc", + "zlogin", + "zlogout", + "fish", + "sh", + "bash", + "zsh", + "csh", + "ksh", + "service", + "timer", + "socket", + "target", + "mount", + "automount", + "path", + "dockerfile", + "dockerfile.dev", + "dockerfile.prod", + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", + "makefile", + "gnumakefile", + "cmakelists.txt", + "jenkinsfile", + "justfile", + "taskfile", + "taskfile.yaml", + "taskfile.yml", + "procfile", + "gemfile", + "rakefile", + "brewfile", + "vagrantfile", + "caddyfile", + "nginx.conf", + "httpd.conf", + "sql", + "graphql", + "tf", + "tfvars", + "hcl", + "nomad", + "gradle", + "kts", + "pom", + "xml", + "json", + "jsonc", + "lock", + "md", + "markdown", + "txt", + "log", + ] + + static let browserReadableDefaultHandlerContentTypeIdentifiers = [ + "public.html", + "public.xhtml", + "public.svg-image", + "com.apple.webarchive", + "com.apple.web-internet-location", + "public.url", + "com.microsoft.internet-shortcut", + "org.ietf.mhtml", + ] + + static let supportedContentTypeIdentifiers = orderedUnique( + browserReadableContentTypeIdentifiers + developerContentTypeIdentifiers + genericFileContentTypeIdentifiers + ) + + static let defaultHandlerContentTypeIdentifiers = orderedUnique( + browserReadableDefaultHandlerContentTypeIdentifiers + developerContentTypeIdentifiers + genericFileContentTypeIdentifiers + ) + + static var defaultHandlerContentTypes: [UTType] { + let explicitTypes = defaultHandlerContentTypeIdentifiers.compactMap(UTType.init) + let extensionTypes = developerFilePatterns.compactMap { UTType(filenameExtension: $0) } + return orderedUnique(explicitTypes + extensionTypes, by: \.identifier) + } + + static func fileMatchTokens(for url: URL) -> Set { + guard url.isFileURL else { return [] } + + let name = url.lastPathComponent.lowercased() + let extensionName = url.pathExtension.lowercased() + var tokens: Set = [name] + + if !extensionName.isEmpty { + tokens.insert(extensionName) + } + + if name.hasPrefix(".") { + tokens.insert(String(name.dropFirst())) + } + + return tokens.filter { !$0.isEmpty } + } + + static func isBrowserReadableFile(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + + let identifiers = Set(browserReadableContentTypeIdentifiers) + if let contentType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + if identifiers.contains(contentType.identifier) { + return true + } + if browserReadableContentTypeIdentifiers + .compactMap(UTType.init) + .contains(where: { contentType.conforms(to: $0) }) + { + return true + } + } + + return !fileMatchTokens(for: url).isDisjoint(with: [ + "html", "htm", "xhtml", "xht", "svg", "xml", "json", "txt", + "text", "pdf", "webarchive", "webloc", "url", "mhtml", "mht", + ]) + } + + static func isDeveloperFile(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + + if !fileMatchTokens(for: url).isDisjoint(with: Set(developerFilePatterns)) { + return true + } + + let identifiers = Set(developerContentTypeIdentifiers) + guard let contentType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType else { + return false + } + + if identifiers.contains(contentType.identifier) { + return true + } + + return developerContentTypeIdentifiers + .compactMap(UTType.init) + .contains { contentType.conforms(to: $0) } + } + + private static func orderedUnique(_ values: [T]) -> [T] { + var seen = Set() + return values.filter { seen.insert($0).inserted } + } + + private static func orderedUnique(_ values: [T], by keyPath: KeyPath) -> [T] { + var seen = Set() + return values.filter { seen.insert($0[keyPath: keyPath]).inserted } + } +} diff --git a/BrowserCat/Models/HistoryEntry.swift b/BrowserCat/Models/HistoryEntry.swift index 1ef070d..60245eb 100644 --- a/BrowserCat/Models/HistoryEntry.swift +++ b/BrowserCat/Models/HistoryEntry.swift @@ -1,6 +1,11 @@ import Foundation struct HistoryEntry: Identifiable, Codable, Equatable { + enum ItemKind: String, Codable { + case link + case file + } + let id: UUID var url: String var domain: String @@ -12,6 +17,11 @@ struct HistoryEntry: Identifiable, Codable, Equatable { let browserID: String? let profileDirectoryName: String? let targetType: URLRule.TargetType? + let itemKind: ItemKind + let fileName: String? + let fileExtension: String? + let fileFormat: String? + let contentTypeIdentifier: String? init( id: UUID = UUID(), @@ -23,7 +33,12 @@ struct HistoryEntry: Identifiable, Codable, Equatable { openedAt: Date = Date(), browserID: String? = nil, profileDirectoryName: String? = nil, - targetType: URLRule.TargetType? = nil + targetType: URLRule.TargetType? = nil, + itemKind: ItemKind = .link, + fileName: String? = nil, + fileExtension: String? = nil, + fileFormat: String? = nil, + contentTypeIdentifier: String? = nil ) { self.id = id self.url = url @@ -35,6 +50,11 @@ struct HistoryEntry: Identifiable, Codable, Equatable { self.browserID = browserID self.profileDirectoryName = profileDirectoryName self.targetType = targetType + self.itemKind = itemKind + self.fileName = fileName + self.fileExtension = fileExtension + self.fileFormat = fileFormat + self.contentTypeIdentifier = contentTypeIdentifier } init(from decoder: Decoder) throws { @@ -49,5 +69,11 @@ struct HistoryEntry: Identifiable, Codable, Equatable { browserID = try container.decodeIfPresent(String.self, forKey: .browserID) profileDirectoryName = try container.decodeIfPresent(String.self, forKey: .profileDirectoryName) targetType = try container.decodeIfPresent(URLRule.TargetType.self, forKey: .targetType) + itemKind = try container.decodeIfPresent(ItemKind.self, forKey: .itemKind) + ?? (url.hasPrefix("file://") ? .file : .link) + fileName = try container.decodeIfPresent(String.self, forKey: .fileName) + fileExtension = try container.decodeIfPresent(String.self, forKey: .fileExtension) + fileFormat = try container.decodeIfPresent(String.self, forKey: .fileFormat) + contentTypeIdentifier = try container.decodeIfPresent(String.self, forKey: .contentTypeIdentifier) } } diff --git a/BrowserCat/Models/InstalledApp.swift b/BrowserCat/Models/InstalledApp.swift index fe5b211..363e995 100644 --- a/BrowserCat/Models/InstalledApp.swift +++ b/BrowserCat/Models/InstalledApp.swift @@ -38,6 +38,25 @@ struct InstalledApp: Identifiable, Equatable { return host == p || host.hasSuffix(".\(p)") } } + + func matchesFile(_ url: URL) -> Bool { + AppDefinition.registryByID[id]?.matchesFile(url) == true + } + + static func matchingFileApps(for url: URL, in apps: [InstalledApp]) -> [InstalledApp] { + guard url.isFileURL else { return [] } + + return apps + .filter { $0.isVisible && $0.matchesFile(url) } + .sorted { lhs, rhs in + let lhsPriority = AppDefinition.registryByID[lhs.id]?.filePickerPriority ?? Int.max + let rhsPriority = AppDefinition.registryByID[rhs.id]?.filePickerPriority ?? Int.max + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return lhs.sortOrder < rhs.sortOrder + } + } } // MARK: - Codable support (without icon) diff --git a/BrowserCat/Resources/Info.plist b/BrowserCat/Resources/Info.plist index aa61b56..6a86188 100644 --- a/BrowserCat/Resources/Info.plist +++ b/BrowserCat/Resources/Info.plist @@ -34,6 +34,40 @@ E+U25RLLvknFBaJXFMZ5YnpN4gRJLsavDNEGc+M2xk0= SUEnableAutomaticChecks + UTImportedTypeDeclarations + + + UTTypeIdentifier + ua.com.rmarinsky.browsercat.env-config + UTTypeDescription + Environment Configuration + UTTypeConformsTo + + public.text + public.data + + UTTypeTagSpecification + + public.filename-extension + + env.local + env.development + env.production + env.staging + env.test + env.example + env.sample + local + development + production + staging + test + example + sample + + + + CFBundleURLTypes @@ -52,7 +86,7 @@ CFBundleTypeName - HTML Document + Browser-readable Document CFBundleTypeRole Viewer LSHandlerRank @@ -61,7 +95,177 @@ public.html public.xhtml + public.svg-image + public.xml + public.json + public.plain-text + com.adobe.pdf + com.apple.webarchive + com.apple.web-internet-location public.url + com.microsoft.internet-shortcut + org.ietf.mhtml + public.yaml + public.source-code + public.script + public.shell-script + public.python-script + public.ruby-script + public.perl-script + public.php-script + public.c-source + public.c-plus-plus-source + public.c-header + public.c-plus-plus-header + public.objective-c-source + public.objective-c-plus-plus-source + public.swift-source + com.netscape.javascript-source + com.apple.property-list + com.apple.applescript.text + public.data + + CFBundleTypeExtensions + + html + htm + xhtml + xht + svg + xml + json + txt + text + pdf + webarchive + webloc + url + mhtml + mht + .env + .env.local + .env.development + .env.production + .env.staging + .env.test + .env.example + .env.sample + .azure.yaml + .gitignore + .gitattributes + .gitconfig + .gitmodules + .dockerignore + .npmrc + .nvmrc + .yarnrc + .editorconfig + env + env.local + env.development + env.production + env.staging + env.test + env.example + env.sample + local + development + production + staging + test + example + sample + azure.yaml + yaml + yml + toml + ini + cfg + conf + config + properties + plist + editorconfig + gitignore + gitattributes + gitconfig + gitmodules + dockerignore + npmrc + nvmrc + yarnrc + tool-versions + bashrc + bash_profile + bash_aliases + profile + zprofile + zshenv + zshrc + zlogin + zlogout + fish + sh + bash + zsh + csh + ksh + service + timer + socket + target + mount + automount + path + Dockerfile + dockerfile + Dockerfile.dev + Dockerfile.prod + compose.yaml + compose.yml + docker-compose.yaml + docker-compose.yml + Makefile + makefile + GNUmakefile + gnumakefile + CMakeLists.txt + Jenkinsfile + jenkinsfile + Justfile + justfile + Taskfile + taskfile + Taskfile.yaml + Taskfile.yml + Procfile + procfile + Gemfile + gemfile + Rakefile + rakefile + Brewfile + brewfile + Vagrantfile + vagrantfile + Caddyfile + caddyfile + nginx.conf + httpd.conf + sql + graphql + tf + tfvars + hcl + nomad + gradle + kts + pom + jsonc + lock + md + markdown + log diff --git a/BrowserCat/Resources/en.lproj/Localizable.strings b/BrowserCat/Resources/en.lproj/Localizable.strings index c1abb07..0b28580 100644 --- a/BrowserCat/Resources/en.lproj/Localizable.strings +++ b/BrowserCat/Resources/en.lproj/Localizable.strings @@ -1,5 +1,6 @@ "General" = "General"; "Apps" = "Apps"; +"File Associations" = "File Associations"; "Rules" = "Rules"; "History" = "History"; "About" = "About"; @@ -8,12 +9,26 @@ "BrowserCat is the default browser" = "BrowserCat is the default browser"; "BrowserCat is not the default browser" = "BrowserCat is not the default browser"; "Set as Default" = "Set as Default"; +"Set as Default also makes BrowserCat the handler for browser-readable web files." = "Set as Default also makes BrowserCat the handler for browser-readable web files."; +"Set as Default handles http/https links. File handlers are configured below." = "Set as Default handles http/https links. File handlers are configured below."; +"Set as Default also configures supported web and dev file handlers." = "Set as Default also configures supported web and dev file handlers."; +"Web Files" = "Web Files"; +"Web & Dev Files" = "Web & Dev Files"; +"BrowserCat handles web files" = "BrowserCat handles web files"; +"BrowserCat handles web and dev files" = "BrowserCat handles web and dev files"; +"BrowserCat does not handle web files" = "BrowserCat does not handle web files"; +"BrowserCat does not handle web and dev files" = "BrowserCat does not handle web and dev files"; +"Set for Web Files" = "Set for Web Files"; +"Set for Files" = "Set for Files"; +"Applies to HTML, XHTML, SVG, web archive, web location, internet shortcut, and MHTML files." = "Applies to HTML, XHTML, SVG, web archive, web location, internet shortcut, and MHTML files."; +"Applies to browser-readable files plus developer/config files like .env, YAML, shell scripts, Dockerfile, Makefile, and systemd units." = "Applies to browser-readable files plus developer/config files like .env, YAML, shell scripts, Dockerfile, Makefile, and systemd units."; "Startup" = "Startup"; "Launch at login" = "Launch at login"; "Picker" = "Picker"; "Compact view" = "Compact view"; "Menu Bar" = "Menu Bar"; "Recent links" = "Recent links"; +"Recent items" = "Recent items"; "Language" = "Language"; "App language" = "App language"; "Restart BrowserCat to apply language changes." = "Restart BrowserCat to apply language changes."; @@ -27,10 +42,34 @@ "Ignored" = "Ignored"; "Native Apps" = "Native Apps"; "Shown only for links they support" = "Shown only for links they support"; +"Shown for matching links and files" = "Shown for matching links and files"; "Rescan Apps" = "Rescan Apps"; "SET KEY" = "SET KEY"; "Key was already in use and has been reassigned" = "Key was already in use and has been reassigned"; +"File handlers registered" = "File handlers registered"; +"File handlers not registered" = "File handlers not registered"; +"Needed for Finder double-click on .env, YAML, Dockerfile, Makefile, and similar files." = "Needed for Finder double-click on .env, YAML, Dockerfile, Makefile, and similar files."; +"Register" = "Register"; +"No File Associations" = "No File Associations"; +"Add presets or create a custom association for extensions, dotfiles, and Linux config files." = "Add presets or create a custom association for extensions, dotfiles, and Linux config files."; +"Add Presets" = "Add Presets"; +"File Association" = "File Association"; +"Default Behavior" = "Default Behavior"; +"Default App" = "Default App"; +"Default app" = "Default app"; +"None" = "None"; +"Associated Apps" = "Associated Apps"; +"Add App" = "Add App"; +"No apps selected" = "No apps selected"; +"Show picker" = "Show picker"; +"Open default app" = "Open default app"; +"Exact filename" = "Exact filename"; +"File type" = "File type"; +"Matches extensions and dotfile tokens, e.g. env, .env.local, yml, Dockerfile." = "Matches extensions and dotfile tokens, e.g. env, .env.local, yml, Dockerfile."; +"Matches the full file name exactly, e.g. .gitignore or Dockerfile." = "Matches the full file name exactly, e.g. .gitignore or Dockerfile."; +"Matches a Uniform Type Identifier, e.g. public.yaml." = "Matches a Uniform Type Identifier, e.g. public.yaml."; + "No URL Rules" = "No URL Rules"; "Add rules to automatically open URLs\nin a specific browser and profile." = "Add rules to automatically open URLs\nin a specific browser and profile."; "(empty)" = "(empty)"; @@ -52,6 +91,7 @@ "No History" = "No History"; "URLs you open will appear here." = "URLs you open will appear here."; +"Links and files you open will appear here." = "Links and files you open will appear here."; "Clear All" = "Clear All"; "Remove Selected" = "Remove Selected"; "Today" = "Today"; @@ -70,7 +110,9 @@ "License" = "License"; "No recent URLs" = "No recent URLs"; +"No recent items" = "No recent items"; "No links today" = "No links today"; +"No items today" = "No items today"; "Open History..." = "Open History..."; "Settings..." = "Settings..."; "Quit BrowserCat" = "Quit BrowserCat"; @@ -81,17 +123,23 @@ "Open in" = "Open in"; "+ key for private mode" = "+ key for private mode"; "No URL" = "No URL"; +"No file" = "No file"; "Press a key..." = "Press a key..."; "Clear" = "Clear"; "Exact site" = "Exact site"; "Part of site address" = "Part of site address"; "Part of link" = "Part of link"; +"Part of file path" = "Part of file path"; +"File extension" = "File extension"; "Advanced pattern" = "Advanced pattern"; "Matches the exact site address (e.g. github.com or any subdomain)." = "Matches the exact site address (e.g. github.com or any subdomain)."; "Matches when the site address contains the text." = "Matches when the site address contains the text."; "Matches when any part of the link — including the path after / — contains the text." = "Matches when any part of the link — including the path after / — contains the text."; +"Matches when a local file path contains the text." = "Matches when a local file path contains the text."; +"Matches local files by extension, e.g. html or pdf." = "Matches local files by extension, e.g. html or pdf."; "Regular expression for advanced users." = "Regular expression for advanced users."; +"Additional files" = "Additional files"; "Examples" = "Examples"; "OR — any of the options" = "OR — any of the options"; diff --git a/BrowserCat/Resources/uk.lproj/Localizable.strings b/BrowserCat/Resources/uk.lproj/Localizable.strings index 51158b4..b43f6e0 100644 --- a/BrowserCat/Resources/uk.lproj/Localizable.strings +++ b/BrowserCat/Resources/uk.lproj/Localizable.strings @@ -1,5 +1,6 @@ "General" = "Загальні"; "Apps" = "Застосунки"; +"File Associations" = "Асоціації файлів"; "Rules" = "Правила"; "History" = "Історія"; "About" = "Про програму"; @@ -8,12 +9,26 @@ "BrowserCat is the default browser" = "BrowserCat — типовий браузер"; "BrowserCat is not the default browser" = "BrowserCat не є типовим браузером"; "Set as Default" = "Зробити типовим"; +"Set as Default also makes BrowserCat the handler for browser-readable web files." = "«Зробити типовим» також призначає BrowserCat обробником web-файлів, які читають браузери."; +"Set as Default handles http/https links. File handlers are configured below." = "«Зробити типовим» обробляє http/https посилання. Обробники файлів налаштовуються нижче."; +"Set as Default also configures supported web and dev file handlers." = "«Зробити типовим» також налаштовує підтримувані web і dev file handlers."; +"Web Files" = "Web-файли"; +"Web & Dev Files" = "Web і dev-файли"; +"BrowserCat handles web files" = "BrowserCat обробляє web-файли"; +"BrowserCat handles web and dev files" = "BrowserCat обробляє web і dev-файли"; +"BrowserCat does not handle web files" = "BrowserCat не обробляє web-файли"; +"BrowserCat does not handle web and dev files" = "BrowserCat не обробляє web і dev-файли"; +"Set for Web Files" = "Зробити типовим для web-файлів"; +"Set for Files" = "Зробити типовим для файлів"; +"Applies to HTML, XHTML, SVG, web archive, web location, internet shortcut, and MHTML files." = "Застосовується до HTML, XHTML, SVG, web archive, web location, internet shortcut і MHTML файлів."; +"Applies to browser-readable files plus developer/config files like .env, YAML, shell scripts, Dockerfile, Makefile, and systemd units." = "Застосовується до файлів, які читають браузери, а також dev/config файлів: .env, YAML, shell scripts, Dockerfile, Makefile і systemd units."; "Startup" = "Запуск"; "Launch at login" = "Запускати при вході"; "Picker" = "Пікер"; "Compact view" = "Компактний вигляд"; "Menu Bar" = "Рядок меню"; "Recent links" = "Останні посилання"; +"Recent items" = "Останні елементи"; "Language" = "Мова"; "App language" = "Мова застосунку"; "Restart BrowserCat to apply language changes." = "Перезапустіть BrowserCat, щоб застосувати зміну мови."; @@ -27,10 +42,34 @@ "Ignored" = "Ігноровані"; "Native Apps" = "Нативні застосунки"; "Shown only for links they support" = "Показуються лише для підтримуваних посилань"; +"Shown for matching links and files" = "Показуються для відповідних посилань і файлів"; "Rescan Apps" = "Пересканувати застосунки"; "SET KEY" = "ВСТАНОВИТИ КЛАВІШУ"; "Key was already in use and has been reassigned" = "Клавішу вже було призначено, її перевизначено"; +"File handlers registered" = "File handlers зареєстровані"; +"File handlers not registered" = "File handlers не зареєстровані"; +"Needed for Finder double-click on .env, YAML, Dockerfile, Makefile, and similar files." = "Потрібно для double-click у Finder на .env, YAML, Dockerfile, Makefile та схожих файлах."; +"Register" = "Зареєструвати"; +"No File Associations" = "Немає асоціацій файлів"; +"Add presets or create a custom association for extensions, dotfiles, and Linux config files." = "Додай presets або створи власну асоціацію для розширень, dotfiles і Linux config файлів."; +"Add Presets" = "Додати presets"; +"File Association" = "Асоціація файлів"; +"Default Behavior" = "Типова поведінка"; +"Default App" = "Типовий застосунок"; +"Default app" = "Типовий застосунок"; +"None" = "Немає"; +"Associated Apps" = "Асоційовані застосунки"; +"Add App" = "Додати застосунок"; +"No apps selected" = "Застосунки не вибрані"; +"Show picker" = "Показувати пікер"; +"Open default app" = "Відкривати типовий застосунок"; +"Exact filename" = "Точна назва файлу"; +"File type" = "Тип файлу"; +"Matches extensions and dotfile tokens, e.g. env, .env.local, yml, Dockerfile." = "Збігається з розширеннями і dotfile tokens, наприклад env, .env.local, yml, Dockerfile."; +"Matches the full file name exactly, e.g. .gitignore or Dockerfile." = "Збігається з повною назвою файлу, наприклад .gitignore або Dockerfile."; +"Matches a Uniform Type Identifier, e.g. public.yaml." = "Збігається з Uniform Type Identifier, наприклад public.yaml."; + "No URL Rules" = "Немає URL-правил"; "Add rules to automatically open URLs\nin a specific browser and profile." = "Додайте правила, щоб автоматично відкривати URL\nу певному браузері та профілі."; "(empty)" = "(порожньо)"; @@ -52,6 +91,7 @@ "No History" = "Немає історії"; "URLs you open will appear here." = "Тут зʼявляться відкриті вами URL."; +"Links and files you open will appear here." = "Тут зʼявляться відкриті посилання і файли."; "Clear All" = "Очистити все"; "Remove Selected" = "Видалити вибрані"; "Today" = "Сьогодні"; @@ -70,7 +110,9 @@ "License" = "Ліцензія"; "No recent URLs" = "Немає недавніх URL"; +"No recent items" = "Немає недавніх елементів"; "No links today" = "Сьогодні посилань не було"; +"No items today" = "Сьогодні ще нічого не відкривали"; "Open History..." = "Відкрити історію..."; "Settings..." = "Налаштування..."; "Quit BrowserCat" = "Вийти з BrowserCat"; @@ -81,17 +123,23 @@ "Open in" = "Відкрити в"; "+ key for private mode" = "+ клавіша для приватного режиму"; "No URL" = "Немає URL"; +"No file" = "Немає файлу"; "Press a key..." = "Натисніть клавішу..."; "Clear" = "Очистити"; "Exact site" = "Точний сайт"; "Part of site address" = "Частина адреси сайту"; "Part of link" = "Частина посилання"; +"Part of file path" = "Частина шляху файлу"; +"File extension" = "Розширення файлу"; "Advanced pattern" = "Складний шаблон"; "Matches the exact site address (e.g. github.com or any subdomain)." = "Збігається з точною адресою сайту (наприклад, github.com та його піддомени)."; "Matches when the site address contains the text." = "Збігається, коли адреса сайту містить вказаний текст."; "Matches when any part of the link — including the path after / — contains the text." = "Збігається, коли будь-яка частина посилання — включно зі шляхом після / — містить вказаний текст."; +"Matches when a local file path contains the text." = "Збігається, коли шлях локального файлу містить вказаний текст."; +"Matches local files by extension, e.g. html or pdf." = "Збігається з локальними файлами за розширенням, наприклад html або pdf."; "Regular expression for advanced users." = "Регулярний вираз для досвідчених користувачів."; +"Additional files" = "Додаткові файли"; "Examples" = "Приклади"; "OR — any of the options" = "АБО — будь-який з варіантів"; diff --git a/BrowserCat/Services/BrowserLauncher.swift b/BrowserCat/Services/BrowserLauncher.swift index d6b9bf7..adde449 100644 --- a/BrowserCat/Services/BrowserLauncher.swift +++ b/BrowserCat/Services/BrowserLauncher.swift @@ -10,43 +10,53 @@ final class BrowserLauncher { } func open(url: URL, with browser: InstalledBrowser, mode: OpenMode = .normal, profile: BrowserProfile? = nil) { + open(urls: [url], with: browser, mode: mode, profile: profile) + } + + func open(urls: [URL], with browser: InstalledBrowser, mode: OpenMode = .normal, profile: BrowserProfile? = nil) { + guard !urls.isEmpty else { return } + if let profile { - openWithProfile(url: url, browser: browser, profile: profile, mode: mode) + openWithProfile(urls: urls, browser: browser, profile: profile, mode: mode) return } switch mode { case .normal: - openNormal(url: url, browser: browser, inBackground: false) + openNormal(urls: urls, browser: browser, inBackground: false) case .background: - openNormal(url: url, browser: browser, inBackground: true) + openNormal(urls: urls, browser: browser, inBackground: true) case .privateMode: - openPrivate(url: url, browser: browser) + openPrivate(urls: urls, browser: browser) } } private func openNormal(url: URL, browser: InstalledBrowser, inBackground: Bool) { + openNormal(urls: [url], browser: browser, inBackground: inBackground) + } + + private func openNormal(urls: [URL], browser: InstalledBrowser, inBackground: Bool) { let config = NSWorkspace.OpenConfiguration() config.activates = !inBackground NSWorkspace.shared.open( - [url], + urls, withApplicationAt: browser.appURL, configuration: config ) { _, error in if let error { - Log.browser.error("Failed to open \(url) with \(browser.displayName): \(error.localizedDescription)") + Log.browser.error("Failed to open \(urls.count) URL(s) with \(browser.displayName): \(error.localizedDescription)") } else { let mode = inBackground ? "background" : "foreground" - Log.browser.info("Opened \(url) with \(browser.displayName) in \(mode)") + Log.browser.info("Opened \(urls.count) URL(s) with \(browser.displayName) in \(mode)") } } } - private func openPrivate(url: URL, browser: InstalledBrowser) { + private func openPrivate(urls: [URL], browser: InstalledBrowser) { guard let args = browser.privateModeArgs else { // Fallback to normal open if no private mode support - openNormal(url: url, browser: browser, inBackground: false) + openNormal(urls: urls, browser: browser, inBackground: false) return } @@ -57,20 +67,20 @@ final class BrowserLauncher { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = args + [url.absoluteString] + process.arguments = args + urls.map(\.absoluteString) do { try process.run() - Log.browser.info("Opened \(url) with \(browser.displayName) in private mode") + Log.browser.info("Opened \(urls.count) URL(s) with \(browser.displayName) in private mode") activateRunningApp(bundleID: browser.id) } catch { Log.browser.error("Failed to open private mode for \(browser.displayName): \(error.localizedDescription)") // Fallback to normal open - openNormal(url: url, browser: browser, inBackground: false) + openNormal(urls: urls, browser: browser, inBackground: false) } } - private func openWithProfile(url: URL, browser: InstalledBrowser, profile: BrowserProfile, mode: OpenMode) { + private func openWithProfile(urls: [URL], browser: InstalledBrowser, profile: BrowserProfile, mode: OpenMode) { let executablePath = browser.appURL .appendingPathComponent("Contents/MacOS") .appendingPathComponent(executableName(for: browser)) @@ -93,7 +103,7 @@ final class BrowserLauncher { args.append(contentsOf: privateArgs) } - args.append(url.absoluteString) + args.append(contentsOf: urls.map(\.absoluteString)) let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) @@ -101,11 +111,11 @@ final class BrowserLauncher { do { try process.run() - Log.browser.info("Opened \(url) with \(browser.displayName) profile '\(profile.displayName)'") + Log.browser.info("Opened \(urls.count) URL(s) with \(browser.displayName) profile '\(profile.displayName)'") activateRunningApp(bundleID: browser.id) } catch { Log.browser.error("Failed to open with profile for \(browser.displayName): \(error.localizedDescription)") - openNormal(url: url, browser: browser, inBackground: false) + openNormal(urls: urls, browser: browser, inBackground: false) } } @@ -135,6 +145,10 @@ final class BrowserLauncher { configuration: config ) { _, error in if let error { + guard !url.isFileURL else { + Log.apps.error("Failed to open file with \(app.displayName): \(error.localizedDescription)") + return + } Log.apps.warning("Direct open failed for \(app.displayName): \(error.localizedDescription), trying URL scheme") // Step 3: Fallback to generic URL scheme transformation Task { @MainActor in diff --git a/BrowserCat/Services/FileShortcutResolver.swift b/BrowserCat/Services/FileShortcutResolver.swift new file mode 100644 index 0000000..09dea2d --- /dev/null +++ b/BrowserCat/Services/FileShortcutResolver.swift @@ -0,0 +1,59 @@ +import Foundation +import os + +enum FileShortcutResolver { + static func resolve(_ url: URL) -> URL { + guard url.isFileURL else { return url } + + switch url.pathExtension.lowercased() { + case "webloc", "inetloc": + return resolvePropertyListShortcut(url) ?? url + case "url": + return resolveInternetShortcut(url) ?? url + default: + return url + } + } + + private static func resolvePropertyListShortcut(_ url: URL) -> URL? { + do { + let data = try Data(contentsOf: url) + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + guard let dictionary = plist as? [String: Any], + let urlString = dictionary["URL"] as? String, + let resolvedURL = URL(string: urlString) + else { + return nil + } + return resolvedURL + } catch { + Log.app.debug("Failed to resolve shortcut \(url.path): \(error.localizedDescription)") + return nil + } + } + + private static func resolveInternetShortcut(_ url: URL) -> URL? { + do { + let data = try Data(contentsOf: url) + guard let contents = String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .isoLatin1) + ?? String(data: data, encoding: .windowsCP1252) + else { + return nil + } + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("url=") else { continue } + + let value = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) + guard let resolvedURL = URL(string: value) else { return nil } + return resolvedURL + } + return nil + } catch { + Log.app.debug("Failed to resolve internet shortcut \(url.path): \(error.localizedDescription)") + return nil + } + } +} diff --git a/BrowserCat/Services/SuggestionEngine.swift b/BrowserCat/Services/SuggestionEngine.swift index 34953e1..32ce888 100644 --- a/BrowserCat/Services/SuggestionEngine.swift +++ b/BrowserCat/Services/SuggestionEngine.swift @@ -30,7 +30,8 @@ enum SuggestionEngine { } let usable: [Usable] = history.compactMap { entry in - guard entry.targetType == .browser, + guard entry.itemKind == .link, + entry.targetType == .browser, let browserID = entry.browserID, let url = URL(string: entry.url), url.host != nil else { return nil } diff --git a/BrowserCatTests/FileShortcutResolverTests.swift b/BrowserCatTests/FileShortcutResolverTests.swift new file mode 100644 index 0000000..8a5c6ec --- /dev/null +++ b/BrowserCatTests/FileShortcutResolverTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import BrowserCat + +final class FileShortcutResolverTests: XCTestCase { + func testWeblocResolvesTargetURL() throws { + let shortcutURL = try makeTempFile(name: "link.webloc") + let data = try PropertyListSerialization.data( + fromPropertyList: ["URL": "https://example.com/path"], + format: .xml, + options: 0 + ) + try data.write(to: shortcutURL) + + XCTAssertEqual(FileShortcutResolver.resolve(shortcutURL).absoluteString, "https://example.com/path") + } + + func testInternetShortcutResolvesTargetURL() throws { + let shortcutURL = try makeTempFile(name: "link.url") + try """ + [InternetShortcut] + URL=https://github.com/rmarinsky/BrowserCat + """.data(using: .utf8)!.write(to: shortcutURL) + + XCTAssertEqual(FileShortcutResolver.resolve(shortcutURL).absoluteString, "https://github.com/rmarinsky/BrowserCat") + } + + func testInvalidShortcutFallsBackToOriginalFileURL() throws { + let shortcutURL = try makeTempFile(name: "broken.url") + try "[InternetShortcut]\n".data(using: .utf8)!.write(to: shortcutURL) + + XCTAssertEqual(FileShortcutResolver.resolve(shortcutURL), shortcutURL) + } + + func testPlainFileReturnsOriginalFileURL() throws { + let fileURL = try makeTempFile(name: "index.html") + try "".data(using: .utf8)!.write(to: fileURL) + + XCTAssertEqual(FileShortcutResolver.resolve(fileURL), fileURL) + } + + private func makeTempFile(name: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("BrowserCatTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: directory) + } + return directory.appendingPathComponent(name) + } +} diff --git a/BrowserCatTests/HistoryEntryCodableTests.swift b/BrowserCatTests/HistoryEntryCodableTests.swift index 4601d69..73d22b7 100644 --- a/BrowserCatTests/HistoryEntryCodableTests.swift +++ b/BrowserCatTests/HistoryEntryCodableTests.swift @@ -32,6 +32,7 @@ final class HistoryEntryCodableTests: XCTestCase { XCTAssertEqual(decoded.browserID, "com.google.Chrome") XCTAssertEqual(decoded.profileDirectoryName, "Default") XCTAssertEqual(decoded.targetType, .browser) + XCTAssertEqual(decoded.itemKind, .link) } func testRoundTripWithNilIDs() throws { @@ -47,6 +48,33 @@ final class HistoryEntryCodableTests: XCTestCase { XCTAssertNil(decoded.browserID) XCTAssertNil(decoded.profileDirectoryName) XCTAssertNil(decoded.targetType) + XCTAssertEqual(decoded.itemKind, .link) + } + + func testRoundTripWithFileMetadata() throws { + let original = HistoryEntry( + url: "file:///Users/roman/project/.env.local", + domain: ".env.local", + title: nil, + appName: "VS Code", + profileName: nil, + openedAt: Date(timeIntervalSince1970: 1_700_000_000), + browserID: "com.microsoft.VSCode", + profileDirectoryName: nil, + targetType: .app, + itemKind: .file, + fileName: ".env.local", + fileExtension: "local", + fileFormat: ".env.local", + contentTypeIdentifier: "ua.com.rmarinsky.browsercat.env-config" + ) + let data = try encoder().encode(original) + let decoded = try decoder().decode(HistoryEntry.self, from: data) + XCTAssertEqual(decoded.itemKind, .file) + XCTAssertEqual(decoded.fileName, ".env.local") + XCTAssertEqual(decoded.fileExtension, "local") + XCTAssertEqual(decoded.fileFormat, ".env.local") + XCTAssertEqual(decoded.contentTypeIdentifier, "ua.com.rmarinsky.browsercat.env-config") } func testDecodesLegacyFormatWithoutIDs() throws { @@ -69,5 +97,24 @@ final class HistoryEntryCodableTests: XCTestCase { XCTAssertNil(decoded.browserID, "Legacy entries must decode with browserID = nil") XCTAssertNil(decoded.profileDirectoryName) XCTAssertNil(decoded.targetType) + XCTAssertEqual(decoded.itemKind, .link) + } + + func testDecodesLegacyFileURLAsFileKind() throws { + let legacyJSON = """ + { + "id": "550E8400-E29B-41D4-A716-446655440000", + "url": "file:///Users/roman/project/Dockerfile", + "domain": "Dockerfile", + "title": null, + "appName": "Sublime Text", + "profileName": null, + "openedAt": "2024-01-01T00:00:00Z" + } + """.data(using: .utf8)! + + let decoded = try decoder().decode(HistoryEntry.self, from: legacyJSON) + XCTAssertEqual(decoded.itemKind, .file) + XCTAssertNil(decoded.fileFormat) } } diff --git a/BrowserCatTests/URLRuleMatcherTests.swift b/BrowserCatTests/URLRuleMatcherTests.swift index 7b6524c..f7c3cd1 100644 --- a/BrowserCatTests/URLRuleMatcherTests.swift +++ b/BrowserCatTests/URLRuleMatcherTests.swift @@ -74,10 +74,9 @@ final class URLRuleMatcherTests: XCTestCase { let m = URLRuleMatcher() var firstRule = make(pattern: "example.com", matchType: .host) firstRule.sortOrder = 0 - var secondRule = URLRule(pattern: "example.com", matchType: .host, browserID: "second", isEnabled: true, sortOrder: 1) + let secondRule = URLRule(pattern: "example.com", matchType: .host, browserID: "second", isEnabled: true, sortOrder: 1) // Whichever has the lower sortOrder wins let match = m.findMatchingRule(for: URL(string: "https://example.com/")!, rules: [secondRule, firstRule]) XCTAssertEqual(match?.browserID, "test") - _ = secondRule } } diff --git a/BrowserCatTests/URLRulesManagerTests.swift b/BrowserCatTests/URLRulesManagerTests.swift index 1ff4435..726ac2a 100644 --- a/BrowserCatTests/URLRulesManagerTests.swift +++ b/BrowserCatTests/URLRulesManagerTests.swift @@ -31,7 +31,7 @@ final class URLRulesManagerTests: XCTestCase { apps: [], rules: [rule] ) - guard case let .browser(b, profile)? = match else { + guard case let .browser(b, profile, _)? = match else { return XCTFail("Expected .browser match") } XCTAssertEqual(b.id, "com.google.Chrome") @@ -55,7 +55,7 @@ final class URLRulesManagerTests: XCTestCase { apps: [], rules: [rule] ) - guard case let .browser(_, profile)? = match else { + guard case let .browser(_, profile, _)? = match else { return XCTFail("Expected .browser match") } XCTAssertEqual(profile?.directoryName, "Profile 1")