From a5236f0c84d70beeb492cc19afd1e6dfbb6acd28 Mon Sep 17 00:00:00 2001 From: Moti Levy <3513686+motilevy@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:02:30 -0500 Subject: [PATCH] feat: Chrome profile support in picker and rules Detect Chrome profiles from Local State and show them as separate items in the browser picker. Each profile can have its own keyboard shortcut, be hidden individually, and be assigned to URL rules. - Add ChromeProfile model and detection from Chrome Local State JSON - Expand Chrome into per-profile picker items (e.g. "Chrome - Moti") - Launch with --profile-directory flag, combinable with incognito - Add Profiles preferences tab (detect, rename, hide, shortcuts) - Add optional profile picker to rule editor when app is Chrome - Fix scrollEdgeEffectDisabled compatibility with current Xcode SDK --- Browserino.xcodeproj/project.pbxproj | 8 + Browserino/BrowserinoApp.swift | 3 +- .../Extensions/View+ScrollEdgeDisabled.swift | 8 +- Browserino/Models/BrowserUtil.swift | 23 ++- Browserino/Models/ChromeProfile.swift | 48 ++++++ Browserino/Models/Rule.swift | 1 + .../Views/Preferences/EditRuleForm.swift | 29 +++- .../Views/Preferences/PreferencesView.swift | 20 ++- .../Views/Preferences/ProfilesTab.swift | 123 ++++++++++++++ Browserino/Views/Prompt/PromptItem.swift | 5 +- Browserino/Views/Prompt/PromptView.swift | 154 +++++++++++------- 11 files changed, 337 insertions(+), 85 deletions(-) create mode 100644 Browserino/Models/ChromeProfile.swift create mode 100644 Browserino/Views/Preferences/ProfilesTab.swift 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])