From c129dbd9c19f810a43f0b1f75fa59014c3f24c2d Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Sun, 19 Jul 2026 20:56:32 -0300 Subject: [PATCH] Add tracking protection and sidebar placement --- Phi.xcodeproj/project.pbxproj | 4 + README.md | 18 ++ Resources/TrackingProtection/background.js | 25 ++ Resources/TrackingProtection/content.js | 69 ++++ Resources/TrackingProtection/manifest.json | 24 ++ Resources/TrackingProtection/popup.css | 87 +++++ Resources/TrackingProtection/popup.html | 40 +++ Resources/TrackingProtection/popup.js | 42 +++ Resources/TrackingProtection/url-cleaner.js | 69 ++++ Sources/Application/AppController+Menu.swift | 104 ++---- Sources/ChromiumBridge/ChromiumLauncher.m | 16 + .../OmniBoxContainerViewController.swift | 8 +- .../Common/TabAreaContextMenuHelper.swift | 34 -- .../MainBrowserWindowController.swift | 6 +- .../MainSplitViewController.swift | 113 ++++--- .../LayoutSelectionViewController.swift | 59 ++-- .../General/GeneralSettingView.swift | 52 ++- .../General/LayoutModeSettingView.swift | 49 ++- .../Preferences/PhiPreferences.swift | 63 ++-- .../Header/FloatingTrafficLightsView.swift | 3 +- .../Sidebar/Header/SidebarHeaderView.swift | 16 + .../Header/WebContentHeaderView.swift | 6 +- ...tainerViewController+FloatingSidebar.swift | 118 ++++--- .../WebContentContainerViewController.swift | 23 +- .../BrowserStateBookmarkLayoutTests.swift | 299 ------------------ Tests/PhiBrowserTests/PhiBrowserTests.swift | 38 +++ script/verify | 10 + 27 files changed, 785 insertions(+), 610 deletions(-) create mode 100644 Resources/TrackingProtection/background.js create mode 100644 Resources/TrackingProtection/content.js create mode 100644 Resources/TrackingProtection/manifest.json create mode 100644 Resources/TrackingProtection/popup.css create mode 100644 Resources/TrackingProtection/popup.html create mode 100644 Resources/TrackingProtection/popup.js create mode 100644 Resources/TrackingProtection/url-cleaner.js delete mode 100644 Tests/PhiBrowserTests/BrowserStateBookmarkLayoutTests.swift diff --git a/Phi.xcodeproj/project.pbxproj b/Phi.xcodeproj/project.pbxproj index 29beb077..7c92240a 100644 --- a/Phi.xcodeproj/project.pbxproj +++ b/Phi.xcodeproj/project.pbxproj @@ -381,6 +381,7 @@ D1F600042FD6000000000004 /* DiffableOutlineDiffPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F600032FD6000000000003 /* DiffableOutlineDiffPlanner.swift */; }; D1F600062FD6000000000006 /* DiffableOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F600052FD6000000000005 /* DiffableOutlineView.swift */; }; D1F600082FD6000000000008 /* SidebarDiffableSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F600072FD6000000000007 /* SidebarDiffableSnapshotBuilder.swift */; }; + D7A000022FE7000000000002 /* TrackingProtection in Resources */ = {isa = PBXBuildFile; fileRef = D7A000012FE7000000000001 /* TrackingProtection */; }; DEBE00000000000000000005 /* DeveloperSettingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBE00000000000000000002 /* DeveloperSettingViewController.swift */; }; DEBE00000000000000000006 /* DeveloperSettingHostingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBE00000000000000000003 /* DeveloperSettingHostingViewController.swift */; }; DEBE00000000000000000007 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBE00000000000000000004 /* DeveloperSettingsView.swift */; }; @@ -856,6 +857,7 @@ D1F600032FD6000000000003 /* DiffableOutlineDiffPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableOutlineDiffPlanner.swift; sourceTree = ""; }; D1F600052FD6000000000005 /* DiffableOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableOutlineView.swift; sourceTree = ""; }; D1F600072FD6000000000007 /* SidebarDiffableSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDiffableSnapshotBuilder.swift; sourceTree = ""; }; + D7A000012FE7000000000001 /* TrackingProtection */ = {isa = PBXFileReference; lastKnownFileType = folder; path = TrackingProtection; sourceTree = ""; }; DEBE00000000000000000002 /* DeveloperSettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingViewController.swift; sourceTree = ""; }; DEBE00000000000000000003 /* DeveloperSettingHostingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingHostingViewController.swift; sourceTree = ""; }; DEBE00000000000000000004 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = ""; }; @@ -1528,6 +1530,7 @@ B0C469752E3B158D007F0C7A /* Resources */ = { isa = PBXGroup; children = ( + D7A000012FE7000000000001 /* TrackingProtection */, 8EC502142F67AF7F00CE7338 /* DownloadSafety.xcstrings */, 375C66372EFB923C00885D78 /* Fonts */, 3765817A2EE9637F00D3E031 /* Localizable.xcstrings */, @@ -2219,6 +2222,7 @@ buildActionMask = 2147483647; files = ( CDA11ED500000000000000B1 /* claude-skill in Resources */, + D7A000022FE7000000000002 /* TrackingProtection in Resources */, 37F574482F7A7C5D00879867 /* LuaIcon.icon in Resources */, B0C4694E2E3B10E2007F0C7A /* Assets.xcassets in Resources */, 3765817B2EE963AD00D3E031 /* Localizable.xcstrings in Resources */, diff --git a/README.md b/README.md index b0ed66e7..adaf06c6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ focuses on the core browsing experience. - Native macOS windows, sidebars, settings, and system integration - Chromium tabs, profiles, extensions, downloads, bookmarks, and DevTools - Spaces for organizing tabs and profiles +- Built-in local tracking-parameter protection with Remove and Mask modes - An optional local CDP skill for supervised browser automation Inherited Phi account, connector, rollback, crash-help, and update services @@ -86,6 +87,23 @@ The old `phi://` URL scheme remains accepted temporarily alongside canonical `lua://` links. macOS permissions and keychain items tied to the former bundle identifier may require reapproval. +## Tracking protection + +Lua bundles a local Chromium extension that recognizes common advertising and +email attribution parameters such as `gclid`, `fbclid`, `msclkid`, and +`utm_*`. Remove mode strips them; Mask mode retains the parameter names while +replacing their values. The extension can be paused and keeps a per-profile +counter in Chromium extension storage. It has no server, account, or telemetry. +The feature was inspired by the public +[`donttrackme`](https://github.com/kiwi-init/donttrackme) project and was +implemented for Chromium as source-owned Lua code. + +Followed links are cleaned before their destination navigation. When a tracked +URL is pasted, opened externally, or reached through a redirect, the initial +request may already contain its parameters; Lua then cleans the visible URL in +place. This is URL hygiene, not a network request blocker or a substitute for +engine-level anti-fingerprinting. + ## Contributing and releases Open pull requests against `main`. Releases are immutable `vX.Y.Z` tags from diff --git a/Resources/TrackingProtection/background.js b/Resources/TrackingProtection/background.js new file mode 100644 index 00000000..6c633f55 --- /dev/null +++ b/Resources/TrackingProtection/background.js @@ -0,0 +1,25 @@ +// Copyright 2026 Phinomenon Inc. +// Licensed under the Apache License, Version 2.0. + +"use strict"; + +let counterUpdate = Promise.resolve(); + +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.get({ mode: "remove", enabled: true, cleaned: 0 }, state => { + chrome.storage.local.set(state); + }); +}); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== "trackers-cleaned") return false; + const increment = Number.isSafeInteger(message.count) && message.count > 0 ? message.count : 0; + if (increment === 0) return false; + + counterUpdate = counterUpdate + .then(() => chrome.storage.local.get({ cleaned: 0 })) + .then(state => chrome.storage.local.set({ cleaned: state.cleaned + increment })) + .then(() => sendResponse({ ok: true })) + .catch(() => sendResponse({ ok: false })); + return true; +}); diff --git a/Resources/TrackingProtection/content.js b/Resources/TrackingProtection/content.js new file mode 100644 index 00000000..6c512bde --- /dev/null +++ b/Resources/TrackingProtection/content.js @@ -0,0 +1,69 @@ +// Copyright 2026 Phinomenon Inc. +// Licensed under the Apache License, Version 2.0. + +(() => { + "use strict"; + + let mode = "remove"; + let enabled = true; + + function reportCleaned(count) { + if (count <= 0) return; + chrome.runtime.sendMessage({ type: "trackers-cleaned", count }, () => { + // Reading lastError suppresses noise when the extension is reloaded + // while a page still owns the previous content-script context. + void chrome.runtime.lastError; + }); + } + + function cleanCurrentURL() { + if (!enabled) return; + const result = LuaTrackingProtection.cleanURL(location.href, mode); + if (!result || result.url === location.href) return; + + try { + history.replaceState(history.state, "", result.url); + reportCleaned(result.count); + } catch { + // Some documents disallow history mutation. Link cleaning remains active. + } + } + + function cleanAnchor(anchor) { + if (!enabled || !anchor?.href) return; + const result = LuaTrackingProtection.cleanURL(anchor.href, mode); + if (!result || result.url === anchor.href) return; + anchor.href = result.url; + reportCleaned(result.count); + } + + function anchorFromEvent(event) { + const target = event.composedPath?.()[0] ?? event.target; + return target?.closest?.("a[href]") ?? null; + } + + for (const eventName of ["pointerdown", "mousedown", "auxclick", "click", "contextmenu"]) { + document.addEventListener(eventName, event => cleanAnchor(anchorFromEvent(event)), true); + } + + document.addEventListener("keydown", event => { + if (event.key === "Enter") cleanAnchor(anchorFromEvent(event)); + }, true); + + for (const eventName of ["pageshow", "popstate", "hashchange"]) { + addEventListener(eventName, cleanCurrentURL, true); + } + + chrome.storage.local.get({ mode: "remove", enabled: true }, state => { + mode = state.mode === "mask" ? "mask" : "remove"; + enabled = state.enabled !== false; + cleanCurrentURL(); + }); + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== "local") return; + if (changes.mode) mode = changes.mode.newValue === "mask" ? "mask" : "remove"; + if (changes.enabled) enabled = changes.enabled.newValue !== false; + cleanCurrentURL(); + }); +})(); diff --git a/Resources/TrackingProtection/manifest.json b/Resources/TrackingProtection/manifest.json new file mode 100644 index 00000000..d26c6030 --- /dev/null +++ b/Resources/TrackingProtection/manifest.json @@ -0,0 +1,24 @@ +{ + "manifest_version": 3, + "name": "Lua Tracking Protection", + "description": "Removes or masks known tracking parameters before followed links navigate.", + "version": "1.0.0", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsfzMxXZotLCRJbx4JtRSfcxk9hm22o+ZkoCoJLcR5SDhv2nWQ5yguFQHo5F674bFHvoXa1CbubYVhRHwEK5g34dhQKc9kLIfczvieA7tIzzv5yUZ+gAX/cawfXotJCaZE8A2BpXseBsGeb3ler5zAR0ems0s6iqX0pXmB/KztZ+AtpnTAR8YEur0XY+yQtscVURCCdCfxfuhhhHZKC826+VjMVGQBVTSbBOSuuRqZoBIzbV9Pxf42wFcq8O5C3Go6MbdxZVenCNEPwPIR0UeJU7qU9omDJiq1gALtIEd+8A9EOa7JCg+uQcVkRlUTxyht1jm9qsRyZjUZ2WYDCNCPQIDAQAB", + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["url-cleaner.js", "content.js"], + "run_at": "document_start", + "all_frames": true + } + ], + "action": { + "default_title": "Lua Tracking Protection", + "default_popup": "popup.html" + }, + "permissions": ["storage"], + "host_permissions": [""] +} diff --git a/Resources/TrackingProtection/popup.css b/Resources/TrackingProtection/popup.css new file mode 100644 index 00000000..43279036 --- /dev/null +++ b/Resources/TrackingProtection/popup.css @@ -0,0 +1,87 @@ +/* Copyright 2026 Phinomenon Inc. Licensed under Apache-2.0. */ + +:root { + color-scheme: light dark; + --background: #ffffff; + --surface: #f3f3f5; + --text: #171719; + --secondary: #6c6c72; + --border: #dedee2; + --accent: #71717a; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #171719; + --surface: #262629; + --text: #f5f5f6; + --secondary: #a1a1a7; + --border: #38383d; + --accent: #a1a1aa; + } +} + +* { box-sizing: border-box; } + +body { + width: 310px; + margin: 0; + padding: 16px; + background: var(--background); + color: var(--text); + font: 13px -apple-system, BlinkMacSystemFont, sans-serif; +} + +header, .mode { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +header div { display: grid; gap: 3px; } +header strong { font-size: 14px; } +header div span, #status, .count span, .mode > span { color: var(--secondary); } +header div span { font-size: 11px; } + +.switch { position: relative; width: 36px; height: 22px; flex: 0 0 auto; } +.switch input { position: absolute; opacity: 0; } +.switch span { + position: absolute; + inset: 0; + border-radius: 11px; + background: var(--border); + transition: background 120ms ease; +} +.switch span::after { + content: ""; + position: absolute; + width: 18px; + height: 18px; + top: 2px; + left: 2px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 3px rgb(0 0 0 / 22%); + transition: transform 120ms ease; +} +.switch input:checked + span { background: var(--accent); } +.switch input:checked + span::after { transform: translateX(14px); } + +main { display: grid; gap: 12px; margin-top: 14px; } +.count { display: grid; place-items: center; gap: 3px; padding: 14px; border-radius: 10px; background: var(--surface); } +.count strong { font-size: 28px; line-height: 1; } +.count span { font-size: 10px; letter-spacing: .04em; text-transform: uppercase; } + +.mode div { display: flex; padding: 2px; border-radius: 8px; background: var(--surface); } +button { + border: 0; + border-radius: 6px; + padding: 6px 13px; + background: transparent; + color: var(--text); + font: inherit; + cursor: pointer; +} +button.selected { background: var(--accent); color: white; } +#status { min-height: 32px; margin: 0; font-size: 11px; line-height: 1.45; text-align: center; } diff --git a/Resources/TrackingProtection/popup.html b/Resources/TrackingProtection/popup.html new file mode 100644 index 00000000..b6ea1336 --- /dev/null +++ b/Resources/TrackingProtection/popup.html @@ -0,0 +1,40 @@ + + + + + + + Lua Tracking Protection + + + + +
+
+ Tracking Protection + Clean tracking parameters from URLs +
+ +
+ +
+
+ 0 + parameters cleaned +
+ +
+ Mode +
+ + +
+
+ +

+
+ + diff --git a/Resources/TrackingProtection/popup.js b/Resources/TrackingProtection/popup.js new file mode 100644 index 00000000..a61fbb57 --- /dev/null +++ b/Resources/TrackingProtection/popup.js @@ -0,0 +1,42 @@ +// Copyright 2026 Phinomenon Inc. +// Licensed under the Apache License, Version 2.0. + +"use strict"; + +const enabledControl = document.querySelector("#enabled"); +const cleanedLabel = document.querySelector("#cleaned"); +const removeButton = document.querySelector("#remove"); +const maskButton = document.querySelector("#mask"); +const statusLabel = document.querySelector("#status"); + +function render(state) { + const mode = state.mode === "mask" ? "mask" : "remove"; + const enabled = state.enabled !== false; + enabledControl.checked = enabled; + cleanedLabel.textContent = String(Number.isSafeInteger(state.cleaned) ? state.cleaned : 0); + removeButton.classList.toggle("selected", mode === "remove"); + maskButton.classList.toggle("selected", mode === "mask"); + + if (!enabled) { + statusLabel.textContent = "Paused. Tracking parameters are left unchanged."; + } else if (mode === "remove") { + statusLabel.textContent = "Known tracking parameters are removed from followed links."; + } else { + statusLabel.textContent = "Known tracking values are replaced with random tokens."; + } +} + +function readState() { + chrome.storage.local.get({ mode: "remove", enabled: true, cleaned: 0 }, render); +} + +enabledControl.addEventListener("change", () => { + chrome.storage.local.set({ enabled: enabledControl.checked }); +}); +removeButton.addEventListener("click", () => chrome.storage.local.set({ mode: "remove" })); +maskButton.addEventListener("click", () => chrome.storage.local.set({ mode: "mask" })); +chrome.storage.onChanged.addListener((_changes, areaName) => { + if (areaName === "local") readState(); +}); + +readState(); diff --git a/Resources/TrackingProtection/url-cleaner.js b/Resources/TrackingProtection/url-cleaner.js new file mode 100644 index 00000000..e42c1f7f --- /dev/null +++ b/Resources/TrackingProtection/url-cleaner.js @@ -0,0 +1,69 @@ +// Copyright 2026 Phinomenon Inc. +// Licensed under the Apache License, Version 2.0. + +(() => { + "use strict"; + + const knownParameters = new Set([ + "gclid", "gclsrc", "dclid", "gbraid", "wbraid", "gad_source", "gad", "gclaw", + "fbclid", "fb_action_ids", "fb_action_types", "fb_ref", "fb_source", "fb_locale", "_fb", + "msclkid", "ttclid", "twclid", "li_fat_id", "yclid", "_openstat", + "mc_eid", "mc_cid", "_hsenc", "_hsmi", "__hssc", "__hstc", "__hsfp", + "hsctatracking", "igshid", "igsh", "epik", "sc_cid", "_ke", "mkt_tok", + "s_kwcid", "vero_id", "vero_conv", "oly_anon_id", "oly_enc_id", + "_branch_match_id", "wickedid" + ]); + + function isTrackingParameter(name) { + const normalized = name.toLowerCase(); + return knownParameters.has(normalized) || normalized.startsWith("utm_"); + } + + function randomToken(length) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + let token = ""; + for (const byte of bytes) { + token += alphabet[byte % alphabet.length]; + } + return token; + } + + function cleanURL(input, mode) { + if (mode !== "remove" && mode !== "mask") return null; + + let url; + try { + url = new URL(input); + } catch { + return null; + } + if (!url.search) return null; + + const cleanedEntries = []; + let cleanedCount = 0; + for (const [name, value] of url.searchParams.entries()) { + if (!isTrackingParameter(name)) { + cleanedEntries.push([name, value]); + continue; + } + + cleanedCount += 1; + if (mode === "mask") { + const tokenLength = Math.max(8, Math.min(32, value.length || 16)); + cleanedEntries.push([name, randomToken(tokenLength)]); + } + } + + if (cleanedCount === 0) return null; + const query = new URLSearchParams(cleanedEntries).toString(); + url.search = query ? `?${query}` : ""; + return { url: url.toString(), count: cleanedCount }; + } + + globalThis.LuaTrackingProtection = Object.freeze({ + cleanURL, + isTrackingParameter + }); +})(); diff --git a/Sources/Application/AppController+Menu.swift b/Sources/Application/AppController+Menu.swift index f75e5c31..27782272 100644 --- a/Sources/Application/AppController+Menu.swift +++ b/Sources/Application/AppController+Menu.swift @@ -16,10 +16,10 @@ extension AppController { static let timeMachineBackupsParentItemTag = 500014 static let toggleBookmarkBarItemTag = 500003 static let toggleBookmarkBarOnNewTabItemTag = 500004 - static let layoutModeDefaultItemTag = 500005 - static let layoutModeNavigationAtTopItemTag = 500006 - static let layoutModeTraditionalItemTag = 500007 - static let layoutModeTitleItemTag = 500008 + static let sidebarPositionLeftItemTag = 500005 + static let sidebarPositionRightItemTag = 500006 + static let removedLayoutModeItemTag = 500007 + static let sidebarPositionTitleItemTag = 500008 static let whatsNewItemTag = 500009 static let bookmarksMenuItemTag = 500010 static let bookmarksMenuIdentifier = NSUserInterfaceItemIdentifier("phi.bookmarks.menu") @@ -77,10 +77,10 @@ extension AppController { item.tag == AppController.viewMenuPhiSectionSeparatorTag || item.tag == AppController.toggleBookmarkBarItemTag || item.tag == AppController.toggleBookmarkBarOnNewTabItemTag || - item.tag == AppController.layoutModeDefaultItemTag || - item.tag == AppController.layoutModeNavigationAtTopItemTag || - item.tag == AppController.layoutModeTraditionalItemTag || - item.tag == AppController.layoutModeTitleItemTag || + item.tag == AppController.sidebarPositionLeftItemTag || + item.tag == AppController.sidebarPositionRightItemTag || + item.tag == AppController.removedLayoutModeItemTag || + item.tag == AppController.sidebarPositionTitleItemTag || item.tag == AppController.agentAutoViewItemTag } @@ -90,47 +90,20 @@ extension AppController { submenu.addItem(topSeparator) } - let layoutTtitle = NSMenuItem.sectionHeader(title: NSLocalizedString("Layout Mode", comment: "View menu - Layout mode section header in View menu")) - layoutTtitle.tag = AppController.layoutModeTitleItemTag - submenu.addItem(layoutTtitle) - - let navigationAtTopItem = NSMenuItem(title: LayoutMode.balanced.displayName, - action: #selector(selectLayoutMode(_:)), - keyEquivalent: "") - navigationAtTopItem.tag = AppController.layoutModeNavigationAtTopItemTag - navigationAtTopItem.target = self - submenu.addItem(navigationAtTopItem) - - let defaultLayoutItem = NSMenuItem(title: LayoutMode.performance.displayName, - action: #selector(selectLayoutMode(_:)), - keyEquivalent: "") - defaultLayoutItem.tag = AppController.layoutModeDefaultItemTag - defaultLayoutItem.target = self - submenu.addItem(defaultLayoutItem) - - let traditionalLayoutItem = NSMenuItem(title: LayoutMode.comfortable.displayName, - action: #selector(selectLayoutMode(_:)), - keyEquivalent: "") - traditionalLayoutItem.tag = AppController.layoutModeTraditionalItemTag - traditionalLayoutItem.target = self - submenu.addItem(traditionalLayoutItem) - - let bookmarkBarSeparator = NSMenuItem.separator() - bookmarkBarSeparator.tag = AppController.viewMenuPhiSectionSeparatorTag - submenu.addItem(bookmarkBarSeparator) - let toggleBookmarkBarItem = NSMenuItem(title: NSLocalizedString("Always Show Bookmark Bar", comment: "View menu - Menu item to always show the bookmark bar"), - action: #selector(toggleBookmarkBar(_:)), - keyEquivalent: "b") - toggleBookmarkBarItem.keyEquivalentModifierMask = [.command, .shift] - toggleBookmarkBarItem.tag = AppController.toggleBookmarkBarItemTag - toggleBookmarkBarItem.target = self - submenu.addItem(toggleBookmarkBarItem) - let toggleBookmarkBarOnNewTabItem = NSMenuItem(title: NSLocalizedString("Show Bookmark Bar on New Tab", comment: "View menu - Menu item to show the bookmark bar on new tab pages"), - action: #selector(toggleBookmarkBarOnNewTab(_:)), - keyEquivalent: "") - toggleBookmarkBarOnNewTabItem.tag = AppController.toggleBookmarkBarOnNewTabItemTag - toggleBookmarkBarOnNewTabItem.target = self - submenu.addItem(toggleBookmarkBarOnNewTabItem) + let sidebarTitle = NSMenuItem.sectionHeader(title: NSLocalizedString("Sidebar Position", comment: "View menu - Sidebar position section header")) + sidebarTitle.tag = AppController.sidebarPositionTitleItemTag + submenu.addItem(sidebarTitle) + + for position in SidebarPosition.allCases { + let item = NSMenuItem(title: position.displayName, + action: #selector(selectSidebarPosition(_:)), + keyEquivalent: "") + item.tag = position == .left + ? AppController.sidebarPositionLeftItemTag + : AppController.sidebarPositionRightItemTag + item.target = self + submenu.addItem(item) + } let sidebarSeparator = NSMenuItem.separator() sidebarSeparator.tag = AppController.viewMenuPhiSectionSeparatorTag @@ -675,16 +648,14 @@ extension AppController { } } - @objc func selectLayoutMode(_ sender: Any?) { + @objc func selectSidebarPosition(_ sender: Any?) { guard let menuItem = sender as? NSMenuItem else { return } switch menuItem.tag { - case AppController.layoutModeDefaultItemTag: - PhiPreferences.GeneralSettings.saveLayoutMode(.performance) - case AppController.layoutModeNavigationAtTopItemTag: - PhiPreferences.GeneralSettings.saveLayoutMode(.balanced) - case AppController.layoutModeTraditionalItemTag: - PhiPreferences.GeneralSettings.saveLayoutMode(.comfortable) + case AppController.sidebarPositionLeftItemTag: + PhiPreferences.GeneralSettings.saveSidebarPosition(.left) + case AppController.sidebarPositionRightItemTag: + PhiPreferences.GeneralSettings.saveSidebarPosition(.right) default: break } @@ -1652,13 +1623,6 @@ extension AppController { if itemIsInAgentLockedMenu(menuItem) { return false } } - // Toggle Sidebar is unavailable in the traditional layout. - if item.action == #selector(toggleSidebar(_:)) { - if PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional { - return false - } - } - if item.action == #selector(showPreferences(_:)) { return true } @@ -1674,17 +1638,15 @@ extension AppController { } } - if item.action == #selector(selectLayoutMode(_:)) { + if item.action == #selector(selectSidebarPosition(_:)) { if let menuItem = item as? NSMenuItem { - let layoutMode = PhiPreferences.GeneralSettings.loadLayoutMode() + let position = PhiPreferences.GeneralSettings.loadSidebarPosition() switch menuItem.tag { - case AppController.layoutModeDefaultItemTag: - menuItem.state = (layoutMode == .performance) ? .on : .off - case AppController.layoutModeNavigationAtTopItemTag: - menuItem.state = (layoutMode == .balanced) ? .on : .off - case AppController.layoutModeTraditionalItemTag: - menuItem.state = (layoutMode == .comfortable) ? .on : .off + case AppController.sidebarPositionLeftItemTag: + menuItem.state = (position == .left) ? .on : .off + case AppController.sidebarPositionRightItemTag: + menuItem.state = (position == .right) ? .on : .off default: break } diff --git a/Sources/ChromiumBridge/ChromiumLauncher.m b/Sources/ChromiumBridge/ChromiumLauncher.m index 21d42107..98fa0b17 100644 --- a/Sources/ChromiumBridge/ChromiumLauncher.m +++ b/Sources/ChromiumBridge/ChromiumLauncher.m @@ -125,6 +125,22 @@ - (BOOL)initializeChromiumWithLaunchArgc:(int)launchArgc launchArgv:(const char // privacy boundary until Lua can rebuild the framework. [arguments addObject:@"--disable-crash-reporter"]; + // Lua owns this unpacked MV3 extension and ships it inside the + // signed app bundle. Loading it at Chromium startup makes URL + // tracking protection available to every profile without a + // Web Store install or an external service. + NSString *trackingProtectionPath = [mainBundle pathForResource:@"TrackingProtection" + ofType:nil]; + NSString *trackingProtectionManifest = [trackingProtectionPath + stringByAppendingPathComponent:@"manifest.json"]; + if (trackingProtectionPath != nil && + [[NSFileManager defaultManager] fileExistsAtPath:trackingProtectionManifest]) { + [arguments addObject:[NSString stringWithFormat:@"--load-extension=%@", + trackingProtectionPath]]; + } else { + AppLogError(@"Lua Tracking Protection is missing from the app bundle"); + } + #if DEBUG || NIGHTLY_BUILD [arguments addObject:@"--phi-ai-debug"]; [arguments addObject:@"--phi-no-embed-extensions"]; diff --git a/Sources/UserInterface/AddressBar/OmniBoxContainerViewController.swift b/Sources/UserInterface/AddressBar/OmniBoxContainerViewController.swift index 2a60a5c4..a912d2e8 100644 --- a/Sources/UserInterface/AddressBar/OmniBoxContainerViewController.swift +++ b/Sources/UserInterface/AddressBar/OmniBoxContainerViewController.swift @@ -549,12 +549,14 @@ final class OmniBoxContainerViewController: NSViewController { let actualWidth = min(contentSize.width, parentBounds.width) let sidebarWidth = browserState?.sidebarWidth ?? 0 - let rightAreaWidth = parentBounds.width - sidebarWidth + let contentAreaWidth = parentBounds.width - sidebarWidth let x: CGFloat - if sidebarWidth == 0 || rightAreaWidth < actualWidth { + if sidebarWidth == 0 || contentAreaWidth < actualWidth { x = max((parentBounds.width - actualWidth) / 2, 0) + } else if PhiPreferences.GeneralSettings.loadSidebarPosition() == .left { + x = max(sidebarWidth + (contentAreaWidth - actualWidth) / 2, 0) } else { - x = max(sidebarWidth + (rightAreaWidth - actualWidth) / 2, 0) + x = max((contentAreaWidth - actualWidth) / 2, 0) } let anchoredTop = (parentBounds.height + anchoredHeight) / 2 diff --git a/Sources/UserInterface/Common/TabAreaContextMenuHelper.swift b/Sources/UserInterface/Common/TabAreaContextMenuHelper.swift index 82241b8c..90b04d94 100644 --- a/Sources/UserInterface/Common/TabAreaContextMenuHelper.swift +++ b/Sources/UserInterface/Common/TabAreaContextMenuHelper.swift @@ -55,16 +55,6 @@ final class TabAreaContextMenuHelper: NSObject { ).configured { $0.keyEquivalentModifierMask = [.command, .shift]; $0.target = self }) } - menu.addItem(.separator()) - - let layoutItem = NSMenuItem( - title: NSLocalizedString("Layout Mode", comment: "Tab area context menu - Switch layout mode"), - action: nil, - keyEquivalent: "" - ) - layoutItem.submenu = buildLayoutSubmenu() - menu.addItem(layoutItem) - // Active-Space controls, mirroring the Spaces menu / Space right-click. // Only surface them while the Spaces feature is enabled and the window // participates in Spaces — standalone incognito windows expose none; @@ -119,30 +109,6 @@ final class TabAreaContextMenuHelper: NSObject { } } - @objc private func switchLayoutMode(_ sender: NSMenuItem) { - guard let rawValue = sender.representedObject as? String, - let mode = LayoutMode(rawValue: rawValue) else { return } - PhiPreferences.GeneralSettings.saveLayoutMode(mode) - } - - // MARK: - Private - - private func buildLayoutSubmenu() -> NSMenu { - let submenu = NSMenu() - let currentMode = browserState?.layoutMode - for mode in LayoutMode.allCases { - let item = NSMenuItem( - title: mode.displayName, - action: #selector(switchLayoutMode(_:)), - keyEquivalent: "" - ) - item.representedObject = mode.rawValue - item.target = self - item.state = (mode == currentMode) ? .on : .off - submenu.addItem(item) - } - return submenu - } } private extension NSMenuItem { diff --git a/Sources/UserInterface/MainBrowserWindow/MainBrowserWindowController.swift b/Sources/UserInterface/MainBrowserWindow/MainBrowserWindowController.swift index af93d179..5388aa77 100644 --- a/Sources/UserInterface/MainBrowserWindow/MainBrowserWindowController.swift +++ b/Sources/UserInterface/MainBrowserWindow/MainBrowserWindowController.swift @@ -228,7 +228,11 @@ class MainBrowserWindowController: NSWindowController { .sink { [weak self] collapsed, fullScreen, _ in guard let self, let window = self.window else { return } let traditionalLayout = PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional - let hideTrafficLights = !fullScreen && collapsed && !traditionalLayout + let sidebarPosition = PhiPreferences.GeneralSettings.loadSidebarPosition() + let hideTrafficLights = !fullScreen + && collapsed + && !traditionalLayout + && sidebarPosition == .left window.standardWindowButton(.closeButton)?.isHidden = hideTrafficLights window.standardWindowButton(.miniaturizeButton)?.isHidden = hideTrafficLights diff --git a/Sources/UserInterface/MainBrowserWindow/MainSplitViewController.swift b/Sources/UserInterface/MainBrowserWindow/MainSplitViewController.swift index 46bdacb1..fb9e186d 100644 --- a/Sources/UserInterface/MainBrowserWindow/MainSplitViewController.swift +++ b/Sources/UserInterface/MainBrowserWindow/MainSplitViewController.swift @@ -76,7 +76,7 @@ class MainSplitViewController: NSViewController { private var webContentSplitViewItem: NSSplitViewItem! private lazy var cancellables = Set() - private var lastUseHorizontalTabs: Bool? + private var lastSidebarPosition: SidebarPosition? let state: BrowserState init(state: BrowserState) { @@ -104,7 +104,7 @@ class MainSplitViewController: NSViewController { setupTitlebarAwareLayout() DispatchQueue.main.async { [weak self] in - self?.splitViewController.splitView.autosaveName = "phiMainBrowserSplitView" + self?.updateSplitViewAutosaveName() } } @@ -115,13 +115,6 @@ class MainSplitViewController: NSViewController { state.$sidebarCollapsed .sink { [weak self] collapsed in guard let self else { return } - // Ignore sidebar expansion updates while traditional layout is active. - if PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional { - if !self.sideBarSplitViewItem.isCollapsed { - self.sideBarSplitViewItem.animator().isCollapsed = true - } - return - } if self.sideBarSplitViewItem.isCollapsed != collapsed { self.toggleSidebar(nil) } @@ -132,15 +125,6 @@ class MainSplitViewController: NSViewController { .sink { [weak self] isCollapsed in guard let self else { return } self.updateSidebarWidth() - // Traditional layout must keep the sidebar collapsed even if split view state - // restoration or other external changes try to expand it. - if PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional { - if !isCollapsed { - self.sideBarSplitViewItem.isCollapsed = true - } - self.state.sidebarCollapsed = true - return - } self.state.toggleSidebar(isCollapsed) } .store(in: &cancellables) @@ -153,14 +137,14 @@ class MainSplitViewController: NSViewController { } .store(in: &cancellables) - // Rebuild layout when the layout preference changes. + // Reorder the shell when the independent sidebar-position preference changes. NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) .sink { [weak self] _ in - self?.updateLayoutForHorizontalTabs() + self?.updateShellPreferences() } .store(in: &cancellables) - updateLayoutForHorizontalTabs() + updateShellPreferences() } override func viewDidAppear() { @@ -182,8 +166,6 @@ class MainSplitViewController: NSViewController { } func toggleSidebar(_ sender: Any?) { - // Sidebar is always collapsed in traditional layout. - guard !PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional else { return } sideBarSplitViewItem.animator().isCollapsed.toggle() } @@ -207,17 +189,12 @@ class MainSplitViewController: NSViewController { /// current divider position untouched (useful when only the collapsed /// state needs to change). func syncSidebar(width: CGFloat?, collapsed: Bool) { - if PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional { - // Traditional layout pins the sidebar collapsed regardless of the - // source window's state; don't fight that here. - return - } if sideBarSplitViewItem.isCollapsed != collapsed { sideBarSplitViewItem.isCollapsed = collapsed } guard !collapsed, let width, width > 0 else { return } let clamped = min(max(width, Self.leftItemMinWidth), Self.leftItemMaxWidth) - splitViewController.splitView.setPosition(clamped, ofDividerAt: 0) + setSidebarWidth(clamped) } func toggleAIChat(_ sender: Any?) { @@ -252,44 +229,81 @@ class MainSplitViewController: NSViewController { } private func setupSplitViewItems() { - setupLeftSplitViewItem() + setupSidebarSplitViewItem() setupWebContentSplitViewItem() + + if PhiPreferences.GeneralSettings.loadSidebarPosition() == .left { + splitViewController.addSplitViewItem(sideBarSplitViewItem) + splitViewController.addSplitViewItem(webContentSplitViewItem) + } else { + splitViewController.addSplitViewItem(webContentSplitViewItem) + splitViewController.addSplitViewItem(sideBarSplitViewItem) + } } - private func setupLeftSplitViewItem() { + private func setupSidebarSplitViewItem() { sideBarSplitViewItem = NSSplitViewItem(viewController: sidebarPanelContainerViewController) sideBarSplitViewItem.minimumThickness = Self.leftItemMinWidth sideBarSplitViewItem.maximumThickness = Self.leftItemMaxWidth sideBarSplitViewItem.canCollapse = true sideBarSplitViewItem.holdingPriority = .init(rawValue: 260) - - if PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional { - sideBarSplitViewItem.isCollapsed = true - } - - splitViewController.addSplitViewItem(sideBarSplitViewItem) } private func setupWebContentSplitViewItem() { webContentSplitViewItem = NSSplitViewItem(contentListWithViewController: webContentContainerViewController) webContentSplitViewItem.holdingPriority = .init(rawValue: 240) - splitViewController.addSplitViewItem(webContentSplitViewItem) } - /// Updates the split-view layout based on the current tab-bar mode. - private func updateLayoutForHorizontalTabs() { - let traditionalLayout = PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional - if lastUseHorizontalTabs != nil && traditionalLayout == lastUseHorizontalTabs { + private func updateShellPreferences() { + PhiPreferences.GeneralSettings.saveLayoutMode() + + let position = PhiPreferences.GeneralSettings.loadSidebarPosition() + guard position != lastSidebarPosition else { return } + + let previousPosition = lastSidebarPosition + lastSidebarPosition = position + + guard previousPosition != nil else { + updateSplitViewAutosaveName() + setSidebarCollapsed(state.sidebarCollapsed, animated: false) + return + } + + let sidebarWidth = sideBarSplitViewItem.isCollapsed + ? nil + : sideBarSplitViewItem.viewController.view.frame.width + let wasCollapsed = sideBarSplitViewItem.isCollapsed + let desiredIndex = position == .left ? 0 : 1 + guard splitViewController.splitViewItems.firstIndex(where: { $0 === sideBarSplitViewItem }) != desiredIndex else { + updateSplitViewAutosaveName() return } - lastUseHorizontalTabs = traditionalLayout + splitViewController.removeSplitViewItem(sideBarSplitViewItem) + splitViewController.insertSplitViewItem(sideBarSplitViewItem, at: desiredIndex) + updateSplitViewAutosaveName() + sideBarSplitViewItem.isCollapsed = wasCollapsed + splitViewController.view.layoutSubtreeIfNeeded() + if let sidebarWidth, !wasCollapsed { + setSidebarWidth(sidebarWidth) + } + updateSidebarWidth() + } + + private func updateSplitViewAutosaveName() { + let position = PhiPreferences.GeneralSettings.loadSidebarPosition() + splitViewController.splitView.autosaveName = "luaMainBrowserSplitView-\(position.rawValue)" + } - if traditionalLayout { - setSidebarCollapsed(true, animated: false) + private func setSidebarWidth(_ width: CGFloat) { + let splitView = splitViewController.splitView + let position: CGFloat + if PhiPreferences.GeneralSettings.loadSidebarPosition() == .left { + position = width } else { - setSidebarCollapsed(false, animated: false) + position = max(0, splitView.bounds.width - width - splitView.dividerThickness) } + splitView.setPosition(position, ofDividerAt: 0) } private func setSidebarCollapsed(_ collapsed: Bool, animated: Bool) { @@ -307,8 +321,7 @@ class MainSplitViewController: NSViewController { guard arguments.contains("-uitest"), let widthFlagIndex = arguments.firstIndex(of: "-sidebarHeaderWidth"), arguments.indices.contains(widthFlagIndex + 1), - let requestedWidth = Double(arguments[widthFlagIndex + 1]), - !PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional else { + let requestedWidth = Double(arguments[widthFlagIndex + 1]) else { return } @@ -321,10 +334,6 @@ class MainSplitViewController: NSViewController { } private func applySidebarHeaderWidthOverrideForUITests(width: CGFloat) { - guard !PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional else { - return - } - syncSidebar(width: width, collapsed: false) view.layoutSubtreeIfNeeded() splitViewController.splitView.layoutSubtreeIfNeeded() diff --git a/Sources/UserInterface/Onboarding/LayoutSelection/LayoutSelectionViewController.swift b/Sources/UserInterface/Onboarding/LayoutSelection/LayoutSelectionViewController.swift index b90813fc..097ef224 100644 --- a/Sources/UserInterface/Onboarding/LayoutSelection/LayoutSelectionViewController.swift +++ b/Sources/UserInterface/Onboarding/LayoutSelection/LayoutSelectionViewController.swift @@ -7,7 +7,7 @@ import Cocoa class LayoutSelectionViewController: OnboardingBaseViewController { - private var selectedMode: LayoutMode = .balanced + private var selectedPosition = PhiPreferences.GeneralSettings.loadSidebarPosition() private let optionWidth: CGFloat = 456 private let optionHeight: CGFloat = 116 @@ -34,49 +34,50 @@ class LayoutSelectionViewController: OnboardingBaseViewController { return stackView }() - private lazy var performanceOptionView: LayoutOptionView = { + private lazy var leftSidebarOptionView: LayoutOptionView = { let view = LayoutOptionView( - title: NSLocalizedString("Performance", comment: "Onboarding layout selection - Performance option"), + title: NSLocalizedString("Sidebar on left", comment: "Onboarding sidebar position - Left option"), previewImage: NSImage(resource: .tabLayoutPerformanceOobe), - isSelected: selectedMode == .performance + isSelected: selectedPosition == .left ) view.onTap = { [weak self] in - self?.selectMode(.performance) + self?.selectPosition(.left) } return view }() - private lazy var balancedOptionView: LayoutOptionView = { + private lazy var rightSidebarOptionView: LayoutOptionView = { let view = LayoutOptionView( - title: NSLocalizedString("Balanced", comment: "Onboarding layout selection - Balanced option"), - previewImage: NSImage(resource: .tabLayoutBalancedOobe), - isSelected: selectedMode == .balanced + title: NSLocalizedString("Sidebar on right", comment: "Onboarding sidebar position - Right option"), + previewImage: Self.rightSidebarPreview, + isSelected: selectedPosition == .right ) view.onTap = { [weak self] in - self?.selectMode(.balanced) + self?.selectPosition(.right) } return view }() - private lazy var comfortableOptionView: LayoutOptionView = { - let view = LayoutOptionView( - title: NSLocalizedString("Comfortable", comment: "Onboarding layout selection - Comfortable option"), - previewImage: NSImage(resource: .tabLayoutComfortableOobe), - isSelected: selectedMode == .comfortable - ) - view.onTap = { [weak self] in - self?.selectMode(.comfortable) + private static var rightSidebarPreview: NSImage { + let source = NSImage(resource: .tabLayoutPerformanceOobe) + return NSImage(size: source.size, flipped: false) { rect in + guard let context = NSGraphicsContext.current?.cgContext else { return false } + context.saveGState() + context.translateBy(x: rect.width, y: 0) + context.scaleBy(x: -1, y: 1) + source.draw(in: rect) + context.restoreGState() + return true } - return view - }() + } // MARK: - Lifecycle override func loadView() { super.loadView() titleLabel.stringValue = NSLocalizedString( - "Layout selection", - comment: "Onboarding layout selection - Page title" + "Sidebar position", + comment: "Onboarding sidebar position - Page title" ) skipButton.isHidden = true setupLayoutOptions() @@ -88,7 +89,7 @@ class LayoutSelectionViewController: OnboardingBaseViewController { view.addSubview(optionsContainer) optionsContainer.addSubview(optionsStackView) - let optionViews = [balancedOptionView, performanceOptionView, comfortableOptionView] + let optionViews = [leftSidebarOptionView, rightSidebarOptionView] for optionView in optionViews { optionsStackView.addArrangedSubview(optionView) optionView.snp.makeConstraints { make in @@ -108,17 +109,17 @@ class LayoutSelectionViewController: OnboardingBaseViewController { } } - private func selectMode(_ mode: LayoutMode) { - selectedMode = mode - performanceOptionView.setSelected(mode == .performance) - balancedOptionView.setSelected(mode == .balanced) - comfortableOptionView.setSelected(mode == .comfortable) + private func selectPosition(_ position: SidebarPosition) { + selectedPosition = position + leftSidebarOptionView.setSelected(position == .left) + rightSidebarOptionView.setSelected(position == .right) } // MARK: - Actions override func nextButtonTapped(_ sender: NSButton? = nil) { - PhiPreferences.GeneralSettings.saveLayoutMode(selectedMode) + PhiPreferences.GeneralSettings.saveLayoutMode() + PhiPreferences.GeneralSettings.saveSidebarPosition(selectedPosition) nextClosure?(true) } } diff --git a/Sources/UserInterface/Preferences/General/GeneralSettingView.swift b/Sources/UserInterface/Preferences/General/GeneralSettingView.swift index 4b29ef32..77297344 100644 --- a/Sources/UserInterface/Preferences/General/GeneralSettingView.swift +++ b/Sources/UserInterface/Preferences/General/GeneralSettingView.swift @@ -111,17 +111,20 @@ private struct ThemeSectionView: View { } private struct AppearanceSectionView: View { - @AppStorage(PhiPreferences.GeneralSettings.layoutModeKey) - private var layoutModeRawValue: String = PhiPreferences.GeneralSettings.loadLayoutMode().rawValue + @AppStorage(PhiPreferences.GeneralSettings.sidebarPositionKey) + private var sidebarPositionRawValue: String = PhiPreferences.GeneralSettings.loadSidebarPosition().rawValue @State private var selectedAppearance: UserAppearanceChoice = ThemeManager.shared.userAppearanceChoice - private var selectedLayoutMode: Binding { + private var selectedSidebarPosition: Binding { Binding( - get: { LayoutMode(rawValue: layoutModeRawValue) ?? PhiPreferences.GeneralSettings.loadLayoutMode() }, - set: { mode in - layoutModeRawValue = mode.rawValue - PhiPreferences.GeneralSettings.saveLayoutMode(mode) + get: { + SidebarPosition(rawValue: sidebarPositionRawValue) + ?? PhiPreferences.GeneralSettings.loadSidebarPosition() + }, + set: { position in + sidebarPositionRawValue = position.rawValue + PhiPreferences.GeneralSettings.saveSidebarPosition(position) } ) } @@ -129,17 +132,23 @@ private struct AppearanceSectionView: View { var body: some View { GeneralSectionView(title: NSLocalizedString("Appearance", comment: "General settings - Appearance section title")) { GeneralContainerView { - GeneralRowView(title: NSLocalizedString("Layout mode", comment: "General settings - Layout mode row title"), alignment: .top) { - HStack(spacing: 16) { - ForEach(LayoutMode.allCases) { mode in - GeneralSttingCardView( - image: Image(layoutImageResource(for: mode)), - action: { selectedLayoutMode.wrappedValue = mode }, - selected: selectedLayoutMode.wrappedValue == mode, - title: mode.displayName - ) + GeneralRowView(title: NSLocalizedString("Layout", comment: "General settings - Fixed layout row title")) { + Text(LayoutMode.performance.displayName) + .font(.system(size: 13, weight: .medium)) + .themedForeground(.textPrimary) + } + + Divider() + + GeneralRowView(title: NSLocalizedString("Sidebar position", comment: "General settings - Sidebar position row title")) { + Picker("", selection: selectedSidebarPosition) { + ForEach(SidebarPosition.allCases) { position in + Text(position.displayName).tag(position) } } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 160) } Divider() @@ -163,17 +172,6 @@ private struct AppearanceSectionView: View { } } - private func layoutImageResource(for mode: LayoutMode) -> ImageResource { - switch mode { - case .performance: - return .tabLayoutPerformance - case .balanced: - return .tabLayoutBalanced - case .comfortable: - return .tabLayoutComfortable - } - } - private func appearanceImageName(for choice: UserAppearanceChoice) -> String { switch choice { case .system: diff --git a/Sources/UserInterface/Preferences/General/LayoutModeSettingView.swift b/Sources/UserInterface/Preferences/General/LayoutModeSettingView.swift index 05f54081..bc9e1d7e 100644 --- a/Sources/UserInterface/Preferences/General/LayoutModeSettingView.swift +++ b/Sources/UserInterface/Preferences/General/LayoutModeSettingView.swift @@ -6,53 +6,48 @@ import SwiftUI struct LayoutModeSettingView: View { - @AppStorage(PhiPreferences.GeneralSettings.layoutModeKey) - private var layoutModeRawValue: String = PhiPreferences.GeneralSettings.loadLayoutMode().rawValue + @AppStorage(PhiPreferences.GeneralSettings.sidebarPositionKey) + private var sidebarPositionRawValue: String = PhiPreferences.GeneralSettings.loadSidebarPosition().rawValue - private var selectedLayoutMode: Binding { + private var selectedSidebarPosition: Binding { Binding( get: { - LayoutMode(rawValue: layoutModeRawValue) ?? PhiPreferences.GeneralSettings.loadLayoutMode() + SidebarPosition(rawValue: sidebarPositionRawValue) + ?? PhiPreferences.GeneralSettings.loadSidebarPosition() }, - set: { mode in - layoutModeRawValue = mode.rawValue - PhiPreferences.GeneralSettings.saveLayoutMode(mode) + set: { position in + sidebarPositionRawValue = position.rawValue + PhiPreferences.GeneralSettings.saveSidebarPosition(position) } ) } var body: some View { VStack(alignment: .leading, spacing: 0) { - Text(NSLocalizedString("Layout Mode", comment: "General settings - Section title for layout configuration")) + Text(NSLocalizedString("Layout", comment: "General settings - Section title for layout configuration")) .font(.system(size: 12)) .foregroundStyle(Color.secondary) .padding(.bottom, 12) - HStack(alignment: .top, spacing: 16) { - ForEach(LayoutMode.allCases) { mode in - GeneralSttingCardView( - image: Image(layoutImageResource(for: mode)), - action: { selectedLayoutMode.wrappedValue = mode }, - selected: selectedLayoutMode.wrappedValue == mode, - title: mode.displayName - ) + HStack(spacing: 16) { + Text(LayoutMode.performance.displayName) + .font(.system(size: 13, weight: .medium)) + + Spacer() + + Picker("", selection: selectedSidebarPosition) { + ForEach(SidebarPosition.allCases) { position in + Text(position.displayName).tag(position) + } } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 160) } .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) } - - private func layoutImageResource(for mode: LayoutMode) -> ImageResource { - switch mode { - case .performance: - return .tabLayoutPerformance - case .balanced: - return .tabLayoutBalanced - case .comfortable: - return .tabLayoutComfortable - } - } } struct GeneralSttingCardView: View { diff --git a/Sources/UserInterface/Preferences/PhiPreferences.swift b/Sources/UserInterface/Preferences/PhiPreferences.swift index 01546b15..70943082 100644 --- a/Sources/UserInterface/Preferences/PhiPreferences.swift +++ b/Sources/UserInterface/Preferences/PhiPreferences.swift @@ -15,9 +15,14 @@ extension UserDefaults { } enum LayoutMode: String, CaseIterable, Identifiable { - case balanced // vertical tabs + address bar at the top of webcontent - case performance // vertical tabs - case comfortable // horizontal tabs + // Performance is Lua's only supported layout. The other raw values remain + // decodeable while existing profiles migrate, so older stored preferences + // never fail to load midway through an app update. + case balanced + case performance + case comfortable + + static let allCases: [LayoutMode] = [.performance] var id: String { rawValue } @@ -36,6 +41,22 @@ enum LayoutMode: String, CaseIterable, Identifiable { var showsNavigationAtTop: Bool { self != .performance } } +enum SidebarPosition: String, CaseIterable, Identifiable { + case left + case right + + var id: String { rawValue } + + var displayName: String { + switch self { + case .left: + return NSLocalizedString("Left", comment: "Sidebar position - Left side") + case .right: + return NSLocalizedString("Right", comment: "Sidebar position - Right side") + } + } +} + enum PhiPreferences: String { case phiMainDebugMenuEnabled case phiLoginPhase @@ -85,31 +106,35 @@ extension PhiPreferences { } static let layoutModeKey = "layoutMode" + static let sidebarPositionKey = "sidebarPosition" static func loadLayoutMode() -> LayoutMode { let defaults = UserDefaults.standard - if let rawValue = defaults.string(forKey: Self.layoutModeKey), - let mode = LayoutMode(rawValue: rawValue) { - return mode + // Lua deliberately ships one layout. Normalize old Phi choices at + // the preference boundary so every consumer observes the same + // stable state instead of carrying compatibility branches into UI. + if defaults.string(forKey: Self.layoutModeKey) != LayoutMode.performance.rawValue { + defaults.set(LayoutMode.performance.rawValue, forKey: Self.layoutModeKey) } + return .performance + } - // Backward compatibility for old dual-bool encoding. - let traditionalLayout = UserDefaults.standard.value(forKey: Self.traditionalLayout.rawValue) as? Bool - let navigationAtTop = UserDefaults.standard.value(forKey: Self.navigationAtTop.rawValue) as? Bool - if traditionalLayout == true { - return .comfortable - } else if navigationAtTop == true { - return .balanced - } else { - // default value - return .performance + static func saveLayoutMode(_: LayoutMode = .performance) { + let defaults = UserDefaults.standard + defaults.set(LayoutMode.performance.rawValue, forKey: Self.layoutModeKey) + } + + static func loadSidebarPosition() -> SidebarPosition { + guard let rawValue = UserDefaults.standard.string(forKey: Self.sidebarPositionKey), + let position = SidebarPosition(rawValue: rawValue) else { + return .left } + return position } - static func saveLayoutMode(_ mode: LayoutMode) { - let defaults = UserDefaults.standard - defaults.set(mode.rawValue, forKey: Self.layoutModeKey) + static func saveSidebarPosition(_ position: SidebarPosition) { + UserDefaults.standard.set(position.rawValue, forKey: Self.sidebarPositionKey) } /// Duration of the cross-Space swap animation, in seconds. Drives the diff --git a/Sources/UserInterface/Sidebar/Header/FloatingTrafficLightsView.swift b/Sources/UserInterface/Sidebar/Header/FloatingTrafficLightsView.swift index 1afd3321..ab2e4dfc 100644 --- a/Sources/UserInterface/Sidebar/Header/FloatingTrafficLightsView.swift +++ b/Sources/UserInterface/Sidebar/Header/FloatingTrafficLightsView.swift @@ -218,7 +218,8 @@ final class FloatingTrafficLightsView: NSView { private var shouldEnableWindowButtonActions: Bool { guard browserState?.sidebarCollapsed == true, - browserState?.isInFullScreenMode != true else { + browserState?.isInFullScreenMode != true, + PhiPreferences.GeneralSettings.loadSidebarPosition() == .left else { return false } return !PhiPreferences.GeneralSettings.loadLayoutMode().isTraditional diff --git a/Sources/UserInterface/Sidebar/Header/SidebarHeaderView.swift b/Sources/UserInterface/Sidebar/Header/SidebarHeaderView.swift index b44aef29..cb3d034e 100644 --- a/Sources/UserInterface/Sidebar/Header/SidebarHeaderView.swift +++ b/Sources/UserInterface/Sidebar/Header/SidebarHeaderView.swift @@ -414,10 +414,26 @@ class SidebarHeaderView: NSView, TitlebarAwareHitTestable { } } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .map { _ in PhiPreferences.GeneralSettings.loadSidebarPosition() } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + self.updateLayoutVisibility(layoutMode: .performance) + self.updateSidebarButtonLeftConstraint() + } + .store(in: &cancellables) } /// Returns the maxX of the leading chrome controls in this header. private func leadingChromeMaxXRelativeToSelf() -> CGFloat? { + // A right-hand sidebar owns no macOS traffic-light chrome. Those + // controls stay at the window's top-left over web content. + if PhiPreferences.GeneralSettings.loadSidebarPosition() == .right { + return 0 + } if isFloating { if browserState?.isInFullScreenMode == true { return 0 diff --git a/Sources/UserInterface/WebContent/Header/WebContentHeaderView.swift b/Sources/UserInterface/WebContent/Header/WebContentHeaderView.swift index 50ae7139..e98b5639 100644 --- a/Sources/UserInterface/WebContent/Header/WebContentHeaderView.swift +++ b/Sources/UserInterface/WebContent/Header/WebContentHeaderView.swift @@ -28,6 +28,8 @@ struct WebContentHeaderView: View { @State private var extensionsModel: WebContentHeaderExtensionsModel @State private var isExtensionPopoverShown = false @State private var totalHeaderWidth: CGFloat = 10000 + @AppStorage(PhiPreferences.GeneralSettings.sidebarPositionKey) + private var sidebarPositionRawValue = PhiPreferences.GeneralSettings.loadSidebarPosition().rawValue init( state: WebContentHeaderState, @@ -147,7 +149,9 @@ struct WebContentHeaderView: View { HStack(spacing: 8) { if state.showSidebarButton { NavigationButton( - systemName: "sidebar.left", + systemName: sidebarPositionRawValue == SidebarPosition.right.rawValue + ? "sidebar.right" + : "sidebar.left", accessibilityLabel: NSLocalizedString("Toggle Sidebar", comment: "Web content header - Accessibility description for sidebar toggle button"), onAnchorResolved: onSidebarAnchorResolved, action: onSidebarTap diff --git a/Sources/UserInterface/WebContent/WebContentContainerViewController+FloatingSidebar.swift b/Sources/UserInterface/WebContent/WebContentContainerViewController+FloatingSidebar.swift index d85ffe32..82a7dbbb 100644 --- a/Sources/UserInterface/WebContent/WebContentContainerViewController+FloatingSidebar.swift +++ b/Sources/UserInterface/WebContent/WebContentContainerViewController+FloatingSidebar.swift @@ -24,8 +24,9 @@ extension WebContentContainerViewController { return Self.floatingSidebarDefaultWidth } - var floatingSidebarHiddenLeading: CGFloat { - -(currentFloatingWidth + Self.floatingSidebarInset) + var floatingSidebarHiddenOffset: CGFloat { + let distance = currentFloatingWidth + Self.floatingSidebarInset + return PhiPreferences.GeneralSettings.loadSidebarPosition() == .left ? -distance : distance } func setupFloatingSidebarTrigger() { @@ -33,7 +34,13 @@ extension WebContentContainerViewController { guard let self else { return } isPointerInsideFloatingSidebarTrigger = true let enterPoint = floatingSidebarTriggerView.convert(event.locationInWindow, from: nil) - floatingSidebarShownFromRightToLeft = enterPoint.x >= (Self.floatingSidebarTriggerWidth * 0.5) + let enteredFromContent: Bool + if PhiPreferences.GeneralSettings.loadSidebarPosition() == .left { + enteredFromContent = enterPoint.x >= (Self.floatingSidebarTriggerWidth * 0.5) + } else { + enteredFromContent = enterPoint.x <= (Self.floatingSidebarTriggerWidth * 0.5) + } + floatingSidebarShownFromContentSide = enteredFromContent showFloatingSidebar() } @@ -60,8 +67,8 @@ extension WebContentContainerViewController { interactionContainerView.onMouseEntered = { [weak self] _ in guard let self else { return } refreshFloatingSidebarPointerState() - if !isPointerInsideFloatingSidebarTrigger && floatingSidebarShownFromRightToLeft { - floatingSidebarShownFromRightToLeft = false + if !isPointerInsideFloatingSidebarTrigger && floatingSidebarShownFromContentSide { + floatingSidebarShownFromContentSide = false } if isPointerInsideFloatingSidebar { cancelFloatingSidebarHide() @@ -91,10 +98,6 @@ extension WebContentContainerViewController { let panelVisualContainer = panelContentView interactionContainerView.addSubview(panelVisualContainer) - panelVisualContainer.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(Self.floatingSidebarInset) - make.top.bottom.trailing.equalToSuperview() - } // Sit above `outerBorderLayer` (zPosition = contentOuterBorder) so the // unified content-border stroke doesn't paint on top of the panel @@ -103,19 +106,59 @@ extension WebContentContainerViewController { interactionContainerView.layer?.zPosition = WebContentContainerViewController.LayerZIndex.floatingSidebar view.addSubview(interactionContainerView, positioned: .above, relativeTo: nil) - interactionContainerView.snp.makeConstraints { make in - floatingSidebarLeadingConstraint = make.leading.equalToSuperview().offset(floatingSidebarHiddenLeading).constraint - make.top.equalToSuperview().offset(Self.floatingSidebarInset) - make.bottom.equalToSuperview().offset(-Self.floatingSidebarInset) - floatingSidebarWidthConstraint = make.width.equalTo(currentFloatingWidth + Self.floatingSidebarInset).constraint - } + floatingSidebarPanelContentView = panelVisualContainer + floatingSidebarContainerView = interactionContainerView + updateFloatingSidebarPlacement() view.layoutSubtreeIfNeeded() interactionContainerView.isHidden = true interactionContainerView.alphaValue = 1 floatingSidebarViewController = floatingSidebarVC floatingSidebarVC.setContentActive(shouldEnableFloatingSidebar()) - floatingSidebarContainerView = interactionContainerView + } + + func updateFloatingSidebarPlacement() { + let position = PhiPreferences.GeneralSettings.loadSidebarPosition() + + floatingSidebarTriggerView.snp.remakeConstraints { make in + if position == .left { + make.leading.equalToSuperview() + } else { + make.trailing.equalToSuperview() + } + make.top.bottom.equalToSuperview() + make.width.equalTo(Self.floatingSidebarTriggerWidth) + } + + guard let panel = floatingSidebarContainerView, + let panelContent = floatingSidebarPanelContentView else { return } + + panelContent.snp.remakeConstraints { make in + make.top.bottom.equalToSuperview() + if position == .left { + make.leading.equalToSuperview().offset(Self.floatingSidebarInset) + make.trailing.equalToSuperview() + } else { + make.leading.equalToSuperview() + make.trailing.equalToSuperview().offset(-Self.floatingSidebarInset) + } + } + + panel.snp.remakeConstraints { make in + let horizontalOffset = panel.isHidden ? floatingSidebarHiddenOffset : 0 + if position == .left { + floatingSidebarHorizontalConstraint = make.leading.equalToSuperview() + .offset(horizontalOffset).constraint + } else { + floatingSidebarHorizontalConstraint = make.trailing.equalToSuperview() + .offset(horizontalOffset).constraint + } + make.top.equalToSuperview().offset(Self.floatingSidebarInset) + make.bottom.equalToSuperview().offset(-Self.floatingSidebarInset) + floatingSidebarWidthConstraint = make.width + .equalTo(currentFloatingWidth + Self.floatingSidebarInset).constraint + } + view.layoutSubtreeIfNeeded() } /// A Space switch driven from this panel orders the window out with the @@ -150,7 +193,7 @@ extension WebContentContainerViewController { floatingSidebarTriggerView.isHidden = true isPointerInsideFloatingSidebar = false isPointerInsideFloatingSidebarTrigger = false - floatingSidebarShownFromRightToLeft = false + floatingSidebarShownFromContentSide = false floatingSidebarViewController?.setContentActive(false) hideFloatingSidebar(animated: false) } else if floatingSidebarTriggerView.isHidden { @@ -175,7 +218,7 @@ extension WebContentContainerViewController { guard panel.isHidden else { return } // Ensure panel starts offscreen before sliding in. - floatingSidebarLeadingConstraint?.update(offset: floatingSidebarHiddenLeading) + floatingSidebarHorizontalConstraint?.update(offset: floatingSidebarHiddenOffset) view.layoutSubtreeIfNeeded() panel.isHidden = false floatingSidebarLastShownAt = Date() @@ -187,7 +230,7 @@ extension WebContentContainerViewController { cancelFloatingSidebarHide() } - floatingSidebarLeadingConstraint?.update(offset: 0) + floatingSidebarHorizontalConstraint?.update(offset: 0) NSAnimationContext.runAnimationGroup { context in context.duration = Self.floatingSidebarShowDuration context.timingFunction = CAMediaTimingFunction(name: .easeOut) @@ -206,7 +249,7 @@ extension WebContentContainerViewController { // Pointer-driven hides are already blocked while the form is up // (see scheduleFloatingSidebarHide), so this only fires on forced paths. floatingSidebarViewController?.dismissCreateSpaceOverlay() - floatingSidebarLeadingConstraint?.update(offset: floatingSidebarHiddenLeading) + floatingSidebarHorizontalConstraint?.update(offset: floatingSidebarHiddenOffset) if animated { NSAnimationContext.runAnimationGroup { context in @@ -217,13 +260,13 @@ extension WebContentContainerViewController { } completionHandler: { panel.isHidden = true self.floatingSidebarLastShownAt = nil - self.floatingSidebarShownFromRightToLeft = false + self.floatingSidebarShownFromContentSide = false } } else { view.layoutSubtreeIfNeeded() panel.isHidden = true floatingSidebarLastShownAt = nil - floatingSidebarShownFromRightToLeft = false + floatingSidebarShownFromContentSide = false } } @@ -239,7 +282,7 @@ extension WebContentContainerViewController { refreshFloatingSidebarPointerState() guard isPointerInsideFloatingSidebar == false else { return } guard isPointerInsideFloatingSidebarTrigger == false else { return } - if floatingSidebarShownFromRightToLeft, isMouseAtFloatingSidebarLeftSide() { + if floatingSidebarShownFromContentSide, isMouseBeyondFloatingSidebarOuterEdge() { return } hideFloatingSidebar(animated: true) @@ -281,27 +324,26 @@ extension WebContentContainerViewController { return targetView.bounds.contains(locationInView) } - func isMouseAtFloatingSidebarLeftSide() -> Bool { + func isMouseBeyondFloatingSidebarOuterEdge() -> Bool { guard let panel = floatingSidebarContainerView, panel.isHidden == false else { return false } guard let window = panel.window else { return false } let mouseLocationInWindow = window.mouseLocationOutsideOfEventStream - let panelFrameInWindow = panel.convert(panel.bounds, to: nil) - - let withinY = (mouseLocationInWindow.y >= panelFrameInWindow.minY) && (mouseLocationInWindow.y <= panelFrameInWindow.maxY) - let visiblePanelMinX = panelFrameInWindow.minX + Self.floatingSidebarInset - return withinY && mouseLocationInWindow.x < visiblePanelMinX + guard let panelContent = floatingSidebarPanelContentView else { return false } + let contentFrameInWindow = panelContent.convert(panelContent.bounds, to: nil) + + let withinY = (mouseLocationInWindow.y >= contentFrameInWindow.minY) + && (mouseLocationInWindow.y <= contentFrameInWindow.maxY) + guard withinY else { return false } + if PhiPreferences.GeneralSettings.loadSidebarPosition() == .left { + return mouseLocationInWindow.x < contentFrameInWindow.minX + } + return mouseLocationInWindow.x > contentFrameInWindow.maxX } func isMouseInsideFloatingSidebarVisibleRegion() -> Bool { - guard let panel = floatingSidebarContainerView, panel.isHidden == false else { return false } - guard let window = panel.window else { return false } - let mouseLocationInWindow = window.mouseLocationOutsideOfEventStream - let panelFrameInWindow = panel.convert(panel.bounds, to: nil) - - let visiblePanelMinX = panelFrameInWindow.minX + Self.floatingSidebarInset - let withinY = (mouseLocationInWindow.y >= panelFrameInWindow.minY) && (mouseLocationInWindow.y <= panelFrameInWindow.maxY) - let withinX = (mouseLocationInWindow.x >= visiblePanelMinX) && (mouseLocationInWindow.x <= panelFrameInWindow.maxX) - return withinX && withinY + guard let panel = floatingSidebarContainerView, panel.isHidden == false, + let panelContent = floatingSidebarPanelContentView else { return false } + return isMouseInside(view: panelContent) } } diff --git a/Sources/UserInterface/WebContent/WebContentContainerViewController.swift b/Sources/UserInterface/WebContent/WebContentContainerViewController.swift index d5749d8d..48686192 100644 --- a/Sources/UserInterface/WebContent/WebContentContainerViewController.swift +++ b/Sources/UserInterface/WebContent/WebContentContainerViewController.swift @@ -224,17 +224,18 @@ class WebContentContainerViewController: NSViewController { /// Titlebar aware area for handling double-click on titlebar private var titleAwareArea = TitlebarAwareView() - /// Left-edge hover trigger for showing floating sidebar when main sidebar is collapsed. + /// Edge hover trigger for showing the floating sidebar when the docked sidebar is collapsed. lazy var floatingSidebarTriggerView = MouseTrackingAreaView() var floatingSidebarContainerView: NSView? + var floatingSidebarPanelContentView: NSView? var floatingSidebarViewController: FloatingSidebarViewController? - var floatingSidebarLeadingConstraint: Constraint? + var floatingSidebarHorizontalConstraint: Constraint? var floatingSidebarWidthConstraint: Constraint? var floatingSidebarHideWorkItem: DispatchWorkItem? var floatingSidebarEnableWorkItem: DispatchWorkItem? var floatingSidebarLastShownAt: Date? - var floatingSidebarShownFromRightToLeft = false + var floatingSidebarShownFromContentSide = false /// Hides the panel when its window leaves the screen (a Space switch /// orders the leaving window out with its panel still up) — without /// this the stale panel would greet the user when that window next @@ -339,12 +340,9 @@ class WebContentContainerViewController: NSViewController { } } - // Add left-edge hover trigger for floating sidebar. + // Add the configured edge hover trigger for the floating sidebar. view.addSubview(floatingSidebarTriggerView) - floatingSidebarTriggerView.snp.makeConstraints { make in - make.leading.top.bottom.equalToSuperview() - make.width.equalTo(Self.floatingSidebarTriggerWidth) - } + updateFloatingSidebarPlacement() setupFloatingSidebarTrigger() // Add titlebar aware area @@ -495,6 +493,15 @@ class WebContentContainerViewController: NSViewController { } .store(in: &cancellables) + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .map { _ in PhiPreferences.GeneralSettings.loadSidebarPosition() } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateFloatingSidebarPlacement() + } + .store(in: &cancellables) + // Listen to targetURL changes to update status bubble browserState?.$targetURL .receive(on: DispatchQueue.main) diff --git a/Tests/PhiBrowserTests/BrowserStateBookmarkLayoutTests.swift b/Tests/PhiBrowserTests/BrowserStateBookmarkLayoutTests.swift deleted file mode 100644 index ad50ac4e..00000000 --- a/Tests/PhiBrowserTests/BrowserStateBookmarkLayoutTests.swift +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright 2026 Phinomenon Inc. -// -// Use of this source code is governed by an Apache license that can be -// found in the LICENSE file. - -import AppKit -import XCTest -@testable import Phi - -@MainActor -final class BrowserStateBookmarkLayoutTests: XCTestCase { - private var tempDirectories: [URL] = [] - private var originalLayoutRawValue: String? - - override func setUpWithError() throws { - try super.setUpWithError() - originalLayoutRawValue = UserDefaults.standard.string(forKey: PhiPreferences.GeneralSettings.layoutModeKey) - PhiPreferences.GeneralSettings.saveLayoutMode(.performance) - } - - override func tearDownWithError() throws { - if let originalLayoutRawValue { - UserDefaults.standard.set(originalLayoutRawValue, - forKey: PhiPreferences.GeneralSettings.layoutModeKey) - } else { - UserDefaults.standard.removeObject(forKey: PhiPreferences.GeneralSettings.layoutModeKey) - } - NotificationCenter.default.post(name: UserDefaults.didChangeNotification, - object: UserDefaults.standard) - - for directory in tempDirectories { - try? FileManager.default.removeItem(at: directory) - } - tempDirectories.removeAll() - try super.tearDownWithError() - } - - func testSwitchingToComfortableDetachesOpenBookmarkTab() throws { - let state = try makeState() - let bookmarkGuid = "bookmark-comfortable" - let bookmark = try createBookmark(in: state, - guid: bookmarkGuid, - url: "https://bookmark.example") - let wrapper = BookmarkLayoutTestWebContentWrapper(urlString: "https://bookmark.example") - let bookmarkTab = Tab(guid: 101, - url: "https://bookmark.example", - isActive: true, - index: 0, - title: "Bookmark", - webContentView: wrapper, - customGuid: bookmarkGuid) - state.tabs = [bookmarkTab] - bookmark.isOpened = true - bookmark.isActive = true - bookmark.chromiumTabGuid = bookmarkTab.guid - bookmark.setWebContentWrapper(wrapper) - state.updateNormalTabs() - - XCTAssertEqual(bookmarkTab.guidInLocalDB, bookmarkGuid) - XCTAssertTrue(state.normalTabs.isEmpty) - - switchLayout(to: .comfortable) - XCTAssertTrue(waitUntil { - state.layoutMode == .comfortable && bookmarkTab.guidInLocalDB == nil - }) - - XCTAssertEqual(wrapper.customValues.last, "") - XCTAssertFalse(bookmark.isOpened) - XCTAssertFalse(bookmark.isActive) - XCTAssertEqual(bookmark.chromiumTabGuid, -1) - XCTAssertEqual(state.normalTabs.map(\.guid), [101]) - } - - func testSwitchingToComfortableDetachesSplitBookmarkBindingWithoutRemovingSplit() throws { - let state = try makeState() - let bookmarkGuid = "split-bookmark-comfortable" - let bookmark = try createBookmark(in: state, - guid: bookmarkGuid, - url: "https://primary.example", - secondaryUrl: "https://secondary.example") - let primary = Tab(guid: 201, - url: "https://primary.example", - isActive: true, - index: 0, - title: "Primary") - let secondary = Tab(guid: 202, - url: "https://secondary.example", - isActive: false, - index: 1, - title: "Secondary") - let split = SplitGroup(id: "split-comfortable", - primaryTabId: primary.guid, - secondaryTabId: secondary.guid, - layout: .vertical, - ratio: 0.5) - state.tabs = [primary, secondary] - state.splits = [split] - state.splitBookmarkBindings[bookmarkGuid] = split.id - state.syncSplitBookmarkOpenedState(bookmarkGuid: bookmarkGuid) - state.updateNormalTabs() - - XCTAssertTrue(bookmark.isOpened) - XCTAssertTrue(state.normalTabs.isEmpty) - - switchLayout(to: .comfortable) - XCTAssertTrue(waitUntil { - state.layoutMode == .comfortable && state.splitBookmarkBindings[bookmarkGuid] == nil - }) - - XCTAssertEqual(state.splits, [split]) - XCTAssertFalse(bookmark.isOpened) - XCTAssertEqual(bookmark.chromiumTabGuid, -1) - XCTAssertEqual(state.normalTabs.map(\.guid), [201, 202]) - } - - func testOpeningSplitBookmarkInComfortableDoesNotBindPendingSplitToBookmark() throws { - let state = try makeState() - let bookmarkGuid = "split-bookmark-open-comfortable" - let bookmark = try createBookmark(in: state, - guid: bookmarkGuid, - url: "https://primary.example", - secondaryUrl: "https://secondary.example") - state.layoutMode = .comfortable - - state.openBookmark(bookmark) - - XCTAssertEqual(state.pendingPrimarySplitTargetByGuid.count, 1) - XCTAssertNil(state.pendingPrimarySplitTargetByGuid.values.first?.boundBookmarkGuid) - } - - func testSwitchingToComfortableClearsPrimaryPendingSplitBookmarkBinding() throws { - let state = try makeState() - let bookmarkGuid = "split-bookmark-primary-pending" - let bookmark = try createBookmark(in: state, - guid: bookmarkGuid, - url: "https://primary.example", - secondaryUrl: "https://secondary.example") - - state.openBookmark(bookmark) - - let pendingGuid = try XCTUnwrap( - state.pendingPrimarySplitTargetByGuid.first { - $0.value.boundBookmarkGuid == bookmarkGuid - }?.key - ) - - switchLayout(to: .comfortable) - XCTAssertTrue(waitUntil { - state.pendingPrimarySplitTargetByGuid[pendingGuid]?.boundBookmarkGuid == nil - }) - - let primaryTab = Tab(guid: 301, - url: "https://primary.example", - isActive: true, - index: 0, - title: "Primary", - customGuid: pendingGuid) - state.consumePendingPrimarySplit(for: primaryTab) - - XCTAssertTrue(state.pendingSplitPartnerByCustomGuid.values.allSatisfy { - $0.boundBookmarkGuid == nil - }) - } - - func testSwitchingToComfortableClearsPartnerPendingSplitBookmarkBinding() throws { - let state = try makeState() - let bookmarkGuid = "split-bookmark-partner-pending" - let bookmark = try createBookmark(in: state, - guid: bookmarkGuid, - url: "https://primary.example", - secondaryUrl: "https://secondary.example") - state.openBookmark(bookmark) - let pendingGuid = try XCTUnwrap( - state.pendingPrimarySplitTargetByGuid.first { - $0.value.boundBookmarkGuid == bookmarkGuid - }?.key - ) - let primaryTab = Tab(guid: 401, - url: "https://primary.example", - isActive: true, - index: 0, - title: "Primary", - customGuid: pendingGuid) - state.consumePendingPrimarySplit(for: primaryTab) - let partnerPendingGuid = try XCTUnwrap( - state.pendingSplitPartnerByCustomGuid.first { - $0.value.boundBookmarkGuid == bookmarkGuid - }?.key - ) - - switchLayout(to: .comfortable) - XCTAssertTrue(waitUntil { - state.pendingSplitPartnerByCustomGuid[partnerPendingGuid]?.boundBookmarkGuid == nil - }) - } - - private func makeState() throws -> BrowserState { - let directory = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - tempDirectories.append(directory) - let store = LocalStore(account: Account(userID: UUID().uuidString), - storeDirectoryURL: directory) - return BrowserState(windowId: 7, localStore: store, profileId: "Default") - } - - private func createBookmark(in state: BrowserState, - guid: String, - url: String, - secondaryUrl: String? = nil) throws -> Bookmark { - state.localStore.createBookmark(url: url, - title: "Bookmark", - profileId: state.profileId, - parentId: nil, - guid: guid, - spaceId: state.spaceId, - secondaryUrl: secondaryUrl) - XCTAssertTrue(waitUntil { - state.bookmarkManager.bookmark(withGuid: guid) != nil - }) - return try XCTUnwrap(state.bookmarkManager.bookmark(withGuid: guid)) - } - - private func switchLayout(to mode: LayoutMode) { - PhiPreferences.GeneralSettings.saveLayoutMode(mode) - NotificationCenter.default.post(name: UserDefaults.didChangeNotification, - object: UserDefaults.standard) - } - - private func waitUntil(timeout: TimeInterval = 1, - condition: () -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { return true } - RunLoop.main.run(until: Date().addingTimeInterval(0.01)) - } - return false - } -} - -private final class BookmarkLayoutTestWebContentWrapper: NSObject, WebContentWrapper { - @objc dynamic weak var nativeView: NSView? - @objc dynamic var isLoading = false - @objc dynamic var loadingState = PhiTabLoadingState(rawValue: 0)! - @objc dynamic var isFocused = false - @objc dynamic var loadProgress: CGFloat = 1 - @objc dynamic var favIconURL: String? - @objc dynamic var favIconData: Data? - @objc dynamic var favIconRevision = 0 - @objc dynamic var canGoBack = false - @objc dynamic var canGoForward = false - @objc dynamic var title: String? - @objc dynamic var urlString: String? - @objc dynamic var securityInfo: [String: Any]? - @objc dynamic var isCurrentlyAudible = false - @objc dynamic var isAudioMuted = false - @objc dynamic var isCapturingAudio = false - @objc dynamic var isCapturingVideo = false - @objc dynamic var isCapturingWindow = false - @objc dynamic var isCapturingDisplay = false - @objc dynamic var isCapturingTab = false - @objc dynamic var isBeingMirrored = false - @objc dynamic var isSharingScreen = false - @objc dynamic var isInContentFullscreen = false - - private(set) var customValues: [String] = [] - - init(urlString: String?) { - self.urlString = urlString - super.init() - } - - func close() {} - func reload() {} - func reloadBypassingCache() {} - func goBack() {} - func goForward() {} - func stopLoading() {} - func navigate(toURL urlString: String) { self.urlString = urlString } - func setAsActiveTab() {} - func moveSelf(to newIndex: Int, selectAfterMove: Bool) {} - func moveSelf(toNewWindow activateNewWindow: Bool) {} - func moveSelf(toWindow targetWindowId: Int64, at insertIndex: Int) {} - func moveSelf(toWindow targetWindowId: Int64, - andAddToGroupTokenHex targetGroupTokenHex: String, - beforeTabId anchorTabId: Int64) {} - func moveSelf(toWindow targetWindowId: Int64, - andAddToGroupTokenHex targetGroupTokenHex: String, - afterTabId anchorTabId: Int64) {} - func moveSplit(toNewWindow activateNewWindow: Bool) {} - func moveSplit(toWindow targetWindowId: Int64, at insertIndex: Int) {} - func updateTabCustomValue(_ customValue: String) { customValues.append(customValue) } - func focus() {} - func restoreFocus() {} - func updateSecurityState(_ securityState: [AnyHashable: Any]) {} - func setAudioMuted(_ muted: Bool) {} - func muteAudio() {} - func unmuteAudio() {} -} diff --git a/Tests/PhiBrowserTests/PhiBrowserTests.swift b/Tests/PhiBrowserTests/PhiBrowserTests.swift index 8a1033f9..1ceb03f9 100644 --- a/Tests/PhiBrowserTests/PhiBrowserTests.swift +++ b/Tests/PhiBrowserTests/PhiBrowserTests.swift @@ -8,6 +8,44 @@ import AppKit @testable import Phi final class PhiBrowserTests: XCTestCase { + func testLuaNormalizesLegacyLayoutChoicesToPerformance() { + let defaults = UserDefaults.standard + let key = PhiPreferences.GeneralSettings.layoutModeKey + let original = defaults.object(forKey: key) + defer { + if let original { + defaults.set(original, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + defaults.set(LayoutMode.comfortable.rawValue, forKey: key) + + XCTAssertEqual(PhiPreferences.GeneralSettings.loadLayoutMode(), .performance) + XCTAssertEqual(defaults.string(forKey: key), LayoutMode.performance.rawValue) + XCTAssertEqual(LayoutMode.allCases, [.performance]) + } + + func testSidebarPositionDefaultsLeftAndPersistsRight() { + let defaults = UserDefaults.standard + let key = PhiPreferences.GeneralSettings.sidebarPositionKey + let original = defaults.object(forKey: key) + defer { + if let original { + defaults.set(original, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + defaults.removeObject(forKey: key) + XCTAssertEqual(PhiPreferences.GeneralSettings.loadSidebarPosition(), .left) + + PhiPreferences.GeneralSettings.saveSidebarPosition(.right) + XCTAssertEqual(PhiPreferences.GeneralSettings.loadSidebarPosition(), .right) + } + func testCopyURLShortcutIsCustomizableFromEditShortcuts() { XCTAssertEqual( Shortcuts.DefaultShortcuts[.PHI_COPY_URL], diff --git a/script/verify b/script/verify index 1e97dc1f..f4547507 100755 --- a/script/verify +++ b/script/verify @@ -43,6 +43,16 @@ plutil -extract CFBundleURLTypes json -o - "$info" | grep -q 'lua' || { echo "LuaIcon.icns was not compiled into the app." >&2 exit 1 } +tracking_protection="$APP/Contents/Resources/TrackingProtection" +[[ -f "$tracking_protection/manifest.json" ]] || { + echo "Lua Tracking Protection is missing from the release bundle." >&2 + exit 1 +} +plutil -convert xml1 -o /dev/null "$tracking_protection/manifest.json" +grep -q '"url-cleaner.js", "content.js"' "$tracking_protection/manifest.json" || { + echo "Lua Tracking Protection content scripts are not configured." >&2 + exit 1 +} if grep -R -E 'ota\.phibrowser\.com|account\.phibrowser\.com|ai\.phibrowser\.com' "$APP/Contents/Resources" || \ strings -a "$APP/Contents/MacOS/Lua" | grep -E 'ota\.phibrowser\.com|account\.phibrowser\.com|ai\.phibrowser\.com'; then