diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04a725d..4c0b85c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ concurrency: jobs: build-test: - name: Build & Test (macOS 15) + name: ci runs-on: macos-15 if: >- github.event_name == 'workflow_dispatch' || diff --git a/README.md b/README.md index 8b2f2ee..d40f1e4 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ YouTube's own internal "InnerTube" API directly. **No Google Cloud project, no API key, no OAuth client to set up.** Sign into youtube.com in your usual browser — passkeys, password managers, all of it — and YouMenuTube imports the session. Supports Safari, Chrome, Edge, Arc, Brave, Vivaldi, -Opera, Helium and Firefox. +Opera, Helium, Firefox and Zen. > ## ⚠️ Important — read before installing > @@ -190,7 +190,7 @@ work — your normal browser. Three formats covered: |---|---|---| | **Safari** | Binary cookies inside the Safari container | One-time **Full Disk Access** grant (System Settings → Privacy & Security → Full Disk Access → add YouMenuTube). Without it the import fails cleanly. | | **Chrome / Edge / Arc / Brave / Vivaldi / Opera / Helium** (Chromium family) | SQLite + AES-128-CBC with a key in the login Keychain | A standard "YouMenuTube wants to use confidential information stored in 'Chrome Safe Storage' …" Keychain prompt. Click **Always Allow** once per browser. (Helium's Keychain entry is `Helium Storage Key` rather than the usual `… Safe Storage`, but the flow is identical.) | -| **Firefox** | Plain SQLite, unencrypted | No prompt. | +| **Firefox / Zen** (Firefox family) | Plain SQLite, unencrypted | No prompt. | The importer filters strictly to `*.youtube.com`-scoped rows — mixing in `.google.com` or `accounts.google.com` cookies makes InnerTube respond diff --git a/Sources/Assets.xcassets/Browsers/Contents.json b/Sources/Assets.xcassets/Browsers/Contents.json new file mode 100644 index 0000000..6e96565 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/arc.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/arc.imageset/Contents.json new file mode 100644 index 0000000..9d33fbd --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/arc.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "arc.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/arc.imageset/arc.svg b/Sources/Assets.xcassets/Browsers/arc.imageset/arc.svg new file mode 100644 index 0000000..a2f8481 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/arc.imageset/arc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/Assets.xcassets/Browsers/brave.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/brave.imageset/Contents.json new file mode 100644 index 0000000..0aa572c --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/brave.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "brave.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/brave.imageset/brave.svg b/Sources/Assets.xcassets/Browsers/brave.imageset/brave.svg new file mode 100644 index 0000000..e3749a6 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/brave.imageset/brave.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/Assets.xcassets/Browsers/chrome.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/chrome.imageset/Contents.json new file mode 100644 index 0000000..9d8a820 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/chrome.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chrome.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/chrome.imageset/chrome.svg b/Sources/Assets.xcassets/Browsers/chrome.imageset/chrome.svg new file mode 100644 index 0000000..d83e7bf --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/chrome.imageset/chrome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/Assets.xcassets/Browsers/edge.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/edge.imageset/Contents.json new file mode 100644 index 0000000..5029ade --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/edge.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "edge.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/edge.imageset/edge.svg b/Sources/Assets.xcassets/Browsers/edge.imageset/edge.svg new file mode 100644 index 0000000..56caadd --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/edge.imageset/edge.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/Assets.xcassets/Browsers/firefox.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/firefox.imageset/Contents.json new file mode 100644 index 0000000..59e17e6 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/firefox.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "firefox.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/firefox.imageset/firefox.svg b/Sources/Assets.xcassets/Browsers/firefox.imageset/firefox.svg new file mode 100644 index 0000000..b7ae49f --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/firefox.imageset/firefox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/Assets.xcassets/Browsers/helium.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/helium.imageset/Contents.json new file mode 100644 index 0000000..c1f29d5 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/helium.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "helium.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Assets.xcassets/Browsers/helium.imageset/helium.png b/Sources/Assets.xcassets/Browsers/helium.imageset/helium.png new file mode 100644 index 0000000..d058466 Binary files /dev/null and b/Sources/Assets.xcassets/Browsers/helium.imageset/helium.png differ diff --git a/Sources/Assets.xcassets/Browsers/opera.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/opera.imageset/Contents.json new file mode 100644 index 0000000..e73067c --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/opera.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "opera.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/opera.imageset/opera.svg b/Sources/Assets.xcassets/Browsers/opera.imageset/opera.svg new file mode 100644 index 0000000..0ebd501 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/opera.imageset/opera.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/Assets.xcassets/Browsers/safari.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/safari.imageset/Contents.json new file mode 100644 index 0000000..feaf249 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/safari.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "safari.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/safari.imageset/safari.svg b/Sources/Assets.xcassets/Browsers/safari.imageset/safari.svg new file mode 100644 index 0000000..ef0dff8 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/safari.imageset/safari.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/Assets.xcassets/Browsers/vivaldi.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/vivaldi.imageset/Contents.json new file mode 100644 index 0000000..7030ee6 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/vivaldi.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "vivaldi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/vivaldi.imageset/vivaldi.svg b/Sources/Assets.xcassets/Browsers/vivaldi.imageset/vivaldi.svg new file mode 100644 index 0000000..7192aa3 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/vivaldi.imageset/vivaldi.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/Assets.xcassets/Browsers/zen.imageset/Contents.json b/Sources/Assets.xcassets/Browsers/zen.imageset/Contents.json new file mode 100644 index 0000000..1027b6a --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/zen.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "zen.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Assets.xcassets/Browsers/zen.imageset/zen.svg b/Sources/Assets.xcassets/Browsers/zen.imageset/zen.svg new file mode 100644 index 0000000..f4d3b59 --- /dev/null +++ b/Sources/Assets.xcassets/Browsers/zen.imageset/zen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/Services/BrowserCookieImport/Browser.swift b/Sources/Services/BrowserCookieImport/Browser.swift index c4e375d..ef4c689 100644 --- a/Sources/Services/BrowserCookieImport/Browser.swift +++ b/Sources/Services/BrowserCookieImport/Browser.swift @@ -2,12 +2,12 @@ import Foundation /// Browsers we know how to pull youtube.com cookies from. /// -/// Three on-disk formats, eight browser variants: +/// Three on-disk formats, nine browser variants: /// - Safari binarycookies → Safari /// - Chromium SQLite + Keychain AES key → Chrome, Edge, Arc, Brave, Vivaldi, Opera -/// - Firefox SQLite (plain) → Firefox +/// - Firefox SQLite (plain) → Firefox, Zen enum Browser: String, CaseIterable, Identifiable { - case safari, chrome, edge, arc, brave, vivaldi, opera, helium, firefox + case safari, chrome, edge, arc, brave, vivaldi, opera, helium, firefox, zen var id: String { rawValue } @@ -16,7 +16,7 @@ enum Browser: String, CaseIterable, Identifiable { var format: Format { switch self { case .safari: .safari - case .firefox: .firefox + case .firefox, .zen: .firefox case .chrome, .edge, .arc, .brave, .vivaldi, .opera, .helium: .chromium } } @@ -32,6 +32,7 @@ enum Browser: String, CaseIterable, Identifiable { case .opera: "Opera" case .helium: "Helium" case .firefox: "Firefox" + case .zen: "Zen" } } @@ -49,15 +50,15 @@ enum Browser: String, CaseIterable, Identifiable { case .opera: "com.operasoftware.Opera" case .helium: "net.imput.helium" case .firefox: "org.mozilla.firefox" + case .zen: "app.zen-browser.zen" } } - /// SF Symbol for the browser picker row. - var symbol: String { - switch self { - case .safari: "safari" - default: "globe" - } + /// Asset-catalog image name for the browser picker row. Sourced from + /// each vendor's official wordmark/logo and namespaced under the + /// `Browsers` group in `Assets.xcassets`. + var iconAsset: String { + "Browsers/\(rawValue)" } /// Root directory that contains this browser's user data. `nil` for @@ -74,6 +75,7 @@ enum Browser: String, CaseIterable, Identifiable { case .opera: return appSupport.appending(path: "com.operasoftware.Opera", directoryHint: .isDirectory) case .helium: return appSupport.appending(path: "net.imput.helium", directoryHint: .isDirectory) case .firefox: return appSupport.appending(path: "Firefox", directoryHint: .isDirectory) + case .zen: return appSupport.appending(path: "zen", directoryHint: .isDirectory) } } diff --git a/Sources/Services/BrowserCookieImport/BrowserCookieImporter.swift b/Sources/Services/BrowserCookieImport/BrowserCookieImporter.swift index ac5bfff..df242c2 100644 --- a/Sources/Services/BrowserCookieImport/BrowserCookieImporter.swift +++ b/Sources/Services/BrowserCookieImport/BrowserCookieImporter.swift @@ -77,10 +77,10 @@ enum BrowserCookieImporter { all = try ChromiumCookies.read(browser: browser, storeURL: store, domainSuffix: youtubeDomainSuffix) log.notice("\(browser.rawValue): read \(all.count) cookies from \(store.path(percentEncoded: false))") case .firefox: - let stores = BrowserDetector.firefoxCookieStores() + let stores = BrowserDetector.firefoxCookieStores(for: browser) guard let store = stores.first else { throw BrowserCookieError.noStore(browser) } all = try FirefoxCookies.read(at: store, domainSuffix: youtubeDomainSuffix) - log.notice("firefox: read \(all.count) cookies from \(store.path(percentEncoded: false))") + log.notice("\(browser.rawValue): read \(all.count) cookies from \(store.path(percentEncoded: false))") } // Keep the youtube.com-only filter (load-bearing per SECURITY.md: diff --git a/Sources/Services/BrowserCookieImport/BrowserDetector.swift b/Sources/Services/BrowserCookieImport/BrowserDetector.swift index b72d01e..0acebea 100644 --- a/Sources/Services/BrowserCookieImport/BrowserDetector.swift +++ b/Sources/Services/BrowserCookieImport/BrowserDetector.swift @@ -56,10 +56,15 @@ enum BrowserDetector { /// Firefox stores profiles under a root directory with randomised names /// (e.g. `abcdef.default-release`). Return every profile that has a /// `cookies.sqlite`, most-recently-modified first. - static func firefoxCookieStores() -> [URL] { + /// + /// Works for any Firefox-format browser (Firefox proper, Zen, …): the + /// profile layout (`Profiles/./cookies.sqlite`) is inherited + /// from upstream Firefox. + static func firefoxCookieStores(for browser: Browser = .firefox) -> [URL] { + guard browser.format == .firefox else { return [] } let fm = FileManager.default let home = fm.homeDirectoryForCurrentUser - guard let root = Browser.firefox.userDataRoot(home: home) else { return [] } + guard let root = browser.userDataRoot(home: home) else { return [] } let profilesDir = root.appending(path: "Profiles", directoryHint: .isDirectory) guard let children = try? fm.contentsOfDirectory( diff --git a/Sources/Views/ImportSessionWindow.swift b/Sources/Views/ImportSessionWindow.swift index a7ed7bc..a5aa098 100644 --- a/Sources/Views/ImportSessionWindow.swift +++ b/Sources/Views/ImportSessionWindow.swift @@ -71,13 +71,36 @@ struct ImportSessionWindow: View { Text("No supported browser found on this Mac.") .foregroundStyle(.secondary) } else { - Picker("Browser", selection: $selected) { + // We use a `Menu` rather than `Picker(.menu)` because the + // closed-state of `Picker(.menu)` flattens to NSPopUpButton, + // which ignores SwiftUI frame modifiers on vector images + // and renders the brand SVGs at their native viewBox size. + // With `Menu`, the closed-state label is pure SwiftUI and + // honours our 16×16 frame; the dropdown rows go through + // NSMenu, where pre-sizing the `NSImage` keeps them tidy. + Menu { ForEach(browsers) { b in - Label(b.displayName, systemImage: b.symbol).tag(Optional(b)) + Button { + selected = b + } label: { + Label { + Text(b.displayName) + } icon: { + browserIcon(b) + } + } + } + } label: { + if let b = selected { + HStack(spacing: 8) { + browserIcon(b) + Text(b.displayName) + } + } else { + Text("Select a browser") } } - .pickerStyle(.menu) - .labelsHidden() + .menuStyle(.borderlessButton) .disabled(status.isWorking) } } @@ -204,6 +227,19 @@ struct ImportSessionWindow: View { } } + /// Loads the browser's brand icon as a pre-sized `NSImage`. AppKit's + /// NSMenu sizes item images from the `NSImage.size` property and + /// ignores SwiftUI frame modifiers, so vector SVGs without an + /// intrinsic size render at their native viewBox (huge). Copying the + /// cached named image and setting `size` fixes the dropdown rows. + private func browserIcon(_ browser: Browser) -> Image { + guard let original = NSImage(named: browser.iconAsset), + let sized = original.copy() as? NSImage + else { return Image(systemName: "globe") } + sized.size = NSSize(width: 16, height: 16) + return Image(nsImage: sized) + } + private func bringToFront() { // `NSApp.activate(ignoringOtherApps:)` was softened in macOS 14 — it // only activates if the caller was recently user-facing, which a