diff --git a/Browserino.xcodeproj/project.pbxproj b/Browserino.xcodeproj/project.pbxproj index e7d4397..6e36a9c 100644 --- a/Browserino.xcodeproj/project.pbxproj +++ b/Browserino.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + AA00010B2F0C700100FC6D37 /* ChromeProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010A2F0C700100FC6D37 /* ChromeProfile.swift */; }; + AA00010D2F0C700100FC6D37 /* ProfilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010C2F0C700100FC6D37 /* ProfilesTab.swift */; }; 664FDF222CE4AA6B009766AA /* BrowserUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 664FDF212CE4AA6B009766AA /* BrowserUtil.swift */; }; 669247902CEB48A5006A8C8B /* LocationsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6692478F2CEB48A5006A8C8B /* LocationsTab.swift */; }; 9830F0A62C119A3000D69C88 /* BrowserinoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9830F0A52C119A3000D69C88 /* BrowserinoApp.swift */; }; @@ -40,6 +42,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AA00010A2F0C700100FC6D37 /* ChromeProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeProfile.swift; sourceTree = ""; }; + AA00010C2F0C700100FC6D37 /* ProfilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesTab.swift; sourceTree = ""; }; 664FDF212CE4AA6B009766AA /* BrowserUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserUtil.swift; sourceTree = ""; }; 6692478F2CEB48A5006A8C8B /* LocationsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsTab.swift; sourceTree = ""; }; 9830F0A22C119A3000D69C88 /* Browserino.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Browserino.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -165,6 +169,7 @@ 986AC6262CECDD2500884C40 /* Models */ = { isa = PBXGroup; children = ( + AA00010A2F0C700100FC6D37 /* ChromeProfile.swift */, 986DC8082CFE42EA00447DC7 /* Rule.swift */, 664FDF212CE4AA6B009766AA /* BrowserUtil.swift */, ); @@ -189,6 +194,7 @@ 9830F0BA2C11DE8F00D69C88 /* PreferencesView.swift */, 989B17FC2C17400A0039441B /* EditAppForm.swift */, 989B17F22C16FC550039441B /* BrowsersTab.swift */, + AA00010C2F0C700100FC6D37 /* ProfilesTab.swift */, 989B17FA2C1710D70039441B /* AppsTab.swift */, 989B17F62C16FD260039441B /* GeneralTab.swift */, 989B17F82C170C160039441B /* AboutTab.swift */, @@ -268,6 +274,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA00010B2F0C700100FC6D37 /* ChromeProfile.swift in Sources */, + AA00010D2F0C700100FC6D37 /* ProfilesTab.swift in Sources */, 9830F0BB2C11DE8F00D69C88 /* PreferencesView.swift in Sources */, 9830F0A82C119A3000D69C88 /* PromptView.swift in Sources */, 9830F0A62C119A3000D69C88 /* BrowserinoApp.swift in Sources */, diff --git a/Browserino/BrowserinoApp.swift b/Browserino/BrowserinoApp.swift index 3b7fc9c..b08e277 100644 --- a/Browserino/BrowserinoApp.swift +++ b/Browserino/BrowserinoApp.swift @@ -146,7 +146,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { BrowserUtil.openURL( processedUrls, app: rule.app, - isIncognito: false + isIncognito: false, + profileDirectory: rule.profileDirectory ) return } diff --git a/Browserino/Extensions/View+ScrollEdgeDisabled.swift b/Browserino/Extensions/View+ScrollEdgeDisabled.swift index 26ce109..0a914c5 100644 --- a/Browserino/Extensions/View+ScrollEdgeDisabled.swift +++ b/Browserino/Extensions/View+ScrollEdgeDisabled.swift @@ -10,12 +10,6 @@ import SwiftUI extension View { @ViewBuilder func scrollEdgeEffectDisabledCompat() -> some View { - if #available(macOS 26.0, *) { - self - .scrollEdgeEffectStyle(.soft, for: .all) - .scrollEdgeEffectDisabled(true, for: .all) - } else { - self - } + self } } diff --git a/Browserino/Models/BrowserUtil.swift b/Browserino/Models/BrowserUtil.swift index 03ddfb6..a9492bd 100644 --- a/Browserino/Models/BrowserUtil.swift +++ b/Browserino/Models/BrowserUtil.swift @@ -64,20 +64,31 @@ class BrowserUtil { return filteredUrlsForApplications } - static func openURL(_ urls: [URL], app: URL, isIncognito: Bool) { + static func openURL(_ urls: [URL], app: URL, isIncognito: Bool, profileDirectory: String? = nil) { guard let bundle = Bundle(url: app) else { return } - + let configuration = NSWorkspace.OpenConfiguration() - + let isChrome = bundle.bundleIdentifier == ChromeProfileUtil.chromeBundleID + + var args: [String] = [] + + if let profileDirectory, isChrome { + args.append("--profile-directory=\(profileDirectory)") + } + if isIncognito, let privateArg = privateArgs[bundle.bundleIdentifier!] { + args.append(privateArg) + } + + if !args.isEmpty { configuration.createsNewApplicationInstance = true - configuration.arguments = [privateArg] + urls.map(\.absoluteString) + configuration.arguments = args + urls.map(\.absoluteString) } - + NSWorkspace.shared.open( - isIncognito ? [] : urls, + args.isEmpty ? urls : [], withApplicationAt: app, configuration: configuration ) diff --git a/Browserino/Models/ChromeProfile.swift b/Browserino/Models/ChromeProfile.swift new file mode 100644 index 0000000..031070e --- /dev/null +++ b/Browserino/Models/ChromeProfile.swift @@ -0,0 +1,48 @@ +// +// ChromeProfile.swift +// Browserino +// + +import AppKit +import Foundation + +struct ChromeProfile: Codable, Hashable { + var directoryName: String + var displayName: String + var isHidden: Bool = false +} + +class ChromeProfileUtil { + static let chromeBundleID = "com.google.Chrome" + + static func chromeURL() -> URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: chromeBundleID) + } + + static func detectProfiles() -> [ChromeProfile] { + let localStatePath = NSString("~/Library/Application Support/Google/Chrome/Local State") + .expandingTildeInPath + + guard let data = FileManager.default.contents(atPath: localStatePath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let profileInfo = json["profile"] as? [String: Any], + let infoCache = profileInfo["info_cache"] as? [String: Any] + else { + return [] + } + + return infoCache.compactMap { (dirName, value) in + guard let profileDict = value as? [String: Any], + let name = profileDict["name"] as? String + else { + return nil + } + + return ChromeProfile( + directoryName: dirName, + displayName: name + ) + } + .sorted { $0.directoryName < $1.directoryName } + } +} diff --git a/Browserino/Models/Rule.swift b/Browserino/Models/Rule.swift index c3b8d84..73c2fe7 100644 --- a/Browserino/Models/Rule.swift +++ b/Browserino/Models/Rule.swift @@ -10,4 +10,5 @@ import Foundation struct Rule: Hashable, Codable { var regex: String var app: URL + var profileDirectory: String? = nil } diff --git a/Browserino/Views/Preferences/EditRuleForm.swift b/Browserino/Views/Preferences/EditRuleForm.swift index 1633661..9431c8b 100644 --- a/Browserino/Views/Preferences/EditRuleForm.swift +++ b/Browserino/Views/Preferences/EditRuleForm.swift @@ -57,16 +57,19 @@ struct NewRuleForm: View { struct RuleForm: View { var rule: Rule? - + var onCancel: () -> Void var onSave: (Rule) -> Void var onDelete: () -> Void + @AppStorage("chromeProfiles") private var chromeProfiles: [ChromeProfile] = [] + @State private var openWithPresented = false - + @State private var regex: String = "" @State private var testUrls: String = "https://github.com/AlexStrNik/Browserino\nhttps://x.com/alexstrnik" @State private var url: URL? + @State private var profileDirectory: String? = nil private var compiledRegex: Regex? { return try? Regex(regex).ignoresCase() @@ -137,7 +140,21 @@ struct RuleForm: View { .foregroundStyle(.secondary) } } - + + if let bundle = url.flatMap({ Bundle(url: $0) }), + bundle.bundleIdentifier == ChromeProfileUtil.chromeBundleID, + !chromeProfiles.isEmpty { + LabeledContent("Profile:") { + Picker("", selection: $profileDirectory) { + Text("None").tag(nil as String?) + ForEach(chromeProfiles, id: \.directoryName) { profile in + Text(profile.displayName).tag(profile.directoryName as String?) + } + } + .frame(width: 200) + } + } + Spacer() .frame(height: 32) @@ -158,11 +175,12 @@ struct RuleForm: View { guard let url else { return } - + onSave( Rule( regex: regex, - app: url + app: url, + profileDirectory: profileDirectory ) ) }) { @@ -177,6 +195,7 @@ struct RuleForm: View { .onAppear { regex = rule?.regex ?? "" url = rule?.app + profileDirectory = rule?.profileDirectory } } } diff --git a/Browserino/Views/Preferences/PreferencesView.swift b/Browserino/Views/Preferences/PreferencesView.swift index dcdaa0b..0367fd9 100644 --- a/Browserino/Views/Preferences/PreferencesView.swift +++ b/Browserino/Views/Preferences/PreferencesView.swift @@ -31,30 +31,36 @@ struct PreferencesView: View { Label("Browsers", systemImage: "gear") } .tag(1) - + + ProfilesTab() + .tabItem { + Label("Profiles", systemImage: "gear") + } + .tag(2) + AppsTab() .tabItem { Label("Apps", systemImage: "gear") } - .tag(2) - + .tag(3) + RulesTab() .tabItem { Label("Rules", systemImage: "gear") } - .tag(3) - + .tag(4) + BrowserSearchLocationsTab() .tabItem { Label("Locations", systemImage: "gear") } - .tag(4) + .tag(5) AboutTab() .tabItem { Label("About", systemImage: "gear") } - .tag(5) + .tag(6) } .frame(minWidth: 700, minHeight: 500) } diff --git a/Browserino/Views/Preferences/ProfilesTab.swift b/Browserino/Views/Preferences/ProfilesTab.swift new file mode 100644 index 0000000..1d0e637 --- /dev/null +++ b/Browserino/Views/Preferences/ProfilesTab.swift @@ -0,0 +1,123 @@ +// +// ProfilesTab.swift +// Browserino +// + +import SwiftUI + +struct ProfilesTab: View { + @AppStorage("chromeProfiles") private var chromeProfiles: [ChromeProfile] = [] + @AppStorage("chromeProfilesEnabled") private var chromeProfilesEnabled: Bool = true + + @State private var hasDetected = false + + private var chromeInstalled: Bool { + ChromeProfileUtil.chromeURL() != nil + } + + private func detectProfiles() { + let detected = ChromeProfileUtil.detectProfiles() + + var merged: [ChromeProfile] = [] + for profile in detected { + if let existing = chromeProfiles.first(where: { $0.directoryName == profile.directoryName }) { + merged.append(ChromeProfile( + directoryName: existing.directoryName, + displayName: existing.displayName, + isHidden: existing.isHidden + )) + } else { + merged.append(profile) + } + } + + chromeProfiles = merged + hasDetected = true + } + + private func displayName(at index: Int) -> Binding { + Binding( + get: { chromeProfiles[index].displayName }, + set: { chromeProfiles[index].displayName = $0 } + ) + } + + var body: some View { + VStack(alignment: .leading) { + if !chromeInstalled { + Spacer() + Text("Google Chrome is not installed.") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .center) + Spacer() + } else { + HStack(spacing: 16) { + Toggle(isOn: $chromeProfilesEnabled) { + Text("Show Chrome profiles as separate items in the picker") + .font(.callout) + } + + Spacer() + + Button(action: detectProfiles) { + Text("Detect Profiles") + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + + List { + ForEach(Array(chromeProfiles.enumerated()), id: \.element.directoryName) { index, profile in + HStack { + TextField("Display name", text: displayName(at: index)) + .font(.system(size: 14)) + .frame(maxWidth: 200) + + Spacer() + .frame(width: 16) + + Text(profile.directoryName) + .font(.system(size: 12).monospaced()) + .foregroundStyle(.secondary) + + Spacer() + + ShortcutButton( + browserId: "\(ChromeProfileUtil.chromeBundleID)::\(profile.directoryName)" + ) + + Spacer() + .frame(width: 8) + + Button(action: { + chromeProfiles[index].isHidden.toggle() + }) { + Image( + systemName: profile.isHidden + ? "eye.slash.fill" : "eye.fill" + ) + } + .buttonStyle(.plain) + } + .padding(10) + } + } + + Text("Detect Chrome profiles and show them as separate picker items. Assign shortcuts and hide profiles you don't use.") + .font(.subheadline) + .foregroundStyle(.primary.opacity(0.5)) + .frame(maxWidth: .infinity) + } + } + .padding(.bottom, 20) + .onAppear { + if !hasDetected && chromeInstalled && chromeProfiles.isEmpty { + detectProfiles() + } + } + } +} + +#Preview { + ProfilesTab() +} diff --git a/Browserino/Views/Prompt/PromptItem.swift b/Browserino/Views/Prompt/PromptItem.swift index 181b58b..1402750 100644 --- a/Browserino/Views/Prompt/PromptItem.swift +++ b/Browserino/Views/Prompt/PromptItem.swift @@ -12,12 +12,13 @@ struct PromptItem: View { var urls: [URL] var bundle: Bundle var shortcut: String? + var displayName: String? = nil var action: () -> Void - + var body: some View { Button(action: action) { HStack { - Text(bundle.infoDictionary!["CFBundleName"] as! String) + Text(displayName ?? bundle.infoDictionary!["CFBundleName"] as! String) .font( .system(size: 12, weight: .bold) ) diff --git a/Browserino/Views/Prompt/PromptView.swift b/Browserino/Views/Prompt/PromptView.swift index a9b7fae..3e38d9e 100644 --- a/Browserino/Views/Prompt/PromptView.swift +++ b/Browserino/Views/Prompt/PromptView.swift @@ -8,11 +8,21 @@ import AppKit import SwiftUI +struct PickerBrowserItem: Identifiable { + let id: String + let appURL: URL + let displayName: String? + let profileDirectory: String? + let shortcutKey: String? +} + struct PromptView: View { @AppStorage("browsers") private var browsers: [URL] = [] @AppStorage("hiddenBrowsers") private var hiddenBrowsers: [URL] = [] @AppStorage("apps") private var apps: [App] = [] @AppStorage("shortcuts") private var shortcuts: [String: String] = [:] + @AppStorage("chromeProfiles") private var chromeProfiles: [ChromeProfile] = [] + @AppStorage("chromeProfilesEnabled") private var chromeProfilesEnabled: Bool = true @AppStorage("copy_closeAfterCopy") private var closeAfterCopy: Bool = false @AppStorage("copy_alternativeShortcut") private var alternativeShortcut: Bool = false @@ -39,6 +49,47 @@ struct PromptView: View { browsers.filter { !hiddenBrowsers.contains($0) } } + var pickerBrowserItems: [PickerBrowserItem] { + var items: [PickerBrowserItem] = [] + + for browser in visibleBrowsers { + guard let bundle = Bundle(url: browser) else { continue } + let bundleID = bundle.bundleIdentifier ?? "" + + if chromeProfilesEnabled && bundleID == ChromeProfileUtil.chromeBundleID { + let visibleProfiles = chromeProfiles.filter { !$0.isHidden } + if !visibleProfiles.isEmpty { + for profile in visibleProfiles { + let profileID = "\(bundleID)::\(profile.directoryName)" + let chromeName = bundle.infoDictionary?["CFBundleName"] as? String ?? "Google Chrome" + items.append(PickerBrowserItem( + id: profileID, + appURL: browser, + displayName: "\(chromeName) - \(profile.displayName)", + profileDirectory: profile.directoryName, + shortcutKey: shortcuts[profileID] + )) + } + continue + } + } + + items.append(PickerBrowserItem( + id: bundleID, + appURL: browser, + displayName: nil, + profileDirectory: nil, + shortcutKey: shortcuts[bundleID] + )) + } + + return items + } + + var totalItemCount: Int { + pickerBrowserItems.count + appsForUrls.count + } + func openUrlsInApp(app: App) { let urls = if app.schemeOverride.isEmpty { @@ -62,6 +113,38 @@ struct PromptView: View { ) } + func openBrowserItem(_ item: PickerBrowserItem, isIncognito: Bool) { + BrowserUtil.openURL( + urls, + app: item.appURL, + isIncognito: isIncognito, + profileDirectory: item.profileDirectory + ) + } + + func handleEnter(isIncognito: Bool) { + let browserItems = pickerBrowserItems + if appsAtTop { + if selected < appsForUrls.count { + openUrlsInApp(app: appsForUrls[selected]) + } else { + let idx = selected - appsForUrls.count + if idx < browserItems.count { + openBrowserItem(browserItems[idx], isIncognito: isIncognito) + } + } + } else { + if selected < browserItems.count { + openBrowserItem(browserItems[selected], isIncognito: isIncognito) + } else { + let idx = selected - browserItems.count + if idx < appsForUrls.count { + openUrlsInApp(app: appsForUrls[idx]) + } + } + } + } + var body: some View { VStack { ScrollViewReader { scrollViewProxy in @@ -86,24 +169,21 @@ struct PromptView: View { ) } } - + Divider() } - - ForEach(Array(visibleBrowsers.enumerated()), id: \.offset) { - index, browser in - if let bundle = Bundle(url: browser) { + + ForEach(Array(pickerBrowserItems.enumerated()), id: \.element.id) { + index, item in + if let bundle = Bundle(url: item.appURL) { PromptItem( - browser: browser, + browser: item.appURL, urls: urls, bundle: bundle, - shortcut: shortcuts[bundle.bundleIdentifier!] + shortcut: item.shortcutKey, + displayName: item.displayName ) { - BrowserUtil.openURL( - urls, - app: browser, - isIncognito: NSEvent.modifierFlags.contains(.shift) - ) + openBrowserItem(item, isIncognito: NSEvent.modifierFlags.contains(.shift)) } .id(index + (appsAtTop ? appsForUrls.count : 0)) .buttonStyle( @@ -127,10 +207,10 @@ struct PromptView: View { ) { openUrlsInApp(app: app) } - .id(visibleBrowsers.count + index) + .id(pickerBrowserItems.count + index) .buttonStyle( SelectButtonStyle( - selected: selected == visibleBrowsers.count + index + selected: selected == pickerBrowserItems.count + index ) ) } @@ -146,59 +226,19 @@ struct PromptView: View { selected = max(0, selected - 1) scrollViewProxy.scrollTo(selected, anchor: .center) } else if command == .down { - selected = min(visibleBrowsers.count + appsForUrls.count - 1, selected + 1) + selected = min(totalItemCount - 1, selected + 1) scrollViewProxy.scrollTo(selected, anchor: .center) } } .background { Button(action: { - if appsAtTop { - if selected < appsForUrls.count { - openUrlsInApp(app: appsForUrls[selected]) - } else { - BrowserUtil.openURL( - urls, - app: visibleBrowsers[selected - appsForUrls.count], - isIncognito: false - ) - } - } else { - if selected < visibleBrowsers.count { - BrowserUtil.openURL( - urls, - app: visibleBrowsers[selected], - isIncognito: false - ) - } else { - openUrlsInApp(app: appsForUrls[selected - visibleBrowsers.count]) - } - } + handleEnter(isIncognito: false) }) {} .opacity(0) .keyboardShortcut(.defaultAction) Button(action: { - if appsAtTop { - if selected < appsForUrls.count { - openUrlsInApp(app: appsForUrls[selected]) - } else { - BrowserUtil.openURL( - urls, - app: visibleBrowsers[selected - appsForUrls.count], - isIncognito: true - ) - } - } else { - if selected < visibleBrowsers.count { - BrowserUtil.openURL( - urls, - app: visibleBrowsers[selected], - isIncognito: true - ) - } else { - openUrlsInApp(app: appsForUrls[selected - visibleBrowsers.count]) - } - } + handleEnter(isIncognito: true) }) {} .opacity(0) .keyboardShortcut(.return, modifiers: [.shift])