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