{
  "format" : 1,
  "manifest" : {
    "accent" : "cyan",
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "osascript",
            "cat"
          ]
        }
      },
      {
        "apps" : {

        }
      },
      {
        "files" : {
          "paths" : [
            "~/Library/Application Support/Google/Chrome/Default",
            "~/Library/Application Support/BraveSoftware/Brave-Browser/Default",
            "~/Library/Application Support/Microsoft Edge/Default",
            "~/Library/Application Support/Vivaldi/Default"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/BrowserTabs",
    "id" : "io.navbytes.stackbar.plugins.browser-tabs",
    "minimumStackbar" : "0.35.0",
    "name" : "Browser Tabs",
    "summary" : "Switch to any open tab in Safari, Chrome, Arc, Brave or Edge, and search your bookmarks.",
    "surfaces" : [
      "panel"
    ],
    "symbol" : "rectangle.stack.fill",
    "triggers" : [
      {
        "launch" : {

        }
      }
    ],
    "version" : "1.2.1"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "lJEcQ7JSMPq31HBLUXvYZs6Y64RX8O0L+xlHgG7J2O1yaMADhIcynRgt2rrJO+44vTJcqCWwalhIK9/S39NJCg==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Your open browser tabs and your bookmarks in the palette, as Raycast's\n/// browser extensions have them: type part of a page's title or address and\n/// Return switches to that tab, or opens the bookmark.\n///\n/// Tabs come from Safari, Chrome, Arc, Brave and Edge while they run, read\n/// with AppleScript (macOS asks once to let Stackbar control each browser).\n/// Bookmarks come from the bookmark file Chrome, Brave, Edge and Vivaldi\n/// keep; Safari's are behind Full Disk Access, so they aren't read.\n@main\nfinal class BrowserTabs: StackbarPlugin {\n    struct Browser: Sendable {\n        var name: String\n        var bundleID: String\n        /// AppleScript dialect for tabs: Safari's or Chromium's.\n        var safari: Bool\n        /// The profile folder holding `Bookmarks`, for Chromium browsers.\n        var profile: String?\n    }\n\n    static let browsers: [Browser] = [\n        Browser(name: \"Safari\", bundleID: \"com.apple.Safari\", safari: true),\n        Browser(name: \"Google Chrome\", bundleID: \"com.google.Chrome\", safari: false, profile: \"~/Library/Application Support/Google/Chrome/Default\"),\n        Browser(name: \"Arc\", bundleID: \"company.thebrowser.Browser\", safari: false),\n        Browser(name: \"Brave Browser\", bundleID: \"com.brave.Browser\", safari: false, profile: \"~/Library/Application Support/BraveSoftware/Brave-Browser/Default\"),\n        Browser(name: \"Microsoft Edge\", bundleID: \"com.microsoft.edgemac\", safari: false, profile: \"~/Library/Application Support/Microsoft Edge/Default\"),\n        Browser(name: \"Vivaldi\", bundleID: \"com.vivaldi.Vivaldi\", safari: false, profile: \"~/Library/Application Support/Vivaldi/Default\"),\n    ]\n\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.browser-tabs\",\n        name: \"Browser Tabs\",\n        version: \"1.2.1\",\n        summary: \"Switch to any open tab in Safari, Chrome, Arc, Brave or Edge, and search your bookmarks.\",\n        surfaces: [.panel],\n        triggers: [.launch],\n        capabilities: [\n            .run(commands: [\"osascript\", \"cat\"]),\n            // Which browsers are running, so a browser that isn't is never\n            // started or asked for.\n            .apps,\n            .files(paths: browsers.compactMap(\\.profile)),\n        ],\n        minimumStackbar: \"0.35.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/BrowserTabs\",\n        accent: .cyan,\n        symbol: \"rectangle.stack.fill\"\n    )\n\n    static let previews: [PreviewScene<BrowserTabs>] = [\n        PreviewScene(\"hero\", \"Every tab, a few letters away\") { plugin in\n            plugin.tabs = [\n                Tab(browser: \"Safari\", window: 1, index: 2, title: \"Pull requests · navbytes/stackbar\", url: \"https://github.com/navbytes/stackbar/pulls\"),\n                Tab(browser: \"Google Chrome\", window: 1, index: 1, title: \"Stackbar — your Mac, one keystroke away\", url: \"https://stackbar.app/\"),\n                Tab(browser: \"Arc\", window: 1, index: 4, title: \"Swift Forums\", url: \"https://forums.swift.org/\"),\n            ]\n            plugin.bookmarks = [Bookmark(title: \"Apple Developer Documentation\", url: \"https://developer.apple.com/documentation/\", folder: \"Bookmarks Bar › Docs\", browser: \"Google Chrome\")]\n            plugin.tabsReadAt = Date()\n        },\n        PreviewScene(\"browsers\", \"Safari, Chrome, Arc, Brave and Edge in one list\") { plugin in\n            plugin.tabs = [\n                Tab(browser: \"Safari\", window: 1, index: 1, title: \"Inbox · Fastmail\", url: \"https://app.fastmail.com/mail/Inbox\"),\n                Tab(browser: \"Google Chrome\", window: 1, index: 3, title: \"Q4 roadmap — Docs\", url: \"https://docs.google.com/document/d/abc\"),\n                Tab(browser: \"Arc\", window: 1, index: 2, title: \"Swift Forums\", url: \"https://forums.swift.org/\"),\n                Tab(browser: \"Brave Browser\", window: 2, index: 1, title: \"Hacker News\", url: \"https://news.ycombinator.com/\"),\n                Tab(browser: \"Microsoft Edge\", window: 1, index: 1, title: \"Azure Portal\", url: \"https://portal.azure.com/\"),\n            ]\n            plugin.tabsReadAt = Date()\n        },\n    ]\n\n    let theme = Theme.cyan\n\n    struct Tab: Equatable, Identifiable {\n        var browser: String\n        var window: Int\n        var index: Int\n        var title: String\n        var url: String\n        /// Lowercased once, so a keystroke doesn't allocate them again.\n        let lowerTitle: String\n        let lowerURL: String\n        var id: String { \"\\(browser)|\\(window)|\\(index)\" }\n\n        init(browser: String, window: Int, index: Int, title: String, url: String) {\n            (self.browser, self.window, self.index, self.title, self.url) = (browser, window, index, title, url)\n            (lowerTitle, lowerURL) = (title.lowercased(), url.lowercased())\n        }\n    }\n\n    struct Bookmark: Equatable, Identifiable {\n        var title: String\n        var url: String\n        var folder: String\n        var browser: String\n        /// Lowercased once, at load: a profile can hold thousands of bookmarks.\n        let lowerTitle: String\n        let lowerURL: String\n        var id: String { \"\\(browser)|\\(folder)|\\(url)\" }\n\n        init(title: String, url: String, folder: String, browser: String) {\n            (self.title, self.url, self.folder, self.browser) = (title, url, folder, browser)\n            (lowerTitle, lowerURL) = (title.lowercased(), url.lowercased())\n        }\n    }\n\n    var tabs: [Tab] = []\n    var tabsReadAt: Date?\n    var bookmarks: [Bookmark] = []\n    var bookmarksReadAt: Date?\n    var problems: [String] = []\n    var bookmarkProblems: [String] = []\n    var tabResults: [Tab] = []\n    var bookmarkResults: [Bookmark] = []\n\n    // MARK: Tabs\n\n    /// Prints `window⇥tab⇥title⇥url` a line for every tab. Only run for a\n    /// browser that is running, so it never starts one.\n    /// One line per tab: window, index, title and URL, split by tab\n    /// characters. The separators are made before `tell`: inside it, `tab`\n    /// is the browser's tab class, which joins as the word \"tab\", so no line\n    /// would split and every browser would look empty.\n    static func listScript(_ browser: Browser) -> String {\n        let title = browser.safari ? \"name of t\" : \"title of t\"\n        return \"\"\"\n        set sep to character id 9\n        set nl to character id 10\n        set out to \"\"\n        tell application \"\\(browser.name)\"\n            repeat with w from 1 to (count of windows)\n                set i to 0\n                repeat with t in (tabs of window w)\n                    set i to i + 1\n                    set out to out & w & sep & i & sep & (\\(title)) & sep & (URL of t) & nl\n                end repeat\n            end repeat\n        end tell\n        return out\n        \"\"\"\n    }\n\n    static func focusScript(_ tab: Tab, safari: Bool) -> String {\n        if safari {\n            return \"\"\"\n            tell application \"Safari\"\n                set current tab of window \\(tab.window) to tab \\(tab.index) of window \\(tab.window)\n                set index of window \\(tab.window) to 1\n                activate\n            end tell\n            \"\"\"\n        }\n        if tab.browser == \"Arc\" {\n            return \"\"\"\n            tell application \"Arc\"\n                tell tab \\(tab.index) of window \\(tab.window) to select\n                activate\n            end tell\n            \"\"\"\n        }\n        return \"\"\"\n        tell application \"\\(tab.browser)\"\n            set active tab index of window \\(tab.window) to \\(tab.index)\n            set index of window \\(tab.window) to 1\n            activate\n        end tell\n        \"\"\"\n    }\n\n    static func closeScript(_ tab: Tab) -> String {\n        \"tell application \\\"\\(tab.browser)\\\" to close tab \\(tab.index) of window \\(tab.window)\"\n    }\n\n    static func parseTabs(_ text: String, browser: String) -> [Tab] {\n        text.split(separator: \"\\n\").compactMap { line in\n            // A title may hold tabs of its own, so the URL is the last field.\n            let fields = line.split(separator: \"\\t\", omittingEmptySubsequences: false).map(String.init)\n            guard fields.count >= 4, let window = Int(fields[0]), let index = Int(fields[1]), let url = fields.last, !url.isEmpty, url != \"missing value\" else { return nil }\n            let title = fields[2..<(fields.count - 1)].joined(separator: \"\\t\")\n            return Tab(browser: browser, window: window, index: index, title: title == \"missing value\" || title.isEmpty ? url : title, url: url)\n        }\n    }\n\n    /// False when the browsers couldn't be listed, so the tabs kept are older\n    /// than asked for.\n    @discardableResult\n    func loadTabs(force: Bool = false) async -> Bool {\n        if !force, let tabsReadAt, Date().timeIntervalSince(tabsReadAt) < 5 { return true }\n        var found: [Tab] = []\n        var failed: [String] = []\n        let running: Set<String>\n        do {\n            running = Set(try await Stackbar.runningApps().map(\\.id))\n        } catch {\n            problems = [\"Can't see which browsers are running: \\(error)\"]\n            tabsReadAt = Date()\n            return false\n        }\n        // One script per running browser, side by side: a browser stuck on a\n        // dialog costs its own timeout, not everyone else's as well.\n        let outcomes = await withTaskGroup(of: (Int, String?, String?).self) { group in\n            for (position, browser) in Self.browsers.enumerated() where running.contains(browser.bundleID) {\n                group.addTask {\n                    guard let result = try? await Stackbar.run(\"osascript\", [\"-e\", Self.listScript(browser)], timeout: .seconds(10)) else { return (position, nil, \"it didn't answer\") }\n                    return (position, result.succeeded ? result.text : nil, result.succeeded ? nil : result.errorText)\n                }\n            }\n            var all: [(Int, String?, String?)] = []\n            for await outcome in group { all.append(outcome) }\n            return all.sorted { $0.0 < $1.0 }\n        }\n        for (position, text, errorText) in outcomes {\n            let browser = Self.browsers[position]\n            if let text {\n                found += Self.parseTabs(text, browser: browser.name)\n            } else if let errorText, errorText.contains(\"-1743\") || errorText.contains(\"Not authorized\") {\n                failed.append(\"Allow Stackbar to control \\(browser.name) in System Settings › Privacy & Security › Automation.\")\n            } else if let errorText {\n                failed.append(\"Couldn't read \\(browser.name)'s tabs: \\(errorText.split(separator: \"\\n\").first.map(String.init) ?? \"it didn't answer\")\")\n            }\n        }\n        tabs = found\n        problems = failed\n        tabsReadAt = Date()\n        return true\n    }\n\n    /// The tab as it is now, found by its page. Closing needs a sure match: the\n    /// same slot with the same title, or the page's only tab. Switching takes\n    /// the copy nearest the old place.\n    func current(_ tab: Tab, fresh: Bool) async throws -> Tab {\n        let read = await loadTabs(force: fresh)\n        if fresh && !read { throw HostError(\"Couldn't check the tab. Try again.\") }\n        let pages = tabs.filter { $0.browser == tab.browser && $0.url == tab.url }\n        let sameTitle = pages.filter { $0.title == tab.title }\n        let candidates = sameTitle.isEmpty ? pages : sameTitle\n        if fresh {\n            if let slot = pages.first(where: { $0.window == tab.window && $0.index == tab.index }), slot.title == tab.title { return slot }\n            guard let only = candidates.first, candidates.count == 1 else {\n                if candidates.isEmpty { throw HostError(\"That tab has closed. Search your tabs again.\") }\n                throw HostError(\"More than one tab has that page. Close it from the browser.\")\n            }\n            return only\n        }\n        // The copy in the same window nearest the old index, then any window.\n        let nearest = candidates.min { lhs, rhs in\n            (lhs.window == tab.window ? 0 : 1, abs(lhs.index - tab.index)) < (rhs.window == tab.window ? 0 : 1, abs(rhs.index - tab.index))\n        }\n        if let nearest { return nearest }\n        throw HostError(\"That tab has closed. Search your tabs again.\")\n    }\n\n    func focus(_ tab: Tab) async throws {\n        let tab = try await current(tab, fresh: false)\n        let safari = Self.browsers.first { $0.name == tab.browser }?.safari ?? false\n        let result = try await Stackbar.run(\"osascript\", [\"-e\", Self.focusScript(tab, safari: safari)], timeout: .seconds(10))\n        guard result.succeeded else { throw HostError(\"Couldn't switch to the tab: \\(result.errorText.prefix(160))\") }\n    }\n\n    func close(_ tab: Tab) async throws {\n        let tab = try await current(tab, fresh: true)\n        let result = try await Stackbar.run(\"osascript\", [\"-e\", Self.closeScript(tab)], timeout: .seconds(10))\n        guard result.succeeded else { throw HostError(\"Couldn't close the tab: \\(result.errorText.prefix(160))\") }\n        await loadTabs(force: true)\n    }\n\n    // MARK: Bookmarks\n\n    /// Chromium's `Bookmarks` JSON: `roots` of folders with `children`.\n    static func parseBookmarks(_ data: Data, browser: String) -> [Bookmark] {\n        struct Node: Decodable {\n            var type: String?\n            var name: String?\n            var url: String?\n            var children: [Node]?\n        }\n        struct File: Decodable { var roots: [String: Node] }\n        guard let file = try? JSONDecoder().decode(File.self, from: data) else { return [] }\n        var found: [Bookmark] = []\n        func walk(_ node: Node, path: [String]) {\n            if node.type == \"url\", let url = node.url, url.hasPrefix(\"http\") {\n                found.append(Bookmark(title: node.name?.isEmpty == false ? node.name! : url, url: url, folder: path.joined(separator: \" › \"), browser: browser))\n            }\n            for child in node.children ?? [] {\n                walk(child, path: node.type == \"folder\" ? path + [node.name ?? \"\"] : path)\n            }\n        }\n        for key in [\"bookmark_bar\", \"other\", \"synced\"] {\n            if let root = file.roots[key] { walk(root, path: []) }\n        }\n        return Array(found.prefix(5000))\n    }\n\n    func loadBookmarks(force: Bool = false) async {\n        if !force, let bookmarksReadAt, Date().timeIntervalSince(bookmarksReadAt) < 300 { return }\n        var found: [Bookmark] = []\n        var failed: [String] = []\n        for browser in Self.browsers {\n            guard let profile = browser.profile else { continue }\n            let result: RunResult\n            do {\n                result = try await Stackbar.run(\"cat\", [\"Bookmarks\"], workingDirectory: profile, timeout: .seconds(10))\n            } catch {\n                // No profile folder means the browser isn't installed: as quiet as a missing file.\n                if !\"\\(error)\".contains(\"is not a folder\") {\n                    failed.append(\"Couldn't read \\(browser.name)'s bookmarks: \\(error)\")\n                }\n                continue\n            }\n            // A missing file means the browser isn't installed, so that stays quiet.\n            guard result.succeeded else {\n                if !result.errorText.contains(\"No such file\") {\n                    failed.append(\"Couldn't read \\(browser.name)'s bookmarks: \\(result.errorText.split(separator: \"\\n\").first.map(String.init) ?? \"it didn't answer\")\")\n                }\n                continue\n            }\n            // Output past 8 MB is cut and the JSON would not decode, so say so\n            // instead of showing no bookmarks.\n            if result.truncated {\n                failed.append(\"\\(browser.name)'s bookmarks file is too large to read.\")\n                continue\n            }\n            found += Self.parseBookmarks(result.stdout, browser: browser.name)\n        }\n        bookmarkProblems = failed\n        var seen = Set<String>()\n        bookmarks = found.filter { seen.insert($0.url).inserted }\n        bookmarksReadAt = Date()\n    }\n\n    func refresh() async throws {\n        await loadTabs(force: true)\n        await loadBookmarks(force: true)\n    }\n\n    // MARK: Matching\n\n    /// `title` and `url` give the lowercased text to match, which the items\n    /// keep from when they were read.\n    static func matches<Item>(_ query: String, in items: [Item], title: (Item) -> String, url: (Item) -> String) -> [Item] {\n        let words = query.lowercased().split(separator: \" \").map(String.init)\n        guard !words.isEmpty else { return Array(items.prefix(80)) }\n        return items.compactMap { item -> (Item, Int)? in\n            let name = title(item)\n            let address = url(item)\n            var score = 0\n            for word in words {\n                if name.hasPrefix(word) { score += 300 } else if name.contains(word) { score += 200 } else if address.contains(word) { score += 100 } else { return nil }\n            }\n            return (item, score)\n        }\n        .sorted { $0.1 > $1.1 }\n        .prefix(80)\n        .map(\\.0)\n    }\n\n    static func host(_ url: String) -> String {\n        URL(string: url)?.host.map { $0.hasPrefix(\"www.\") ? String($0.dropFirst(4)) : $0 } ?? url\n    }\n\n    /// A Markdown link to paste: brackets escaped in the title, parentheses and\n    /// spaces percent-encoded in the address, so the link holds.\n    static func markdownLink(title: String, url: String) -> String {\n        let text = title.replacingOccurrences(of: \"[\", with: \"\\\\[\").replacingOccurrences(of: \"]\", with: \"\\\\]\")\n        let address = url.replacingOccurrences(of: \"(\", with: \"%28\").replacingOccurrences(of: \")\", with: \"%29\").replacingOccurrences(of: \" \", with: \"%20\")\n        return \"[\\(text)](\\(address))\"\n    }\n\n    /// A panel row also hides from the palette: a tab read at launch may have\n    /// closed or changed title since, and its actions would still be offered.\n    func row(_ tab: Tab, inPanel: Bool = false) -> Row {\n        let row = Row(tab.title, subtitle: Self.host(tab.url), symbol: \"globe\", key: tab.id)\n            .appIcon(Self.browsers.first { $0.name == tab.browser }?.bundleID)\n            .action(\"Switch to Tab\") { try await self.focus(tab) }\n            .action(\"Copy Link\", shortcut: \"cmd+shift+c\") { Stackbar.copy(tab.url) }\n            .action(\"Copy as Markdown Link\") { Stackbar.copy(Self.markdownLink(title: tab.title, url: tab.url)) }\n            .action(\"Close Tab\", shortcut: \"cmd+w\", role: .critical, confirm: .none) { try await self.close(tab) }\n        return inPanel ? row.hiddenFromPalette() : row\n    }\n\n    func row(_ bookmark: Bookmark) -> Row {\n        let bundleID = Self.browsers.first { $0.name == bookmark.browser }?.bundleID\n        return Row(bookmark.title, subtitle: [bookmark.folder, Self.host(bookmark.url)].filter { !$0.isEmpty }.joined(separator: \" · \"), symbol: \"bookmark\", key: bookmark.id)\n            .appIcon(bundleID)\n            .action(\"Open in \\(bookmark.browser)\") { try await Stackbar.open(bookmark.url, with: bundleID) }\n            .action(\"Open in Default Browser\") { Stackbar.open(bookmark.url) }\n            .action(\"Copy Link\", shortcut: \"cmd+shift+c\") { Stackbar.copy(bookmark.url) }\n            .action(\"Copy as Markdown Link\") { Stackbar.copy(Self.markdownLink(title: bookmark.title, url: bookmark.url)) }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"tabs\", title: \"Search Browser Tabs\", subtitle: \"Switch to any open tab\", symbol: \"rectangle.stack\", keywords: [\"tab\", \"browser\", \"safari\", \"chrome\", \"arc\", \"switch\"])\n            .view(search: { invocation in\n                await self.loadTabs(force: invocation.text.isEmpty)\n                self.tabResults = Self.matches(invocation.text, in: self.tabs, title: \\.lowerTitle, url: \\.lowerURL)\n            }) { _ in\n                if self.tabResults.isEmpty {\n                    ContentUnavailableView(self.tabs.isEmpty ? \"No tabs open\" : \"No tab matches\", symbol: \"rectangle.stack\", message: self.problems.first ?? \"Tabs in Safari, Chrome, Arc, Brave and Edge show up here while they run.\")\n                } else {\n                    List {\n                        ForEach(self.tabResults) { tab in self.row(tab) }\n                    }\n                }\n            }\n        Command(\"bookmarks\", title: \"Search Bookmarks\", subtitle: \"From Chrome, Brave, Edge and Vivaldi\", symbol: \"bookmark\", keywords: [\"bookmark\", \"favorite\", \"browser\", \"chrome\"])\n            .view(search: { invocation in\n                await self.loadBookmarks()\n                self.bookmarkResults = Self.matches(invocation.text, in: self.bookmarks, title: \\.lowerTitle, url: \\.lowerURL)\n            }) { _ in\n                if self.bookmarkResults.isEmpty {\n                    ContentUnavailableView(self.bookmarks.isEmpty ? \"No bookmarks found\" : \"No bookmark matches\", symbol: \"bookmark\", message: self.bookmarkProblems.first ?? \"Bookmarks from Chrome, Brave, Edge and Vivaldi show up here. Safari keeps its bookmarks private.\")\n                } else {\n                    List {\n                        ForEach(self.bookmarkResults) { bookmark in self.row(bookmark) }\n                    }\n                }\n            }\n    }\n\n    // MARK: Panel\n\n    var panel: ViewContent {\n        PanelHeader(\"Browser Tabs\", subtitle: \"\\(tabs.count) tabs · \\(bookmarks.count) bookmarks\", symbol: \"rectangle.stack.fill\", theme: theme)\n        for problem in problems { Banner(problem, theme: .warning) }\n        for problem in bookmarkProblems { Banner(problem, theme: .warning) }\n        if tabs.isEmpty {\n            EmptyState(\"Switch tabs from the palette\", symbol: \"rectangle.stack\", message: \"Open the palette, type Search Browser Tabs, then part of a page's title or address.\", theme: theme)\n        } else {\n            Card(\"Open tabs\", spacing: 0, padding: Space.s) {\n                ForEach(Array(tabs.prefix(8))) { tab in self.row(tab, inPanel: true) }\n            }\n        }\n    }\n}\n",
  "sourceDigest" : "49061afcc3d03c2ac673747739587ac3c90a50f3d61bfac8840e3aa52e5357a8"
}